GitHub Actions Reusable Workflows: Stop Copy-Pasting CI Configs Across 20 Repos ๐
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 ๐
- Create a
.githubrepo in your org (or use an existing devops repo) - Extract your most duplicated workflow โ probably your test/lint pipeline
- Convert one service to use the reusable workflow, verify it works
- Migrate remaining services โ one PR each, totally mechanical
- 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. ๐โจ