Git, CI/CD & GitOpsXXXIX · PipelinesPipelines
Reusable workflows — DRY at the workflow level, what they enable, what they cost
What you'll learn
- Declare a reusable workflow with the workflow_call trigger
- Call a reusable workflow from a caller workflow with inputs and secrets
- Distinguish a reusable workflow from a composite action
- Identify the four DRY patterns reusable workflows replace and their costs
- Recognise the over-engineering threshold where a reusable workflow is wrong
Prerequisites
Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x
A reusable workflow is a workflow file that another workflow
can call. The caller passes inputs and secrets; the callee
runs as if it were a job in the caller’s DAG, but in a
separate runner, with its own log, its own status, and its
own artifact namespace. The mechanism is workflow_call on
the callee and uses: on the caller. The effect is DRY at
the workflow level: one definition, N callers.
Declaring a reusable workflow
# .github/workflows/terraform-apply.yml (the callee)
name: Terraform Apply
on:
workflow_call:
inputs:
environment:
type: string
required: true
working-directory:
type: string
required: false
default: terraform/
secrets:
aws-role-to-assume:
required: true
jobs:
apply:
runs-on: ubuntu-latest
environment: ${ inputs.environment }
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${ secrets.aws-role-to-assume }
- run: terraform apply -auto-approve ${ inputs.working-directory }
The on: workflow_call: trigger declares the workflow as
callable. inputs: defines the typed parameters the caller
must pass. secrets: declares the secrets the caller must
forward. The callee’s jobs run with the inputs and secrets
the caller provided.
Calling a reusable workflow
# .github/workflows/deploy-production.yml (the caller)
name: Deploy Production
on:
push:
branches: [main]
jobs:
apply-production:
uses: org/repo/.github/workflows/terraform-apply.yml@main
with:
environment: production
working-directory: terraform/production/
secrets:
aws-role-to-assume: ${ secrets.AWS_DEPLOY_ROLE }
The uses: clause references the callee by path and ref.
The with: clause passes inputs; the secrets: clause
forwards secrets. The callee’s jobs are inlined into the
caller’s DAG; the caller’s downstream jobs can depend on
the callee’s outputs.
flowchart LR
C["Caller workflow"] -->|"uses: org/repo/.github/workflows/callee.yml@main"| W["Reusable workflow"]
W --> J1["apply job"]
W --> J2["plan job"]
Inputs, secrets, and outputs
The three data channels between caller and callee:
flowchart TB
C["Caller"] -->|"inputs (typed parameters)"| W["Reusable workflow"]
C -->|"secrets (forwarded)"| W
W -->|"outputs (return values)"| C
- Inputs are typed parameters declared in
on.workflow_call.inputs. The caller passes them viawith:. Inputs are visible in the caller’s run page and the callee’s run page. - Secrets are sensitive values declared in
on.workflow_call.secrets. The caller forwards them viasecrets:; the callee references them as${ secrets.<name> }. The caller cannot pass a secret the callee has not declared. - Outputs are values the callee returns to the
caller. The callee declares them in
jobs.<id>.outputs; the caller reads them vianeeds.<callee-job>.outputs.<name>.
# Callee declaring outputs
jobs:
apply:
outputs:
apply-id: ${ steps.apply.outputs.id }
steps:
- id: apply
run: terraform apply ...
# Caller consuming outputs
jobs:
notify:
needs: [apply-production]
runs-on: ubuntu-latest
steps:
- run: echo "apply id: ${ needs.apply-production.outputs.apply-id }"
Reusable workflow versus composite action
Two DRY mechanisms; different scopes:
flowchart TB
Q{"What is being\nextracted?"}
Q -->|"A sequence of steps"| A["Composite action\n(action.yml)"]
Q -->|"A whole pipeline"| B["Reusable workflow\n(workflow_call)"]
- Composite action is a single step that bundles
multiple commands. Declared in
action.yml. Reused viauses:in a step. The composite runs in the caller’s job; it cannot have its own runner, environment, or matrix. - Reusable workflow is a whole pipeline extracted into
a callee. Declared in a workflow file with
workflow_call. Reused viauses:in a job. The callee runs in its own job; it can have its own runner, environment, matrix, and concurrency group.
A composite action is the right tool for “I have a sequence of shell commands I want to share”. A reusable workflow is the right tool for “I have a job (or jobs) I want to share with its own runner and environment”.
The four patterns reusable workflows replace
flowchart LR
subgraph P1["1. Copied workflow files"]
A1["repo1/.github/workflows/build.yml"]
A2["repo2/.github/workflows/build.yml"]
A3["repo3/.github/workflows/build.yml"]
end
subgraph P2["2. Local scripts in repos"]
B1["scripts/deploy.sh in repo1"]
B2["scripts/deploy.sh in repo2"]
end
subgraph P3["3. Shared CI templates"]
C1["internal-template@v1"]
C2["internal-template@v2"]
end
subgraph P4["4. Composite actions doing too much"]
D1["action.yml with runner + secrets"]
end
- Copied workflow files. N repositories with N copies of the same workflow. A bug fix in repo1’s copy is not in repo2’s copy. Reusable workflows replace this with one callee referenced by N callers.
- Local scripts in repos. N repositories with N
copies of
scripts/deploy.sh. The reusable workflow replaces the script with a job; the script becomes the step inside the callee. - Shared CI templates. A “template” repository that N callers copy from. The template is a snapshot, not a reference; updates are pull requests, not live. Reusable workflows are referenced live, by SHA or branch.
- Composite actions doing too much. An
action.ymlthat wants its own runner, environment, or matrix. The composite action is the wrong scope; the reusable workflow is the right one.
The costs
Reusable workflows impose three costs that the team must pay deliberately:
- Coupling. A change to the callee is a change to every caller. The team must review the callee’s PR with the caller’s PR in mind; the coupling is across repositories, not just within one.
- Debugging distance. A failure in the callee is a
failure whose logs live in a different run page. The
engineer must follow the
uses:reference to the callee and dig through a second run page to find the failure. - Review surface. A reusable workflow is a library; its API (inputs, secrets, outputs) is a contract. A breaking change to the contract is a breaking change to every caller. The reviewer must check the contract, not just the implementation.
When the reusable workflow is the wrong tool
The reusable workflow is over-engineering when:
flowchart TB
Q{"Is the duplication real?"}
Q -->|"two callers, divergent soon"| NO["Copy the workflow"]
Q -->|"two callers, identical forever"| YES["Reusable workflow"]
Q -->|"three callers, divergent in places"| MAYBE["Reusable workflow with care"]
- Two callers, divergent soon. The duplication is incidental; the workflows will diverge. A reusable workflow forces them back into sync; the divergence is now a breaking change to the contract.
- Three callers, but the callee has more callers than the team can review. The coupling is wider than the review surface; breaking changes will slip through.
- The callee is one job, one step. A reusable workflow for a single step is a composite action that happens to have a runner. Use a composite action.
Production discipline
- The reusable workflow is a library; review its API (inputs, secrets, outputs) as a contract. A breaking change to the API is a breaking change to every caller.
- Pin the
uses:reference to a SHA or a release tag. A branch reference (@main) tracks the latest commit; a SHA reference is immutable. - Add a test caller in the callee repository. The test caller exercises every input combination; it is the contract test for the callee.
- Document the inputs and outputs in the callee’s README. A caller who cannot read the callee’s contract from its documentation is a caller who will guess and break.
- Audit the callers on every breaking change. A reusable workflow with N callers has N callers to update. The audit is the price of the DRY.
Cross-course references
- Ansible for Production Sysadmins - Part XXXVII (RepoArch) applies the same pattern to Ansible roles: a role is a library; its variables are its API; its defaults are its outputs.
- Terraform for Production Sysadmins - Part XIII (Modules) treats Terraform modules the same way: a module is a library; its variables are its API; its outputs are its return values.
- Linux for Production Sysadmins - Part XXXIV (ConfigMgmt) applies the same pattern to apt package repositories: a repository is a library; its Packages file is its API.
Quiz
Knowledge check · 4 questions
Q1. A team has extracted `terraform apply` into a reusable workflow with `on: workflow_call`. The reusable workflow declares one input (`environment`) and one secret (`aws-role-to-assume`). The caller passes the secret in `secrets:`. What happens at runtime if the caller forgets to forward the secret?
Q2. A reusable workflow with no caller in production is dead code; the team should either find a caller or delete the workflow.
Q3. State the trigger keyword that marks a workflow as reusable and the keyword the caller uses to invoke it.
Q4. Diagnose why a breaking change to a reusable workflow broke three production deploys simultaneously and recommend a release discipline.
Team T maintains a reusable workflow `terraform-apply.yml` referenced by three caller repositories: `infra-prod`, `infra-staging`, and `infra-dev`. The callee's author renames the input `working-directory` to `tf-dir` and merges the change to `main`. The next deploy from each caller fails because the callee no longer accepts the old input. All three production deploys are blocked.
Passing score: 75%. Answers are checked in this browser.