Skip to main content
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.

GitHub Actions Reusable Workflows: DRY Pipelines at Org Scale

KU
Kiril Urbonas
3 months ago • 4 min read•Updated last month•41 views

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.

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:

yaml.yaml
# .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.

yaml.yaml
# .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:

yaml.yaml
# 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.

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

Explore topics:CI/CDDevOps
React

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.

Share this post
KU

Kiril Urbonas

AI Engineer

560 articles
View all articles by Kiril Urbonas

You might have missed

Evergreen posts worth revisiting.