Module 5: Project — Complete CI/CD Pipeline
3. Reusable workflows
What this capsule covers
Your pipeline has 300+ lines of YAML in a single file (ci.yml). When you add a new deploy target or modify the Python setup, you touch several duplicated blocks. And if in the future you have a second repo with a similar pipeline, you copy it all again. It's time to apply DRY (Don't Repeat Yourself) in CI/CD.
This capsule teaches you reusable workflows with workflow_call — the pattern that lets you define a workflow once and call it from others. You're going to refactor your pipeline into modules: _reusable-tests.yml, _reusable-quality.yml, _reusable-deploy.yml. Same behavior, much more maintainable and reusable between repos.
By the end, you'll be able to:
- Create reusable workflows with the
workflow_calltrigger - Pass inputs and outputs between workflows
- Pass secrets securely to reusable workflows
- Refactor your monolithic pipeline into modules
- Reuse workflows between repos (with cross-repo considerations)
- Decide when to extract a job as reusable vs leave it inline
The problem: duplicated YAML and pipelines that are hard to maintain
Your current pipeline:
# .github/workflows/ci.yml — 300+ lines
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- run: pip install -e ".[dev]"
- run: pytest -v --cov=app --cov-fail-under=80
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- run: pip install -e ".[dev]"
- uses: pre-commit/action@v3.0.1
# ... 200 more lines of deploy-dev, deploy-staging, deploy-production
Problems:
-
Duplication: the "setup Python + install deps" steps appear in
test,quality,deploy-*. Any change (e.g., adding--upgrade pip) requires editing several places. -
Hard to read: 300+ lines in one file. Finding bugs requires scrolling.
-
Not reusable between repos: if you have another Python repo with a similar pipeline, you copy it all and maintain two misaligned versions.
-
Hard to test: modifying the workflow requires opening a PR; you can't "test the workflow" without triggering the whole pipeline.
Reusable workflows solve this.
The mental model: workflows as functions
Think of a reusable workflow as a function in programming:
# Before (without a function):
print("Setting up...")
run_setup()
run_tests("python3.10")
print("Setting up...")
run_setup()
run_tests("python3.11")
print("Setting up...")
run_setup()
run_tests("python3.12")
# After (with a function):
def setup_and_test(version):
print("Setting up...")
run_setup()
run_tests(version)
setup_and_test("python3.10")
setup_and_test("python3.11")
setup_and_test("python3.12")
Reusable workflows = YAML functions.
# Before (without reusable):
# 300 lines with duplicated steps
# After (with reusable):
jobs:
test:
uses: ./.github/workflows/_reusable-tests.yml
with:
python-versions: '["3.10", "3.11", "3.12"]'
quality:
uses: ./.github/workflows/_reusable-quality.yml
deploy:
uses: ./.github/workflows/_reusable-deploy.yml
with:
environment: production
secrets: inherit
Same behavior, much less code in the caller. The detail lives in the _reusable-*.yml files.
Step 1: file structure
Organize the workflows like this:
.github/workflows/
├── ci.yml # entry point: runs on PRs and main
├── cd.yml # entry point: runs only on main
├── _reusable-tests.yml # called by ci.yml
├── _reusable-quality.yml # called by ci.yml
├── _reusable-build-push.yml # called by cd.yml
├── _reusable-deploy.yml # called by cd.yml (3 times — dev/staging/prod)
└── rollback.yml # manual trigger
Convention:
- Workflows with the
_prefix are internal (reusable, not triggered directly) - Workflows without a prefix are entry points (triggered by events)
Step 2: your first reusable workflow
Start with the simplest: the test job. Extract it to _reusable-tests.yml:
# .github/workflows/_reusable-tests.yml
name: Reusable tests
on:
workflow_call:
inputs:
python-versions:
description: 'JSON array of Python versions to test'
required: false
default: '["3.10", "3.11", "3.12"]'
type: string
coverage-threshold:
description: 'Minimum coverage percentage'
required: false
default: 80
type: number
jobs:
test:
name: Test Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ${{ fromJSON(inputs.python-versions) }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
cache-dependency-path: 'pyproject.toml'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: Run tests
run: |
pytest -v \
--cov=app --cov-branch \
--cov-report=term-missing \
--cov-fail-under=${{ inputs.coverage-threshold }} \
-m "not slow"
Key characteristics:
on: workflow_call:
This trigger makes the workflow reusable. It isn't triggered by normal events — only when another workflow calls it.
inputs:
Parameters the caller can pass:
python-versions: an array of versions (default: 3.10, 3.11, 3.12)coverage-threshold: a number (default: 80)
Supported types: string, number, boolean, choice.
The internal job uses the inputs
python-version: ${{ matrix.python-version }}
# matrix.python-version comes from the array the caller passed
Step 3: the caller
ci.yml now calls the reusable one:
# .github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
uses: ./.github/workflows/_reusable-tests.yml
# We don't need to pass inputs — it uses the defaults
quality:
uses: ./.github/workflows/_reusable-quality.yml
ci-success:
name: CI Success
needs: [test, quality]
if: always()
runs-on: ubuntu-latest
steps:
- if: needs.test.result != 'success' || needs.quality.result != 'success'
run: exit 1
- run: echo "✅ CI passed"
From 300 lines to 25. Same functionality. The complexity is in the _reusable-*.yml files.
Step 4: reusable workflow with secrets
The deploy job needs specific secrets. Reusable workflows receive secrets in three ways:
Way A: explicit secrets as inputs
# _reusable-deploy.yml
on:
workflow_call:
inputs:
environment:
required: true
type: string
secrets:
RAILWAY_TOKEN:
required: true
DATABASE_URL:
required: true
DEPLOY_URL:
required: true
The caller passes them explicitly:
deploy-production:
uses: ./.github/workflows/_reusable-deploy.yml
with:
environment: production
secrets:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
DEPLOY_URL: ${{ secrets.DEPLOY_URL }}
Pros:
- Explicit: the caller declares which secrets it passes
- Safe: the reusable doesn't receive more secrets than it needs
Cons:
- Verbose if there are many secrets
Way B: secrets: inherit (the simplest)
deploy-production:
uses: ./.github/workflows/_reusable-deploy.yml
with:
environment: production
secrets: inherit
inherit makes all the caller's secrets available in the reusable one. Much simpler.
Pros:
- One line
- If you add a new secret, you don't need to update the caller
Cons:
- Less explicit (the reusable receives all the secrets, not just the ones it needs)
Recommendation: secrets: inherit for internal reusable workflows (same repo). Way A for workflows you want to reuse between repos with different secrets.
Step 5: complete reusable workflow for deploy
# .github/workflows/_reusable-deploy.yml
name: Reusable deploy
on:
workflow_call:
inputs:
environment:
description: 'Target environment (dev/staging/production)'
required: true
type: string
image-tag:
description: 'Docker image tag to deploy'
required: false
default: 'latest'
type: string
jobs:
deploy:
name: Deploy to ${{ inputs.environment }}
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- run: pip install -e ".[dev]"
- name: Run migrations
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: alembic upgrade head
- name: Redeploy Railway
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
run: |
npm install -g @railway/cli
railway redeploy --service ${{ secrets.RAILWAY_SERVICE_ID }}
- name: Health check
run: |
DEPLOY_URL="${{ secrets.DEPLOY_URL }}"
for i in $(seq 1 24); do
CODE=$(curl -s -o /dev/null -w "%{http_code}" "$DEPLOY_URL/health" || echo "000")
[ "$CODE" = "200" ] && exit 0
sleep 5
done
exit 1
The trick: environment: ${{ inputs.environment }} makes the job use the secrets of the corresponding environment. Same workflow, different secrets according to the input.
Step 6: the cd.yml caller
# .github/workflows/cd.yml
name: CD
on:
push:
branches: [main]
jobs:
# Reuse the CI (runs again on main as a safety measure)
ci:
uses: ./.github/workflows/ci.yml
build-and-push:
needs: ci
uses: ./.github/workflows/_reusable-build-push.yml
deploy-dev:
needs: build-and-push
uses: ./.github/workflows/_reusable-deploy.yml
with:
environment: dev
secrets: inherit
deploy-staging:
needs: deploy-dev
uses: ./.github/workflows/_reusable-deploy.yml
with:
environment: staging
secrets: inherit
deploy-production:
needs: deploy-staging
uses: ./.github/workflows/_reusable-deploy.yml
with:
environment: production
secrets: inherit
Three calls to the same reusable workflow. Each with a different environment. DRY to the max.
If in the future you add a qa environment:
deploy-qa:
needs: deploy-dev
uses: ./.github/workflows/_reusable-deploy.yml
with:
environment: qa
secrets: inherit
One more line in cd.yml. Zero changes in _reusable-deploy.yml.
Step 7: outputs between workflows
Sometimes a reusable workflow produces data the caller needs:
# _reusable-build-push.yml
on:
workflow_call:
outputs:
image-tag:
description: 'The tag of the pushed image'
value: ${{ jobs.build.outputs.tag }}
jobs:
build:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.meta.outputs.tags }}
steps:
# ... build steps
- id: meta
run: |
TAG="ghcr.io/${{ github.repository }}:sha-${{ github.sha }}"
echo "tags=$TAG" >> $GITHUB_OUTPUT
The caller uses the output:
# cd.yml
jobs:
build:
uses: ./.github/workflows/_reusable-build-push.yml
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "Deploying image ${{ needs.build.outputs.image-tag }}"
A useful pattern: a workflow produces something (image tag, version number, build artifact path) and the following ones consume it.
Step 8: refactoring your current pipeline
A step-by-step migration plan:
Phase 1: extract _reusable-tests.yml
- Create
_reusable-tests.ymlwith the content of thetestjob - Modify
ci.ymlto call it - Push to a branch, open a PR
- Verify that CI keeps working identically
- If OK, merge
Phase 2: extract _reusable-quality.yml
Same process. Iterative and safe.
Phase 3: extract _reusable-build-push.yml
Phase 4: extract _reusable-deploy.yml
This is the most impactful — a single reusable used three times (dev/staging/production).
Phase 5: split ci.yml vs cd.yml
Originally everything is in ci.yml. Split:
ci.yml: only jobs that run on PRs and main (test, quality)cd.yml: only jobs that run on main (build, deploy)
Benefit: PRs only trigger ci.yml (without triggering cd.yml). A faster pipeline for PR feedback.
When NOT to use reusable workflows
Reusable workflows add complexity. Don't use them if:
1. The job is used only once
# ci.yml — the linting job only appears here
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pre-commit/action@v3.0.1
Extracting this to _reusable-quality.yml adds no value — it just adds one more file. Keep it inline.
2. The job is trivial (< 5 lines)
If the job has 1-2 steps, the overhead of separate files > the benefit.
3. The inputs make the workflow more complex than the duplication
# If you need 15 inputs for the reusable to work,
# it's probably simpler to duplicate the job
When you reach 15+ inputs, the abstraction is wrong — they're probably two different workflows disguised as one.
4. The work requires shared state between steps
Reusable workflows have isolated jobs. If your work requires sharing complex state between steps, consider custom actions (composite actions) instead of reusable workflows.
Composite actions: the alternative for repeated steps
There's another pattern: composite actions. Useful when you want to reuse steps, not complete jobs.
.github/actions/setup-python-deps/action.yml
# .github/actions/setup-python-deps/action.yml
name: 'Setup Python + dependencies'
description: 'Sets up Python and installs dev dependencies'
inputs:
python-version:
description: 'Python version'
required: true
runs:
using: composite
steps:
- uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
cache: 'pip'
cache-dependency-path: 'pyproject.toml'
- shell: bash
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
Usage:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-python-deps
with:
python-version: '3.12'
- run: pytest
Differences:
| Reusable Workflow | Composite Action |
|---|---|
| Reuses complete jobs | Reuses steps within a job |
Triggered with uses: workflow.yml | Triggered with uses: ./.github/actions/X |
| Has its own job context (env, runs-on) | Runs inside the caller's job |
Can have needs:, matrix, etc. | No (it's part of the caller's job) |
| More suitable for: deploy, build pipelines | More suitable for: reusable setup steps |
Use both: reusable workflows for jobs (test, deploy), composite actions for common setup steps.
Common traps
1. Wrong path to the reusable workflow
uses: .github/workflows/_reusable-tests.yml # ❌ without ./
How to handle it: the path ALWAYS starts with ./ for reusable workflows in the same repo:
uses: ./.github/workflows/_reusable-tests.yml # ✅
For reusable workflows from another repo:
uses: organization/repo/.github/workflows/workflow.yml@main
# or
uses: organization/repo/.github/workflows/workflow.yml@v1.0.0
2. Secrets not inherited
deploy:
uses: ./.github/workflows/_reusable-deploy.yml
# missing secrets: inherit
The reusable one runs but ${{ secrets.X }} is empty.
How to handle it: add secrets: inherit or declare explicit secrets.
3. Input typo
deploy:
uses: ./.github/workflows/_reusable-deploy.yml
with:
environement: production # typo: environement vs environment
GitHub doesn't validate the input name. The reusable receives an empty inputs.environment.
How to handle it: test the reusable on a PR before merging. Use an IDE with YAML validation (e.g., the GitHub Actions VS Code extension).
4. Misaligned versions in cross-repo
If the reusable lives in org/shared-workflows:
uses: org/shared-workflows/.github/workflows/test.yml@v1.0.0
If the maintainers update to v1.1.0, you stay on v1.0.0. You have to merge it manually.
How to handle it:
- Use
@mainfor always-latest (with the risk of breaking) - Use semver tags (
@v1.0.0) for stability - Dependabot updates these refs automatically (capsule 05)
5. Reusable workflow without testing
Reusable workflows are also code. If they have bugs, all the repos that use them break.
How to handle it:
- The CI of the reusable workflow's repo includes a job that calls the workflow itself
- Integration tests: a small "consumer" repo that uses the workflow and verifies it works
- Semantic versioning of the reusable: never breaking changes in patches
Worked case: refactor from 300 lines to modular
Before (monolith):
.github/workflows/
└── ci.yml (350 lines, 8 jobs)
After (modular):
.github/workflows/
├── ci.yml (35 lines)
├── cd.yml (45 lines)
├── _reusable-tests.yml (40 lines)
├── _reusable-quality.yml (25 lines)
├── _reusable-build-push.yml (50 lines)
└── _reusable-deploy.yml (60 lines)
Total LOC: 255 lines instead of 350 (25% less from eliminating duplication).
Future changes:
- "Add Python 3.13 to the matrix": edit one place (
_reusable-tests.yml) - "Add a Slack notification to the deploy": edit one place (
_reusable-deploy.yml) - "Add a QA environment": edit one place (
cd.yml), add 5 lines
Compound benefit: each change touches less code → less chance of bugs → more iteration speed.
Exercise: refactoring your pipeline
-
Identify the jobs that would benefit most from extraction:
- ✅
test: used in CI - ✅
quality: used in CI - ✅
deploy-*: 3 almost-identical jobs (dev/staging/prod) → clear candidate - ❌
ci-success: trivial, keep inline - ❌
build-and-push: used only once, marginal
- ✅
-
Extract
_reusable-deploy.yml(highest impact). Verify that CI stays green. -
Split
ci.ymlvscd.yml. -
Iterate with
_reusable-tests.yml,_reusable-quality.yml. -
Measure:
- Total LOC before vs after
- Pipeline execution time (should be similar)
- Time to make a future change (should be less)
Solution: final structure
.github/workflows/
├── ci.yml
├── cd.yml
├── rollback.yml
├── _reusable-tests.yml
├── _reusable-quality.yml
├── _reusable-build-push.yml
└── _reusable-deploy.yml
ci.yml:
name: CI
on:
pull_request: { branches: [main] }
push: { branches: [main] }
jobs:
test:
uses: ./.github/workflows/_reusable-tests.yml
quality:
uses: ./.github/workflows/_reusable-quality.yml
ci-success:
needs: [test, quality]
if: always()
runs-on: ubuntu-latest
steps:
- if: needs.test.result != 'success' || needs.quality.result != 'success'
run: exit 1
cd.yml:
name: CD
on:
push: { branches: [main] }
jobs:
ci:
uses: ./.github/workflows/ci.yml
build:
needs: ci
uses: ./.github/workflows/_reusable-build-push.yml
deploy-dev:
needs: build
uses: ./.github/workflows/_reusable-deploy.yml
with: { environment: dev }
secrets: inherit
deploy-staging:
needs: deploy-dev
uses: ./.github/workflows/_reusable-deploy.yml
with: { environment: staging }
secrets: inherit
deploy-production:
needs: deploy-staging
uses: ./.github/workflows/_reusable-deploy.yml
with: { environment: production }
secrets: inherit
Self-check
1. When do you extract a job to a reusable workflow vs when do you keep it inline?
Extract to reusable when:
✅ The job is used 2 or more times in the same workflow or in different workflows.
- Example: deploy to dev/staging/prod uses the same workflow with a different environment.
✅ The job has significant complexity (more than 5-10 steps).
- If it's complex, the abstraction helps to maintain it.
✅ The job is a candidate for reuse between repos.
- "Any Python repo needs to test with a matrix" → reusable.
✅ The job represents a clear logical unit (test, deploy, build).
- If it has an easy name ("deploy to environment X"), it's a good candidate.
Keep inline when:
❌ The job is trivial (1-3 steps).
- The overhead of a separate file > the benefit.
❌ It's used only once and isn't a candidate for reuse.
- Premature abstraction.
❌ It requires many specific inputs (>10) that make the reusable more complex than the duplication.
❌ It's repo-specific integration (your custom logic, not a generalizable pattern).
Rule of thumb: if the job doesn't meet any reason to "extract", keep it inline. It's easier to promote to reusable later than to demote to inline.
2. Your reusable workflow fails in some repos but works in others. How do you diagnose it?
Five common causes, in order of probability:
1. Differences in secrets:
- Repo A has
RAILWAY_TOKENconfigured - Repo B doesn't have it
- The reusable uses
${{ secrets.RAILWAY_TOKEN }}→ empty in repo B - The step fails
Diagnosis: verify that the caller passes the correct secrets or uses secrets: inherit and that the caller has all the necessary secrets.
2. Differences in files:
- The reusable does
pip install -e ".[dev]"assuming apyproject.tomlwith dev extras - Repo B has
requirements.txtinstead ofpyproject.toml - The command fails
Diagnosis: standardize the repo (everyone uses pyproject.toml) or add inputs to the reusable to customize the install command.
3. Different GITHUB_TOKEN permissions:
- Repo A has
permissions: packages: writein the caller - Repo B doesn't have it
- The reusable tries to push to GHCR → 403
Diagnosis: declare the required permissions in the reusable and verify that the caller provides them.
4. Runner versions:
- Repo A uses
ubuntu-22.04 - Repo B uses
ubuntu-latestwhich is nowubuntu-24.04 - The command depends on a specific version of a tool
Diagnosis: specify runs-on explicitly in the reusable.
5. Conflicting caches:
- Different cache hit/miss between repos
- A step that depends on the cache behaves differently
Diagnosis: review the cache key. Use keys that include hashFiles('pyproject.toml') so they change with real changes in the repo.
General strategy: run the reusable in workflow_dispatch mode with verbose output (set -x in bash steps) and compare logs between repos where it works vs where it fails. The line where they diverge reveals the cause.
Summary and next step
- Reusable workflows = YAML functions; they eliminate duplication and improve maintainability
- The
workflow_calltrigger makes them callable;uses: ./.github/workflows/Xinvokes them - Inputs for parameters,
secrets: inheritfor credentials (simplest) - Composite actions reuse steps, reusable workflows reuse jobs
- Extract when used 2+ times or complex; inline if trivial or single-use
- Your pipeline goes from 350 LOC in one file to ~250 LOC in modules
Bridge to the next step: Your pipeline is modular and maintainable. But it takes 5 minutes per run — and that's 5 minutes × 50 PRs/month × 12 months = 50+ hours of wait time per year. In capsule 04 you're going to optimize with caching (pip cache, Docker layer cache GHA, etc.) to reduce the time to 2 minutes. Same pipeline, 2.5× faster.
Resources
- Reusing workflows — official documentation.
- Sharing workflows, secrets, and runners with your organization — for cross-repo reusables.
- Creating a composite action — for reusable steps.
- Awesome Actions — a catalog of open source reusable actions.
- Reusable workflows vs composite actions — when to use each.
Capsule 03 of 08 — Module 5 — CI/CD for Python Backend Guide