Skip to content
juniordev4life
Go back

Write Your CI Once - Reusable Workflows as DRY for Pipelines

Open three of your team’s repositories side by side and diff their CI configuration. Go on.

You will find the same jobs - lint, test, build, audit - in three slightly different dialects. One pins an old runtime version. One lost its security audit step during a merge conflict. One has a comment from 2024 explaining a workaround for a bug that was fixed long ago. Nobody did anything wrong. Each file was copied from the previous project on the day it started, and then all three drifted quietly for two years.

I have written about DRY where the stakes are highest - login, auth, security layers. Pipelines are the same argument one level up, and they are the most copy-pasted code most organizations own.

Why CI drifts by default

Pipeline configuration has a peculiar property: it is copied at birth and never converges again.

The result is that your “organizational standards” exist as archaeology - readable only by comparing files across repos and guessing which one is current.

The pattern: one workflow, many callers

The fix is built into GitHub Actions and takes about an hour to adopt. A workflow declared with on: workflow_call becomes callable by any repo in your organization, with typed inputs and secrets:

# org/.github-workflows/.github/workflows/node-ci.yml
on:
  workflow_call:
    inputs:
      node-version:
        type: string
        default: "24"
jobs:
  checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@<sha> # v4
      - uses: actions/setup-node@<sha> # v4
        with:
          node-version: ${{ inputs.node-version }}
      - run: npm ci
      - run: npm run lint
      - run: npm run format:check
      - run: npm audit --audit-level=high
      - run: npm run build

And every product repo shrinks to the part that is actually project-specific:

# any product repo — the whole CI file
name: CI
on: [pull_request]
jobs:
  ci:
    uses: org/.github-workflows/.github/workflows/node-ci.yml@v1
    with:
      node-version: "24"

Now the logic lives once. Add a new check - a license scan, an accessibility gate - and every repo inherits it on its next version bump. Fix a bug and it is fixed everywhere. As a rule of thumb, this starts paying off at about three repos with similar pipelines, and the payoff grows with every new one.

What belongs central - and what does not

Resist the urge to centralize everything. The split I use:

Central (the same everywhere):

Local (genuinely project-specific):

The test: would a change here be correct for every repo that calls it? If not, it takes an input - or it stays local.

Versioning: the part people get wrong

Once many repos depend on one workflow, how you reference it becomes a real decision with real consequences:

  1. @main — never for production repos. Every caller picks up every change instantly, with no review and no approval step. One bad commit breaks every pipeline in the company simultaneously.
  2. Floating major tag (@v1) — consumers get patches and fixes automatically; you move the tag deliberately after testing. Convenient, and the common middle ground - but a bad move breaks everyone at once.
  3. Exact version (@v1.4.2) — upgrades become deliberate opt-in. Slower to roll out fixes, maximum control.
  4. SHA pin (@<commit-sha>) — maximum safety and reproducibility, because tags can be moved and commit SHAs cannot.

My recommendation for most teams: callers pin a floating major tag you advance intentionally, while the shared workflow itself SHA-pins every action it uses. Which brings us to the reason this is not merely tidiness.

The security payoff (this is the real argument)

Here is what most articles about reusable workflows leave out: your callers inherit your pinning choices invisibly. Every repo that calls your shared workflow runs whatever third-party actions that workflow references, whether or not those repos ever reviewed them.

This is not hypothetical. When a widely used action was compromised (CVE-2025-30066), the pattern was brutally clear: every consumer pinned to a tag received the malicious payload; every consumer pinned to a commit SHA did not. Tags can be moved by an attacker who gains write access. A SHA cannot.

So the shared workflow becomes your single choke point for supply-chain safety:

That is the same compounding-scrutiny argument I made for auth components: concentrate your paranoia where it can be reviewed once and benefit everyone.

The honest risk: one broken workflow, all repos

Centralizing has a failure mode you must plan for: when the shared workflow breaks, nothing in the organization can merge. That is a genuinely bad afternoon.

Treat the shared workflow like the product it now is:

One cookbook, not ten notebooks

Picture a family where every relative keeps their own handwritten notebook of the same recipes. Grandma’s cake appears in all of them - but one copy dropped the vanilla years ago, another doubled the baking time after a bad oven, and a third has a stain covering the sugar quantity. Nobody is wrong, exactly. But nobody bakes the same cake anymore, and when someone finally works out the perfect version, the improvement stays trapped in one notebook.

Now imagine the family publishes one proper cookbook instead. Everyone owns the same edition. When someone improves the recipe, a new edition goes out - and everyone can choose when to pick it up. The stains stay in the kitchen; the recipe stays true.

Your CI configuration is Grandma’s cake, and most organizations are still passing around notebooks. Publish the cookbook. Version the editions. And check very carefully which ingredients you are telling everyone to buy - because when you write the recipe for the whole family, one bad ingredient goes into every kitchen at once.


Share this post:

Next Post
Design Tokens Are a Contract Too