We had the same 180-line build workflow copy-pasted into 60 repos. Fixing one bug meant 60 PRs. Here's the reusable-workflow setup that made it one.
The wake-up call was a CVE in a build action. We used it in what turned out to be 60 repositories, each with its own copy-pasted 180-line CI workflow, and patching it meant opening 60 pull requests, chasing 60 reviews, and hoping nobody's copy had drifted enough to break the find-and-replace. It took two engineers most of a day. That's when we finally moved the pipeline logic into reusable workflows, where fixing that CVE would have been a single commit.
People mix these up. A composite action bundles a few steps you call inside a job. A reusable workflow is an entire job (or set of jobs) you call from another workflow with uses: at the job level. For "every service builds, tests, scans, and pushes an image the same way," you want a reusable workflow, because you're standardizing whole jobs, not a step or two.
The reusable workflow lives in a central repo, say devopsness/.github or a dedicated devopsness/ci repo:
# .github/workflows/build-and-push.yml in devopsness/ci
name: build-and-push
on:
workflow_call:
inputs:
image-name:
required: true
type: string
dockerfile:
required: false
type: string
default: Dockerfile
secrets:
registry-token:
required: true
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -f ${{ inputs.dockerfile }} -t ${{ inputs.image-name }} .
- name: Scan with Trivy
uses: aquasecurity/trivy-action@0.28.0
with:
image-ref: ${{ inputs.image-name }}
exit-code: '1'
severity: 'CRITICAL,HIGH'
- name: Push
run: |
echo "${{ secrets.registry-token }}" | docker login -u ci --password-stdin
docker push ${{ inputs.image-name }}
Each of the 60 repos now has a tiny workflow that just calls the shared one. This is the whole payoff: the 180 lines collapse to about 12, and none of them contain logic that can drift.
# .github/workflows/ci.yml in payments-service
name: CI
on:
push:
branches: [main]
jobs:
build:
uses: devopsness/ci/.github/workflows/build-and-push.yml@v3
with:
image-name: ghcr.io/devopsness/payments:${{ github.sha }}
secrets:
registry-token: ${{ secrets.GHCR_TOKEN }}
Now the CVE fix is one commit in devopsness/ci. Every repo pinned to @v3 picks it up on their next run. Zero PRs across the fleet.
There's a real tension. Pinning @v3 (a tag you re-point as you release fixes) gives you central control: you move the tag, everyone updates. Pinning @<sha> gives you immutability but reintroduces the 60-PR problem, because updating means bumping every caller.
We use a hybrid. Internal reusable workflows are pinned to a major tag like @v3 that we advance for backward-compatible fixes, so security patches flow automatically. Third-party actions inside the reusable workflow are pinned to full SHAs, because those we don't control and supply-chain attacks on tags are real. So trivy-action@0.28.0 in the example above would actually be a SHA in production. Central logic: moving tag. External code: frozen SHA.
The flip side of one-commit-updates-everything is one bad commit breaks everything. We protect against that. The ci repo has its own test that runs the reusable workflow against a sample service on every PR, so a broken change fails before merge. We also allow-list which workflows can be called from outside the repo:
# org settings: Actions > General
# "Allow <org>/ci actions and reusable workflows" only
And we roll major-tag moves out to a canary set of five repos first (via a @v3-canary tag those repos track) before advancing the real @v3. A regression shows up in five repos, not sixty.
Reusable workflows count against your concurrency and billing exactly like inlined jobs, but they're now firing across 60 repos on a shared schedule. When we centralized, a lot of repos ended up with near-identical push triggers and we briefly saturated our runner concurrency. Two fixes: add a concurrency group so redundant runs cancel, and don't run the full pipeline on every push to every branch.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
If you have more than about ten repos sharing pipeline logic, move it into reusable workflows in a central repo now. The copy-paste model looks harmless until a CVE turns one fix into sixty PRs. Pin your internal reusable workflows to a moving major tag for central control, pin third-party actions to SHAs for safety, test the central repo against a sample service, and canary tag moves before they hit the fleet. We went from a day-long CVE scramble to a fix that ships to every service on the next push.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
The Backstage demo always wows leadership. Then six months later the catalog has 400 stale entries and nobody trusts it. Here's what got ours to actually stick.
Reliability arguments used to be shouting matches between SRE and product. An error budget turned them into arithmetic. Here's how we made the number drive the roadmap.
Explore more articles in this category
We used to ship code and turn it on in the same breath, so every deploy was a bet. Feature flags split those two events apart and made rollbacks a config toggle.
Our best engineer quit citing on-call. We rebuilt the whole thing: saner rotations, runbooks that actually help at 3am, and escalation that doesn't punish asking for help.
Our early postmortems quietly assigned blame and taught people to hide mistakes. Here's the template and the facilitation rules that finally made them honest and useful.
Evergreen posts worth revisiting.