0x55aa
โ† Back to Blog

GitHub Actions Reusable Workflows: Stop Copy-Pasting CI Configs Across 20 Repos ๐Ÿ”

โ€ข7 min read

GitHub Actions Reusable Workflows: Stop Copy-Pasting CI Configs Across 20 Repos ๐Ÿ”

A conversation I had with myself at 11 PM:

"There's a security vulnerability in our npm audit step. I need to update the GitHub Actions workflow to fail on high severity."

"Cool. How many repos?"

"...seventeen."

"How many have different versions of the same workflow?"

"...probably all of them."

"How long will this take?"

"I hate my life."

If you're managing CI/CD across multiple repos โ€” microservices, shared libraries, internal tools โ€” you've lived this nightmare. You copy-paste a working workflow, it diverges immediately, and two months later you're playing "spot the difference" between 17 slightly-wrong YAML files.

Reusable workflows are GitHub Actions' answer to this problem. Define your CI once, call it from everywhere. Let me show you how.

The Problem With Copy-Paste CI ๐Ÿ“‹

Here's what "copy-paste CI" looks like in the wild:

service-auth/      .github/workflows/ci.yml (v1, has a bug)
service-payments/  .github/workflows/ci.yml (v1, has a bug, different bug)
service-orders/    .github/workflows/ci.yml (v2, bug fixed!)
service-users/     .github/workflows/ci.yml (v1, but someone added a step manually)
service-email/     .github/workflows/ci.yml (copy from 6 months ago, uses deprecated action)

Every repo is a unique snowflake of CI dysfunction. One bug fix = N pull requests. One upgrade = N PRs. One security policy change = N PRs and a very tired engineer.

Enter Reusable Workflows ๐ŸŽฏ

A reusable workflow lives in one repo (usually a dedicated devops or .github repo) and gets called from other repos. Like a function you can import โ€” except for CI.

Step 1: Create your reusable workflow

In a central repo (e.g., your-org/.github), create:

# .github/workflows/node-ci.yml
name: Node.js CI

on:
  workflow_call:
    inputs:
      node-version:
        required: false
        type: string
        default: '20'
      run-e2e:
        required: false
        type: boolean
        default: false
    secrets:
      NPM_TOKEN:
        required: false

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci
        env:
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

      - name: Lint
        run: npm run lint

      - name: Unit tests
        run: npm test -- --coverage

      - name: Audit dependencies
        run: npm audit --audit-level=high

      - name: E2E tests
        if: inputs.run-e2e
        run: npm run test:e2e

The magic is workflow_call โ€” that's what makes it reusable. It's just a regular trigger, like push or pull_request, except it responds to being called by another workflow.

Step 2: Call it from your service repos

Now in service-auth, service-payments, and all 15 other repos:

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  ci:
    uses: your-org/.github/.github/workflows/node-ci.yml@main
    with:
      node-version: '20'
      run-e2e: true
    secrets:
      NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

That's the ENTIRE file. Five lines of YAML, zero duplicated logic. Fix the reusable workflow once โ†’ all 17 repos get the fix automatically. ๐ŸŽ‰

Real-World Lesson: The Incident That Forced This ๐Ÿ”ฅ

We had a cryptominer targeting GitHub Actions runners via a vulnerable action version. The fix was pinning all actions/checkout to a specific SHA instead of a mutable tag like v3.

The manual approach: Open 17 PRs, merge 17 PRs, wait for CI to green, merge, repeat.

The reusable workflow approach: Update actions/checkout@v4 in one file, done.

The reusable workflow approach took 4 minutes. The manual approach took... let's call it "a very long Thursday."

This is when I converted every service we had.

Advanced Patterns: Make It Actually Useful ๐Ÿ› ๏ธ

Pattern 1: Matrix Builds Across Repos

Need to test against multiple Node versions? Define it once:

# your-org/.github/.github/workflows/node-ci-matrix.yml
on:
  workflow_call:
    inputs:
      node-versions:
        required: false
        type: string
        default: '["18", "20", "22"]'

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node: ${{ fromJSON(inputs.node-versions) }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: 'npm'
      - run: npm ci
      - run: npm test

Calling it:

jobs:
  ci:
    uses: your-org/.github/.github/workflows/node-ci-matrix.yml@main
    with:
      node-versions: '["18", "20"]'  # Override for specific repos

Pattern 2: Chaining Reusable Workflows

You can call multiple reusable workflows from one caller:

# .github/workflows/release.yml
name: Release

on:
  push:
    tags: ['v*']

jobs:
  ci:
    uses: your-org/.github/.github/workflows/node-ci.yml@main
    secrets: inherit  # Pass ALL secrets automatically

  build-docker:
    needs: ci
    uses: your-org/.github/.github/workflows/docker-build.yml@main
    with:
      image-name: my-service
    secrets: inherit

  deploy:
    needs: build-docker
    uses: your-org/.github/.github/workflows/deploy-k8s.yml@main
    with:
      environment: production
    secrets: inherit

One file orchestrates your entire release pipeline, all logic lives in reusable workflows, all services use the same deploy process. This is what "DRY CI" looks like. ๐Ÿงผ

The Versioning Strategy (This Matters!) ๐Ÿ“Œ

When you call a reusable workflow, you reference a branch or tag:

# Pinned to a tag (stable, recommended for production)
uses: your-org/.github/.github/workflows/[email protected]

# Pinned to main (always latest, riskier)
uses: your-org/.github/.github/workflows/ci.yml@main

# Pinned to a commit SHA (most stable, but needs updates manually)
uses: your-org/.github/.github/workflows/ci.yml@a8f3b92

My recommendation: Use tags for production services, main for internal tools. Create a new tag whenever you make breaking changes to your reusable workflows. Treat them like a library โ€” semantic versioning applies!

# When you update the reusable workflow
git tag v2.2.0
git push origin v2.2.0

# Services can opt in on their own schedule
# Old services: still on v2.1.0 (no surprise breakage)
# New services: start with v2.2.0

This is the workflow equivalent of "^2.1.0" in package.json โ€” you control when you adopt changes.

Common Gotchas (Learn From My Pain) ๐Ÿชค

Gotcha #1: secrets: inherit vs explicit secrets

# This passes ALL repo secrets โ€” convenient but less explicit
secrets: inherit

# This passes specific secrets โ€” more secure, clearer contract
secrets:
  DATABASE_URL: ${{ secrets.DATABASE_URL }}

I prefer secrets: inherit for internal workflows and explicit secrets when the reusable workflow is public โ€” you don't want to accidentally expose secrets to a workflow you don't fully control.

Gotcha #2: Reusable workflows can't call other reusable workflows (until recently!)

As of late 2023, you CAN nest reusable workflows up to 4 levels deep. But keep it shallow โ€” deeply nested workflows are hard to debug. Two levels max in practice.

Gotcha #3: The calling workflow's env doesn't inherit into reusable workflows

# Caller
env:
  MY_VAR: hello  # This does NOT auto-pass to the reusable workflow

jobs:
  ci:
    uses: org/repo/.github/workflows/ci.yml@main
    # MY_VAR is NOT available in ci.yml

Use inputs instead. Design your reusable workflow's interface explicitly.

The Bottom Line ๐Ÿ’ก

Reusable workflows solve exactly one problem, and they solve it completely: stop maintaining the same logic in N places.

Once you've set this up:

  • Security patches: update one file, done
  • Dependency upgrades: one PR in one repo, benefits flow everywhere
  • New CI requirements: add to reusable workflow, all services get it
  • Debugging: one place to look, not seventeen

The initial setup takes an afternoon. The ongoing maintenance savings are compounding โ€” every time you would have opened 10+ PRs for the same change, you open one instead.

I've seen teams go from "we don't update CI because it's too painful" to "we ship CI improvements weekly" after this migration. It's not magic, it's just DRY principles applied to infrastructure.

Your Action Plan ๐Ÿš€

  1. Create a .github repo in your org (or use an existing devops repo)
  2. Extract your most duplicated workflow โ€” probably your test/lint pipeline
  3. Convert one service to use the reusable workflow, verify it works
  4. Migrate remaining services โ€” one PR each, totally mechanical
  5. Tag a v1.0.0 and start treating your CI like the shared library it is

Once you do this once, you'll never go back. Your future self โ€” the one who needs to update 20 repos at 11 PM โ€” will thank you.


Managing CI/CD for a large org? Connect with me on LinkedIn โ€” I love talking about scaling DevOps practices.

Want to see this in action? Check out the GitHub docs on reusable workflows โ€” they're actually pretty good for once!

Now go refactor that copy-pasted YAML. You know it needs to happen. ๐Ÿ”โœจ