Skip to main content
Back to blogs
CI/CD

GitHub Actions: Reusable Workflows That Actually Scale

How duplicated CI/CD configs across 30+ repos were eliminated with reusable workflows, composite actions, and a central workflow registry.

February 28, 20265 min read
github-actionsci-cdautomationdevops

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:

.github/workflows/node-ci.yml
# 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:e2e

Consuming repos call it with a single line:

.github/workflows/ci.yml
# 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: inherit

One 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:

actions/docker-build/action.yml
# 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=max

Usage in any workflow:

usage.yml
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:

release-workflow.sh
#!/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}" --force

Repos 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:

.github/workflows/deploy.yml
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: inherit

Monitoring Workflow Health

Track workflow reliability across the org:

workflow-health.sh
#!/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
done

Key Takeaways

  1. Centralize workflows in a dedicated repo — one source of truth for all CI/CD logic
  2. Use reusable workflows for full pipelines — lint, test, build, deploy in one call
  3. Use composite actions for shared steps — Docker builds, deployment scripts, notification steps
  4. Version your workflows — pin consuming repos to tags, not branches
  5. secrets: inherit simplifies secret management — no need to pass each secret individually
Share