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

TypeExampleUse
string"3.12"Versions, names, paths
booleantrueFeature flags
number15Timeouts, 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:

AspectExplicit secretssecrets: inherit
SecurityIt only passes what's neededIt passes everything
ClarityDocumented which secrets get usedOpaque
MaintenanceUpdate it when a secret gets addedAutomatic
RecommendedFor cross-org workflowsFor 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

StrategyWhen to use it
@mainActive development, total confidence in main
@v1 (major)Production — only breaking changes bump the major
@v1.2.0 (exact)Maximum control, manual updates
@shaCritical 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:

  1. Make the repo public
  2. Duplicate the workflow in each org
  3. Use GitHub Apps with installation tokens (out of scope for this guide)

Comparisons

Reusable workflows vs Composite actions

AspectReusable WorkflowComposite Action
ScopeA complete workflow (multiple jobs)Steps within a job
Triggerworkflow_calluses: in a step
Its own jobsYes (it can define several jobs)No (it runs inside the caller's job)
RunnersIt can choose its own runnerIt uses the caller's runner
When to use itA complete shared flowCommon shared steps

Reusable workflows vs Templates

AspectReusable WorkflowA template (copying the YAML)
UpdatingAutomatic (you change it once)Manual (you change it in every repo)
DivergenceImpossibleInevitable
FlexibilityInputs parameterize itYou modify it freely
SetupA shared repo + a referenceCopying 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:

  1. A single repo with a unique machine learning training pipeline
  2. 5 microservices with the same lint → test → deploy flow
  3. An experimentation repo where you change the pipeline every day
  4. 3 repos in the same org, the same flow, a team of 8 developers
See solution
  1. Inline. A single repo, a unique pipeline — there's no benefit in centralizing.
  2. A reusable workflow. 5 repos with the same flow — the maintainability justifies the setup.
  3. Inline. Fast iteration — the overhead of updating a shared repo isn't worth it when you change the pipeline daily.
  4. 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

  1. GitHub Actions — Reusing Workflows - The complete official documentation
  2. GitHub Actions — workflow_call - The trigger's reference
  3. GitHub Actions — Workflow Inputs - Input syntax
  4. GitHub Actions — Sharing Workflows - Sharing within an org
  5. Reusable Workflows Best Practices - GitHub's official blog
  6. GitHub Actions Starter Workflows - Official workflow examples