DRY GitHub Actions with reusable workflows
Extract shared build-and-scan steps into a reusable workflow and call it from every repo with inputs and secrets.
Fifty repositories with fifty copies of ci.yml is how a security policy erodes without anyone deciding to weaken it: one team pins actions/checkout@v7, another is still on v3, a third turned the scanner's exit code off during an incident and never turned it back on. A reusable workflow moves the job definition into one repository and lets every caller invoke it with inputs. The interesting questions are not how to write one but what runs in whose context, and what happens to fifty repositories when the template changes.
What runs where
A called workflow runs in the caller's context: it checks out the caller's code, uses the caller's GITHUB_TOKEN, and can receive the caller's secrets. Its permissions block can only narrow what the caller granted, never widen it, and the same rule now applies to cache access (a caller with read-only cache access cannot call a workflow that declares write). The template repository contributes the steps; the application repository contributes everything those steps touch. That is why a compromised template is a supply-chain incident across every caller, and why the ref you call is the most important line in the file.
name: Reusable build and scanon:workflow_call:inputs:image:required: truetype: stringseverity:required: falsetype: stringdefault: HIGH,CRITICALsecrets:registry_token:required: false # callers on OIDC do not need itjobs:build:runs-on: ubuntu-latestpermissions: # cannot exceed what the caller grantedcontents: readpackages: writeid-token: writesteps:- uses: actions/checkout@v7- run: docker build -t "${{ inputs.image }}" .- uses: aquasecurity/trivy-action@0.36.0with:image-ref: ${{ inputs.image }}severity: ${{ inputs.severity }}ignore-unfixed: trueexit-code: "1"
name: CIon:push:branches: [main]pull_request:permissions:contents: readpackages: writeid-token: writejobs:pipeline:uses: acme/ci-templates/.github/workflows/build-scan.yml@v1.4.0with:image: ghcr.io/acme/api:${{ github.sha }}secrets: inherit
secrets: inherit passes every secret the caller has to the called workflow. It is convenient and it is also the widest grant available; a template that only needs a registry token should receive secrets: { registry_token: … } explicitly so that an unrelated secret in the caller cannot be reached by a step someone adds to the template later. Document which secrets the template needs in its README, because a missing one fails at the first run in a new repository, not at review time.
Three ways to pin, three blast radii
The ref in `uses:` decides who can change fifty pipelines
| Ref | What a template change does | Suitable for |
|---|---|---|
@main | runs on every caller’s next build, with the caller’s secrets, unreviewed by the caller | nothing that touches credentials |
@v1.4.0 (tag) | callers move when someone bumps the tag; a moved tag moves everyone silently | teams that protect tags and release from a reviewed branch |
@8f3c… (full SHA) | nothing moves until a caller changes the SHA | anything that signs, deploys or holds cloud credentials |
A tag is a name that can be moved; a SHA cannot. For a template that signs artifacts or assumes a cloud role, callers should pin the SHA and bump it through a pull request, the same way third-party actions are pinned. The template repository itself needs branch protection, required review, and tags that only a release job creates. Review on the template is review on fifty pipelines; the point of centralising is that the review happens once, and the risk is that a bad review lands everywhere at once.
gh search code "uses: acme/ci-templates/" --owner acme --json repository,textMatches -q ".[].repository.nameWithOwner" | sort -u | wc -l47gh search code "uses: acme/ci-templates/.github/workflows/build-scan.yml@main" --owner acme -q ".[].repository.nameWithOwner"acme/billingacme/legacy-importertwo callers take whatever lands on main, including a change to how secrets are usedWorkflow or composite action
A composite action bundles steps and runs inside a job the caller defines; a reusable workflow owns whole jobs, with their runner, matrix, permissions and environment. Choose the workflow when the unit you want to standardise is a job (the scan gate, the signed push, the deploy to staging) and the composite action when it is a snippet (a checkout with a fixed set of options, a login sequence). Reusable workflows can call other reusable workflows, so an organisation-level deploy.yml can wrap a team-level build.yml without either copying the other.
With one template in place, the identity the jobs run as becomes the next thing to centralise: OIDC federation to AWS belongs in the template so every caller deploys through the same trust policy, and Cosign keyless signing reuses that identity to sign what the template built.