GitHub Actions Reusable Workflows: DRY Pipelines at Org Scale
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.
Key takeaways
- 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.
On this page
GitHub Actions Reusable Workflows: DRY Pipelines at Org Scale#
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.
Composite actions vs reusable workflows#
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 }}
The caller shrinks to almost nothing#
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.
Pin to a moving tag, not a SHA, and here's the nuance#
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.
Guardrails so a bad central change doesn't break 60 repos#
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.
Concurrency and cost, the thing that surprised us#
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
The call we'd make#
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 DevOps Troubleshooting Cheat Sheet
Subscribe and get our free one-page reference for the errors that eat an afternoon — CrashLoopBackOff, OOMKilled, Terraform state locks, and more — plus new guides as we publish them.
Agent Memory: Short-Term, Long-Term, and When You Need Neither
Most agents that "need memory" actually need a smaller context window and a database. Here's how we cut a support agent's token bill by 60 percent by deleting memory.
Serverless Cold Starts: Measuring and Fixing Them on Lambda
A p99 that jumped to 3.4 seconds during traffic ramps turned out to be cold starts. Here's how we measured them properly and cut the tail, with real init timings.
More from DevOps
Explore more articles in this category
GitLab's New Rate Limits: What to Fix Before Oct 19
GitLab is capping unauthenticated API calls at 60 an hour starting October 19, and the preview windows land before most teams will have noticed.
Storm-3068: A CI/CD Pipeline Is a Kubeconfig Exfiltration Machine
Microsoft's Storm-3068 report used zero malware to steal Kubernetes credentials, just a password reset, a pipeline edit, and permissions nobody had scoped down.
Docker Cloud Sandboxes: Why Agents Need MicroVMs, Not Containers
Docker put AI coding agents in hosted microVMs instead of containers, because a container was never the isolation boundary this job needed.
You might have missed
Evergreen posts worth revisiting.