GitHub Actions Reusable Workflows: DRY CI/CD Without the Footguns
Reusable workflows cut duplication across repos, but they introduce indirection and versioning risks. Here's how to structure inputs, pin versions, and decide when a composite action is simpler.
03 Aug 2025, 05:32 UTC

The problem: copy-paste CI across ten repos
You maintain a dozen microservices. Each has a .github/workflows/ci.yml that installs Node, runs ESLint, executes Jest, builds a Docker image, and pushes to a registry. The only differences are the Node version matrix and the registry secret name. When the security team mandates a new base image, you edit twelve files. When a flaky test needs a retry flag, you edit twelve files. This is the classic DRY violation that reusable workflows were built to solve.
How reusable workflows work
A reusable workflow lives in a template repository (e.g., ci-templates) under .github/workflows/reusable-build.yml. It declares on.workflow_call with typed inputs and optional outputs. Downstream repos call it with a single uses: line, passing their own values.
Call syntax and contracts
# ci-templates/.github/workflows/reusable-build.yml
on:
workflow_call:
inputs:
node-versions:
type: string
required: true
description: 'JSON array of Node versions, e.g. ["18", "20"]'
docker-registry:
type: string
required: false
default: 'ghcr.io'
outputs:
image-digest:
description: 'Digest of the pushed image'
value: ${{ jobs.build.outputs.image-digest }}
secrets:
DOCKER_TOKEN:
required: true
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node: ${{ fromJson(inputs.node-versions) }}
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm test
- name: Build and push
uses: docker/build-push-action@v5
with:
push: true
tags: ${{ inputs.docker-registry }}/${{ github.repository }}:${{ github.sha }}
env:
DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }}
outputs:
image-digest: ${{ steps.build.outputs.digest }}
The caller workflow (in each microservice repo) becomes tiny:
# consumer-app/.github/workflows/ci.yml
on: [push, pull_request]
jobs:
ci:
uses: ci-templates/.github/workflows/reusable-build.yml@v2.3.1
with:
node-versions: '["18", "20"]'
docker-registry: 'docker.io/myorg'
secrets:
DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }}
Inputs, outputs, and the fromJson trick
Inputs are strings only. To pass a matrix, serialize it as JSON and parse with fromJson inside the reusable workflow. Outputs must be declared at the job level (jobs.build.outputs.image-digest) and then mapped in on.workflow_call.outputs. The caller accesses them via needs.ci.outputs.image-digest if the call is inside a job that uses needs.
Version pinning: SHA vs. tag vs. branch
Supply-chain security demands immutable references. Pin to a full commit SHA (@a1b2c3d4e5f6...) for production callers. Use semantic version tags (@v2.3.1) for staging, and enable Dependabot to auto-bump them. Never use @main or @master in any environment you care about — a force-push to the template repo would instantly change every caller's behavior.
To verify the exact SHA a tag points to, run in the template repo:
gh api /repos/ci-templates/git/refs/tags/v2.3.1 --jq '.object.sha'
# Returns the commit SHA; compare with what your callers pin.
Required permission: contents:read on the template repo. The command runs from any machine with gh authenticated to the org.
Secrets: explicit pass or secrets: inherit
Secrets do not flow automatically. The caller must list each secret under secrets:. For org-wide secrets (e.g., a shared npm token), enable secrets: inherit in the reusable workflow:
on:
workflow_call:
secrets: inherit # opt-in; requires caller and callee in same org/enterprise
Then the caller can omit that secret entirely. Caveat: secrets: inherit only works when both repos belong to the same organization or GitHub Enterprise Cloud account. Cross-visibility (public template, private caller) requires Enterprise Cloud and explicit cross-repo access policies — verify your org settings before relying on it.
Worked example: central Node/Docker template
The ci-templates repo holds reusable-build.yml (shown above). Three downstream repos call it:
api-gatewaypassesnode-versions: '["20"]'anddocker-registry: 'ghcr.io'.billing-servicepassesnode-versions: '["18", "20"]'and a private registry.legacy-adminpins to@a1b2c3d(old SHA) because it still needs Node 16, which the latest template dropped.
When the security team updates the base image, you bump the template, tag v2.4.0, and Dependabot opens PRs in api-gateway and billing-service. legacy-admin stays on the old SHA until its team upgrades Node.
Trade-offs and when to choose composite actions
Reusable workflows own the full job context: matrix, services, environment protection rules, and concurrency gates. Composite actions (runs.using: composite) only share steps. Use a composite action for a repeated setup-node-cache sequence that runs inside different jobs. Use a reusable workflow when you want to enforce a complete build-test-deploy pipeline across repos.
| Capability | Reusable workflow | Composite action |
|---|---|---|
| Job-level matrix | Yes | No |
| Service containers | Yes | No |
| Environment protection rules | Yes | No |
| Concurrency cancellation | Per-caller repo | N/A |
| Debugging | Two-repo logs | Single workflow log |
| Version pinning | SHA/tag/branch | SHA/tag/branch |
Key limitation: concurrency groups defined in the reusable workflow (concurrency: group: ci-${{ github.ref }}) are scoped per caller repo. Two repos calling the same template run simultaneously unless each caller adds its own concurrency gate. Self-hosted runner labels (runs-on: [self-hosted, gpu]) resolve in the caller's context — the template cannot guarantee the runner has a GPU.
Actionable closing: start with a pilot
- Create a test org with two repos:
ci-templates(public) andconsumer-app(private). - Add the reusable workflow above, tag
v1.0.0, and note the SHA. - In
consumer-app, write a caller workflow pinned to that SHA. Run it; confirm the called workflow's jobs appear in the Actions UI. - Enable
secrets: inheritin the template, create an org secretSHARED_TOKEN, and trigger a run without passing the secret explicitly. Verify the step receives it. - Introduce a breaking change (rename an input) in the template, tag
v2.0.0. Observe the consumer run fail. Then change the caller's pin back to the v1.0.0 SHA and confirm it passes — proving immutable pinning works.
If the pilot succeeds, codify governance: put the template repo under CODEOWNERS with required reviews, enable branch protection, and document the pinning policy in your contributor guide. Reusable workflows reduce duplication, but they shift complexity into versioning and cross-repo trust. Treat the template like a released library, not a shared folder.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.