When you manage 30+ repositories, copy-pasting CI/CD workflows between repos becomes unsustainable. One security patch to the build process means updating 30 workflow files. Reusable workflows solve this — define once, reference everywhere.
The Problem
Every repo had its own .github/workflows/ci.yml. They were mostly identical but had drifted over time. Some had Docker layer caching, some didn't. Some ran security scans, some forgot to. Updating the Node.js version meant 30 PRs.
Reusable Workflows
A reusable workflow lives in a central repository and is called by other repos:
# Central repo: myorg/workflows
name: Node.js CI
on:
workflow_call:
inputs:
node-version:
description: "Node.js version"
required: false
default: "20"
type: string
run-e2e:
description: "Run E2E tests"
required: false
default: false
type: boolean
secrets:
NPM_TOKEN:
required: false
SONAR_TOKEN:
required: false
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: "npm"
- run: npm ci
- name: Lint
run: npm run lint
- name: Type check
run: npm run typecheck
- name: Unit tests
run: npm test -- --coverage
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Snyk
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high
- name: Audit dependencies
run: npm audit --audit-level=high
e2e:
if: inputs.run-e2e
runs-on: ubuntu-latest
needs: lint-and-test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: "npm"
- run: npm ci
- name: E2E tests
run: npm run test:e2eConsuming repos call it with a single line:
# Any repo in the org
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
ci:
uses: myorg/workflows/.github/workflows/node-ci.yml@main
with:
node-version: "20"
run-e2e: true
secrets: inheritOne file per consuming repo. All the logic lives centrally.
Composite Actions for Shared Steps
When you need to share individual steps rather than entire workflows, composite actions are more flexible:
# Central repo: myorg/workflows/actions/docker-build
name: Docker Build and Push
description: Build and push a Docker image with layer caching
inputs:
registry:
description: "Container registry URL"
required: true
image-name:
description: "Image name"
required: true
tag:
description: "Image tag"
required: false
default: ${{ github.sha }}
dockerfile:
description: "Path to Dockerfile"
required: false
default: "Dockerfile"
runs:
using: composite
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ${{ inputs.registry }}
username: ${{ env.REGISTRY_USERNAME }}
password: ${{ env.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: ${{ inputs.dockerfile }}
push: true
tags: |
${{ inputs.registry }}/${{ inputs.image-name }}:${{ inputs.tag }}
${{ inputs.registry }}/${{ inputs.image-name }}:latest
cache-from: type=gha
cache-to: type=gha,mode=maxUsage in any workflow:
steps:
- uses: actions/checkout@v4
- uses: myorg/workflows/actions/docker-build@v2
with:
registry: ghcr.io
image-name: myorg/api-service
env:
REGISTRY_USERNAME: ${{ github.actor }}
REGISTRY_PASSWORD: ${{ secrets.GITHUB_TOKEN }}Versioning Strategy
The central workflow repo is tagged with semantic versions:
#!/bin/bash
# Tag a new version of the workflows
VERSION=$1
git tag -a "v${VERSION}" -m "Release v${VERSION}"
git push origin "v${VERSION}"
# Update the major version tag (v2 -> latest v2.x.x)
MAJOR=$(echo "$VERSION" | cut -d. -f1)
git tag -fa "v${MAJOR}" -m "Update v${MAJOR} to v${VERSION}"
git push origin "v${MAJOR}" --forceRepos can pin to:
@v2— get all minor/patch updates automatically@v2.1.0— exact version, no surprises@main— bleeding edge (only for testing)
Workflow Dispatch for Manual Operations
Some workflows need to be triggered manually with parameters:
name: Deploy
on:
workflow_dispatch:
inputs:
environment:
description: "Deploy target"
required: true
type: choice
options:
- staging
- production
version:
description: "Image tag to deploy"
required: true
type: string
dry-run:
description: "Dry run (plan only)"
required: false
default: true
type: boolean
jobs:
deploy:
uses: myorg/workflows/.github/workflows/k8s-deploy.yml@v2
with:
environment: ${{ inputs.environment }}
version: ${{ inputs.version }}
dry-run: ${{ inputs.dry-run }}
secrets: inheritMonitoring Workflow Health
Track workflow reliability across the org:
#!/bin/bash
# Check workflow success rates across repos
for repo in $(gh repo list myorg --json name -q '.[].name'); do
TOTAL=$(gh run list -R "myorg/$repo" -L 20 --json conclusion -q 'length')
FAILED=$(gh run list -R "myorg/$repo" -L 20 --json conclusion -q '[.[] | select(.conclusion=="failure")] | length')
if [ "$TOTAL" -gt 0 ]; then
RATE=$(echo "scale=0; ($TOTAL - $FAILED) * 100 / $TOTAL" | bc)
echo "$repo: ${RATE}% success ($FAILED/$TOTAL failed)"
fi
doneKey Takeaways
- Centralize workflows in a dedicated repo — one source of truth for all CI/CD logic
- Use reusable workflows for full pipelines — lint, test, build, deploy in one call
- Use composite actions for shared steps — Docker builds, deployment scripts, notification steps
- Version your workflows — pin consuming repos to tags, not branches
secrets: inheritsimplifies secret management — no need to pass each secret individually