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_call trigger
  • 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:

  1. Duplication: the "setup Python + install deps" steps appear in test, quality, deploy-*. Any change (e.g., adding --upgrade pip) requires editing several places.

  2. Hard to read: 300+ lines in one file. Finding bugs requires scrolling.

  3. Not reusable between repos: if you have another Python repo with a similar pipeline, you copy it all and maintain two misaligned versions.

  4. 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

  1. Create _reusable-tests.yml with the content of the test job
  2. Modify ci.yml to call it
  3. Push to a branch, open a PR
  4. Verify that CI keeps working identically
  5. 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 WorkflowComposite Action
Reuses complete jobsReuses steps within a job
Triggered with uses: workflow.ymlTriggered 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 pipelinesMore 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 @main for 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

  1. 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
  2. Extract _reusable-deploy.yml (highest impact). Verify that CI stays green.

  3. Split ci.yml vs cd.yml.

  4. Iterate with _reusable-tests.yml, _reusable-quality.yml.

  5. 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_TOKEN configured
  • 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 a pyproject.toml with dev extras
  • Repo B has requirements.txt instead of pyproject.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: write in 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-latest which is now ubuntu-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_call trigger makes them callable; uses: ./.github/workflows/X invokes them
  • Inputs for parameters, secrets: inherit for 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

  1. Reusing workflows — official documentation.
  2. Sharing workflows, secrets, and runners with your organization — for cross-repo reusables.
  3. Creating a composite action — for reusable steps.
  4. Awesome Actions — a catalog of open source reusable actions.
  5. Reusable workflows vs composite actions — when to use each.

Capsule 03 of 08 — Module 5 — CI/CD for Python Backend Guide