Module 7: Monitoring, Notifications, and Advanced Patterns
5. Reusable Workflows
Overview
You have 3 AI microservices: a chatbot, a document classifier, and a report generator. All three use Python, all three have the same CI flow (lint → test → AI checks), and all three use Docker for deployment. The CI YAML is practically identical in all 3 repos — with small differences like the service's name or the Python version.
When you need to add a security scanning step to the pipeline, you have to modify 3 files in 3 repos. When you update Python from 3.11 to 3.12, that's 3 PRs. A month later you discover that one of the repos was never updated because the PR got forgotten. You're maintaining 3 copies of the same pipeline, and each copy silently diverges from the others.
Reusable workflows solve this. You define the workflow once in a central repo, and the other repos call it with workflow_call. When you update the central workflow, every repo that uses it gets the change automatically. It's DRY applied to CI/CD — not for aesthetics, but for operability.
Connection with the final pipeline: In the capstone pipeline (Module 8), the reusable workflows let CI, the AI checks, and CD share the same logic with no duplication — one change to the workflow propagates to every pipeline.
workflow_call: The reusable workflow trigger
What is workflow_call?
It's a trigger that turns a workflow into a "function" other workflows can call:
# The reusable workflow (the definition)
on:
workflow_call:
inputs:
python-version:
type: string
default: "3.12"
secrets:
openai-api-key:
required: true
# The calling workflow (the consumer)
jobs:
ci:
uses: org/shared-workflows/.github/workflows/ai-ci.yml@main
with:
python-version: "3.12"
secrets:
openai-api-key: ${{ secrets.OPENAI_API_KEY }}
The analogy
Think of a reusable workflow as a function:
A Python function:
def run_ci(python_version="3.12", api_key=None):
lint()
test()
ai_checks(api_key)
A reusable workflow:
workflow_call:
inputs: { python-version: "3.12" }
secrets: { openai-api-key }
jobs:
lint: ...
test: ...
ai-checks: ...
Creating a reusable workflow
Step 1: The reusable workflow
# Repo: org/shared-workflows
# File: .github/workflows/ai-ci.yml
name: Reusable AI CI Pipeline
on:
workflow_call:
inputs:
python-version:
description: "Python version to use"
type: string
default: "3.12"
run-ai-checks:
description: "Whether to run AI-specific checks"
type: boolean
default: true
working-directory:
description: "Directory containing the project"
type: string
default: "."
secrets:
openai-api-key:
description: "OpenAI API key for AI checks"
required: false
jobs:
lint:
name: "Lint & Format"
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
- name: Install linting tools
run: pip install ruff
- name: Run linter
working-directory: ${{ inputs.working-directory }}
run: ruff check .
- name: Check formatting
working-directory: ${{ inputs.working-directory }}
run: ruff format --check .
test:
name: "Tests"
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
cache: pip
- name: Install dependencies
working-directory: ${{ inputs.working-directory }}
run: pip install -r requirements.txt
- name: Run tests
working-directory: ${{ inputs.working-directory }}
run: pytest tests/ -v --tb=short
ai-checks:
name: "AI Checks"
runs-on: ubuntu-latest
timeout-minutes: 15
if: ${{ inputs.run-ai-checks }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
cache: pip
- name: Install dependencies
working-directory: ${{ inputs.working-directory }}
run: pip install -r requirements.txt
- name: Run prompt regression
working-directory: ${{ inputs.working-directory }}
env:
OPENAI_API_KEY: ${{ secrets.openai-api-key }}
run: python scripts/prompt_regression.py --mode check
- name: Run cost estimation
working-directory: ${{ inputs.working-directory }}
env:
OPENAI_API_KEY: ${{ secrets.openai-api-key }}
run: python scripts/cost_estimation.py --threshold 0.10
Step 2: Calling it from another repo
# Repo: org/chatbot-service
# File: .github/workflows/ci.yml
name: CI Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
ci:
uses: org/shared-workflows/.github/workflows/ai-ci.yml@main
with:
python-version: "3.12"
run-ai-checks: true
secrets:
openai-api-key: ${{ secrets.OPENAI_API_KEY }}
Step 3: Calling it from another repo with differences
# Repo: org/document-classifier
# File: .github/workflows/ci.yml
name: CI Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
ci:
uses: org/shared-workflows/.github/workflows/ai-ci.yml@main
with:
python-version: "3.11" # This repo still uses 3.11
run-ai-checks: false # It has no prompt regression
secrets:
openai-api-key: ${{ secrets.OPENAI_API_KEY }}
Inputs and Secrets
The types of inputs
| Type | Example | Use |
|---|---|---|
string | "3.12" | Versions, names, paths |
boolean | true | Feature flags |
number | 15 | Timeouts, thresholds |
on:
workflow_call:
inputs:
python-version:
type: string
default: "3.12"
run-ai-checks:
type: boolean
default: true
timeout-minutes:
type: number
default: 15
Secrets
Secrets get passed explicitly — the reusable workflow has no automatic access to the calling repo's secrets:
on:
workflow_call:
secrets:
openai-api-key:
required: true
slack-webhook:
required: false
secrets: inherit
If you don't want to list each secret explicitly, you can pass all the repo's secrets:
jobs:
ci:
uses: org/shared-workflows/.github/workflows/ai-ci.yml@main
with:
python-version: "3.12"
secrets: inherit # It passes ALL the repo's secrets
The trade-off:
| Aspect | Explicit secrets | secrets: inherit |
|---|---|---|
| Security | It only passes what's needed | It passes everything |
| Clarity | Documented which secrets get used | Opaque |
| Maintenance | Update it when a secret gets added | Automatic |
| Recommended | For cross-org workflows | For workflows within the same org |
Reusable workflow outputs
A reusable workflow can return outputs to the workflow that called it:
# A reusable workflow with outputs
on:
workflow_call:
outputs:
test-result:
description: "Result of tests"
value: ${{ jobs.test.outputs.result }}
coverage:
description: "Test coverage percentage"
value: ${{ jobs.test.outputs.coverage }}
jobs:
test:
runs-on: ubuntu-latest
outputs:
result: ${{ steps.test.outputs.result }}
coverage: ${{ steps.coverage.outputs.pct }}
steps:
- id: test
run: |
pytest tests/ -v
echo "result=passed" >> $GITHUB_OUTPUT
- id: coverage
run: |
COV=$(pytest tests/ --cov=src --cov-report=term | grep TOTAL | awk '{print $4}')
echo "pct=$COV" >> $GITHUB_OUTPUT
# The consumer that uses the outputs
jobs:
ci:
uses: org/shared-workflows/.github/workflows/ai-ci.yml@main
secrets: inherit
post-ci:
needs: ci
runs-on: ubuntu-latest
steps:
- run: |
echo "Tests: ${{ needs.ci.outputs.test-result }}"
echo "Coverage: ${{ needs.ci.outputs.coverage }}"
Versioning reusable workflows
The referencing strategies
# By branch (the latest, potentially unstable)
uses: org/shared-workflows/.github/workflows/ai-ci.yml@main
# By tag (versioned, stable)
uses: org/shared-workflows/.github/workflows/ai-ci.yml@v1.0.0
# By SHA (immutable, maximum security)
uses: org/shared-workflows/.github/workflows/ai-ci.yml@abc1234567890
The recommendation
| Strategy | When to use it |
|---|---|
@main | Active development, total confidence in main |
@v1 (major) | Production — only breaking changes bump the major |
@v1.2.0 (exact) | Maximum control, manual updates |
@sha | Critical security, a supply chain attack concern |
For most teams, @v1 is the sweet spot: you get bug fixes and minor features automatically, but breaking changes require you to explicitly update to @v2.
When to use reusable workflows vs inline
Use reusable workflows when:
- ✅ Multiple repos share the same flow. 3+ repos with the same CI pipeline
- ✅ The flow changes frequently. If you update the pipeline every week, centralizing saves time
- ✅ You need consistency. Every repo must have exactly the same quality gate
- ✅ A large team. Several developers maintain multiple repos
Use inline (a local workflow) when:
- ✅ A single repo. There's no benefit in centralizing if you only have one repo
- ✅ A very specific flow. The pipeline is so custom that it doesn't apply to other repos
- ✅ Fast iteration. You're experimenting with the pipeline and don't want to affect other repos
- ✅ A small team. 1-2 people, 1-2 repos — the overhead of maintaining a shared repo isn't worth it
The decision tree
How many repos use the same CI flow?
├─ 1 repo → Inline (there's no benefit in centralizing)
├─ 2 repos → It depends (if the flow is identical, consider reusable)
└─ 3+ repos → A reusable workflow (the maintainability justifies it)
Cross-repo reusable workflows
A public repository
If the repo with the reusable workflows is public, any repo can call it:
uses: public-org/shared-workflows/.github/workflows/ci.yml@v1
A private repository (the same org)
For private repos within the same organization, you need to enable access:
The shared-workflows repo → Settings → Actions → General
→ "Allow access from private repositories in the organization"
A private repository (a different org)
It isn't possible directly. The options are:
- Make the repo public
- Duplicate the workflow in each org
- Use GitHub Apps with installation tokens (out of scope for this guide)
Comparisons
Reusable workflows vs Composite actions
| Aspect | Reusable Workflow | Composite Action |
|---|---|---|
| Scope | A complete workflow (multiple jobs) | Steps within a job |
| Trigger | workflow_call | uses: in a step |
| Its own jobs | Yes (it can define several jobs) | No (it runs inside the caller's job) |
| Runners | It can choose its own runner | It uses the caller's runner |
| When to use it | A complete shared flow | Common shared steps |
Reusable workflows vs Templates
| Aspect | Reusable Workflow | A template (copying the YAML) |
|---|---|---|
| Updating | Automatic (you change it once) | Manual (you change it in every repo) |
| Divergence | Impossible | Inevitable |
| Flexibility | Inputs parameterize it | You modify it freely |
| Setup | A shared repo + a reference | Copying a file |
Troubleshooting
"Error: could not find a workflow"
Cause: The reference to the reusable workflow is badly formatted, or the file doesn't exist.
Solution: Verify the complete path:
# The correct format
uses: {owner}/{repo}/.github/workflows/{file}@{ref}
# An example
uses: org/shared-workflows/.github/workflows/ai-ci.yml@main
Verify that the file exists at that exact path in the referenced repo.
"Error: permission denied"
Cause: The repo with the reusable workflow is private and doesn't have access enabled.
Solution: In the reusable workflow's repo: Settings → Actions → General → "Allow access from private repositories in the organization."
"The secrets aren't available in the reusable workflow"
Cause: Secrets don't get passed automatically. You need to pass them explicitly or use secrets: inherit.
Solution:
jobs:
ci:
uses: org/shared-workflows/.github/workflows/ai-ci.yml@main
secrets:
openai-api-key: ${{ secrets.OPENAI_API_KEY }}
# Or use secrets: inherit to pass all of them
"The reusable workflow doesn't reflect my latest changes"
Cause: You're referencing a fixed version (@v1.0.0) that doesn't include your changes.
Solution: Update the reference to the new tag or use @main temporarily for testing.
Exercises
Exercise 1: Create a basic reusable workflow
Create a reusable workflow that lints and tests, parameterized with the Python version.
See solution
# .github/workflows/reusable-ci.yml
name: Reusable CI
on:
workflow_call:
inputs:
python-version:
type: string
default: "3.12"
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
- run: pip install ruff
- run: ruff check .
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
cache: pip
- run: pip install -r requirements.txt
- run: pytest tests/ -v
Exercise 2: Call the reusable workflow from another workflow
Write the workflow that calls Exercise 1's reusable workflow, passing Python 3.11.
See solution
# .github/workflows/ci.yml
name: CI Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
ci:
uses: ./.github/workflows/reusable-ci.yml
with:
python-version: "3.11"
Note: ./.github/workflows/reusable-ci.yml is the reference for a reusable workflow in the same repo. For cross-repo it would be org/repo/.github/workflows/reusable-ci.yml@main.
Exercise 3: Add secrets and optional AI checks
Extend the reusable workflow to include optional AI checks controlled by a boolean input.
See solution
on:
workflow_call:
inputs:
python-version:
type: string
default: "3.12"
run-ai-checks:
type: boolean
default: false
secrets:
openai-api-key:
required: false
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
- run: pip install ruff && ruff check .
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
cache: pip
- run: pip install -r requirements.txt
- run: pytest tests/ -v
ai-checks:
runs-on: ubuntu-latest
if: ${{ inputs.run-ai-checks }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
cache: pip
- run: pip install -r requirements.txt
- run: python scripts/prompt_regression.py
env:
OPENAI_API_KEY: ${{ secrets.openai-api-key }}
The caller:
jobs:
ci:
uses: ./.github/workflows/reusable-ci.yml
with:
python-version: "3.12"
run-ai-checks: true
secrets:
openai-api-key: ${{ secrets.OPENAI_API_KEY }}
Exercise 4: A reusable workflow or inline?
For each scenario, decide whether you would use a reusable workflow or an inline workflow:
- A single repo with a unique machine learning training pipeline
- 5 microservices with the same lint → test → deploy flow
- An experimentation repo where you change the pipeline every day
- 3 repos in the same org, the same flow, a team of 8 developers
See solution
- Inline. A single repo, a unique pipeline — there's no benefit in centralizing.
- A reusable workflow. 5 repos with the same flow — the maintainability justifies the setup.
- Inline. Fast iteration — the overhead of updating a shared repo isn't worth it when you change the pipeline daily.
- A reusable workflow. 3 repos, a large team — consistency is critical. One change to the workflow propagates to all 3 repos automatically.
Summary
- ✅ Reusable workflows turn a workflow into a function other workflows can call with
workflow_call - ✅ Inputs parameterize the workflow (string, boolean, number), secrets get passed explicitly or with
inherit - ✅ Outputs let the reusable workflow return data to the caller
- ✅ Versioning with tags (
@v1) is the sweet spot between stability and automatic updates - ✅ Cross-repo works in public repos and in private repos of the same org (it requires configuration)
- ✅ Use reusable workflows for 3+ repos with the same flow; use inline for single repos or fast iteration
- ✅ Reusable workflows vs composite actions: workflows for complete flows, composite actions for individual steps
- ✅ DRY in CI/CD isn't aesthetics — it's operability: one change, automatic propagation
Additional resources
- GitHub Actions — Reusing Workflows - The complete official documentation
- GitHub Actions — workflow_call - The trigger's reference
- GitHub Actions — Workflow Inputs - Input syntax
- GitHub Actions — Sharing Workflows - Sharing within an org
- Reusable Workflows Best Practices - GitHub's official blog
- GitHub Actions Starter Workflows - Official workflow examples