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.

Jan 14, 2025·Updated ·5 min readIntermediate·By SecOpsLog · documentation-verified

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.

acme/ci-templates/.github/workflows/build-scan.yml
name: Reusable build and scan
on:
workflow_call:
inputs:
image:
required: true
type: string
severity:
required: false
type: string
default: HIGH,CRITICAL
secrets:
registry_token:
required: false # callers on OIDC do not need it
jobs:
build:
runs-on: ubuntu-latest
permissions: # cannot exceed what the caller granted
contents: read
packages: write
id-token: write
steps:
- uses: actions/checkout@v7
- run: docker build -t "${{ inputs.image }}" .
- uses: aquasecurity/trivy-action@0.36.0
with:
image-ref: ${{ inputs.image }}
severity: ${{ inputs.severity }}
ignore-unfixed: true
exit-code: "1"
acme/api/.github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
packages: write
id-token: write
jobs:
pipeline:
uses: acme/ci-templates/.github/workflows/build-scan.yml@v1.4.0
with:
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

RefWhat a template change doesSuitable for
@mainruns on every caller’s next build, with the caller’s secrets, unreviewed by the callernothing that touches credentials
@v1.4.0 (tag)callers move when someone bumps the tag; a moved tag moves everyone silentlyteams that protect tags and release from a reviewed branch
@8f3c… (full SHA)nothing moves until a caller changes the SHAanything 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.

bash — find the callers that float
gh search code "uses: acme/ci-templates/" --owner acme --json repository,textMatches -q ".[].repository.nameWithOwner" | sort -u | wc -l
47
gh search code "uses: acme/ci-templates/.github/workflows/build-scan.yml@main" --owner acme -q ".[].repository.nameWithOwner"
acme/billing
acme/legacy-importer
two callers take whatever lands on main, including a change to how secrets are used

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

The template is a dependency with write access to everything
It runs with each caller’s token and secrets. Treat it the way you treat a library that ships with root: protected branches, required reviewers, release tags created only by CI, SHA pinning in callers that hold credentials, and a search like the one above run on a schedule so that a floating ref is found by you rather than by an attacker who got write access to the template.

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.

Related posts

Quick reference