DevOpsil
CI/CD
88%
Needs Review
Part 3 of 6 in CI/CD Mastery

GitHub Actions Reusable Workflows and Composite Actions for DRY Pipelines

Sarah ChenSarah Chen8 min read

The Reusable Workflow First

Here's a reusable workflow for building, testing, and pushing a container image. Then I'll explain every piece.

# .github/workflows/reusable-build.yml
name: Reusable Build and Push

on:
  workflow_call:
    inputs:
      image-name:
        required: true
        type: string
      dockerfile:
        required: false
        type: string
        default: "./Dockerfile"
      push:
        required: false
        type: boolean
        default: false
    secrets:
      registry-username:
        required: true
      registry-password:
        required: true
    outputs:
      image-digest:
        description: "The pushed image digest"
        value: ${{ jobs.build.outputs.digest }}

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      digest: ${{ steps.push.outputs.digest }}
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          username: ${{ secrets.registry-username }}
          password: ${{ secrets.registry-password }}

      - id: push
        uses: docker/build-push-action@v6
        with:
          context: .
          file: ${{ inputs.dockerfile }}
          push: ${{ inputs.push }}
          tags: ${{ inputs.image-name }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

One file. Every repo calls it. Zero copy-paste.

Why You Need This

If you maintain more than two repositories, you have duplicated pipeline code. I guarantee it. Same lint step, same Docker build, same deploy logic — scattered across dozens of workflow files.

Reusable workflows fix this:

  • Single source of truth for your CI/CD logic
  • Update once, propagate everywhere — no repo-by-repo YAML edits
  • Enforce standards across all teams without writing policy docs nobody reads

If it's not automated, it doesn't exist. If it's duplicated, it's already broken.

Calling the Reusable Workflow

# .github/workflows/ci.yml (in any consuming repo)
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  build:
    uses: your-org/shared-workflows/.github/workflows/reusable-build.yml@v1
    with:
      image-name: ghcr.io/your-org/my-service
      push: ${{ github.ref == 'refs/heads/main' }}
    secrets:
      registry-username: ${{ secrets.REGISTRY_USER }}
      registry-password: ${{ secrets.REGISTRY_PASS }}

  deploy:
    needs: build
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying image digest ${{ needs.build.outputs.image-digest }}"

That @v1 tag matters. Pin to a release tag, not main. You don't want every repo breaking because someone pushed a bad commit to the shared workflows repo.

Composite Actions: The Other Tool

Reusable workflows replace entire jobs. Composite actions replace repeated steps within a job.

# .github/actions/setup-and-test/action.yml
name: "Setup and Test"
description: "Install deps, lint, and run tests"

inputs:
  node-version:
    description: "Node.js version"
    required: false
    default: "22"

runs:
  using: "composite"
  steps:
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}
        cache: npm

    - shell: bash
      run: npm ci

    - shell: bash
      run: npm run lint

    - shell: bash
      run: npm test

Key difference: composite actions run inside an existing job. Reusable workflows spin up their own runner. Choose based on what you're abstracting.

Using a Composite Action

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

      - uses: your-org/shared-actions/setup-and-test@v2
        with:
          node-version: "20"

      - run: npm run build

Three lines replace ten. Every repo gets the same lint config, same test runner, same caching strategy.

When to Use Which

ScenarioUse
Full build-test-deploy pipelineReusable workflow
Shared setup steps (install, lint)Composite action
Needs its own runner or secretsReusable workflow
Needs to run alongside other stepsComposite action
Cross-repo standardizationEither — depends on granularity

Versioning Strategy

# In your shared-workflows repo
git tag -a v1.0.0 -m "Initial release"
git push origin v1.0.0

# Move the major version tag (v1 always points to latest v1.x.x)
git tag -fa v1 -m "Update v1 to v1.0.0"
git push origin v1 --force

Consumers pin to @v1. They get non-breaking updates automatically. Breaking changes go to @v2.

Passing Secrets Securely

Reusable workflows support secrets: inherit for passing all secrets from the caller:

jobs:
  build:
    uses: your-org/shared-workflows/.github/workflows/reusable-build.yml@v1
    with:
      image-name: ghcr.io/your-org/api
    secrets: inherit

Convenient but coarse-grained. Explicit secret mapping is safer — it documents exactly what the reusable workflow needs and follows least-privilege.

Advanced Patterns

Reusable Deploy Workflow with Environment Matrix

# .github/workflows/reusable-deploy.yml
name: Reusable Deploy

on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
      image-digest:
        required: true
        type: string
      cluster:
        required: true
        type: string
    secrets:
      kube-config:
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - uses: actions/checkout@v4

      - uses: azure/setup-kubectl@v3

      - run: |
          echo "${{ secrets.kube-config }}" | base64 -d > kubeconfig
          export KUBECONFIG=kubeconfig
          kubectl set image deployment/api api=${{ inputs.image-digest }} \
            --namespace=${{ inputs.environment }}
          kubectl rollout status deployment/api \
            --namespace=${{ inputs.environment }} \
            --timeout=300s

The calling workflow chains environments:

# .github/workflows/release.yml
jobs:
  build:
    uses: ./.github/workflows/reusable-build.yml@v1
    # ...

  deploy-staging:
    needs: build
    uses: ./.github/workflows/reusable-deploy.yml@v1
    with:
      environment: staging
      image-digest: ${{ needs.build.outputs.image-digest }}
      cluster: staging-cluster
    secrets:
      kube-config: ${{ secrets.STAGING_KUBECONFIG }}

  deploy-production:
    needs: [build, deploy-staging]
    uses: ./.github/workflows/reusable-deploy.yml@v1
    with:
      environment: production
      image-digest: ${{ needs.build.outputs.image-digest }}
      cluster: production-cluster
    secrets:
      kube-config: ${{ secrets.PROD_KUBECONFIG }}

Same deploy logic for every environment. The environment input gates production with required reviewers.

Composite Action with Outputs

# .github/actions/docker-build/action.yml
name: "Docker Build"
description: "Build and optionally push a Docker image"

inputs:
  image-name:
    required: true
  push:
    required: false
    default: "false"
  registry-password:
    required: false

outputs:
  image-tag:
    description: "The full image tag"
    value: ${{ steps.meta.outputs.tags }}
  digest:
    description: "The image digest"
    value: ${{ steps.build.outputs.digest }}

runs:
  using: "composite"
  steps:
    - uses: docker/setup-buildx-action@v3

    - id: meta
      uses: docker/metadata-action@v5
      with:
        images: ${{ inputs.image-name }}
        tags: |
          type=sha,prefix=
          type=ref,event=branch
          type=semver,pattern={{version}}

    - if: inputs.push == 'true'
      uses: docker/login-action@v3
      with:
        registry: ghcr.io
        username: ${{ github.actor }}
        password: ${{ inputs.registry-password }}

    - id: build
      uses: docker/build-push-action@v6
      with:
        context: .
        push: ${{ inputs.push }}
        tags: ${{ steps.meta.outputs.tags }}
        cache-from: type=gha
        cache-to: type=gha,mode=max

This composite action encapsulates the entire Docker build pattern. Any repo gets production-grade Docker builds in three lines:

- uses: your-org/shared-actions/docker-build@v1
  with:
    image-name: ghcr.io/your-org/my-service
    push: "true"
    registry-password: ${{ secrets.GITHUB_TOKEN }}

Testing Shared Workflows

Reusable workflows are code. Test them before releasing.

# .github/workflows/test-reusable-build.yml
name: Test Reusable Build
on:
  pull_request:
    paths:
      - '.github/workflows/reusable-build.yml'
      - '.github/actions/**'

jobs:
  test-build:
    uses: ./.github/workflows/reusable-build.yml
    with:
      image-name: ghcr.io/your-org/test-image
      push: false
    secrets:
      registry-username: ${{ secrets.REGISTRY_USER }}
      registry-password: ${{ secrets.REGISTRY_PASS }}

Every PR that modifies a reusable workflow triggers a test run. If the build fails, the PR can't merge.

Migrating Existing Repos

When you have 30 repos with duplicated CI, migrating to shared workflows takes planning.

Step 1: Extract the most common pattern into a reusable workflow.

Step 2: Pick 2-3 repos and migrate them. Fix edge cases. Get the interface stable.

Step 3: Create a standard CI template:

# .github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:

jobs:
  ci:
    uses: your-org/shared-workflows/.github/workflows/standard-ci.yml@v1
    with:
      node-version: "22"
      image-name: ghcr.io/your-org/${{ github.event.repository.name }}
    secrets: inherit

Step 4: Roll out gradually. Let teams adopt at their own pace. Track adoption with a simple dashboard showing which repos use shared workflows vs custom ones.

Handling Breaking Changes in Shared Workflows

When you need to change a reusable workflow's interface, follow semver principles.

# Non-breaking: add an optional input with a default
# This is a minor version bump (v1 -> v1.1)
inputs:
  enable-cache:
    required: false
    type: boolean
    default: true

# Breaking: rename an input, remove an input, change behavior
# This is a major version bump (v1 -> v2)

Maintain parallel major versions when needed:

.github/workflows/
├── reusable-build.yml     # v2 — current
└── reusable-build-v1.yml  # v1 — deprecated, still works

Document deprecation timelines. Give consumers 2-4 weeks to migrate before removing the old version.

Monitoring Shared Workflow Adoption

Track which repos use your shared workflows:

# Find all repos using your shared workflows
gh search code "uses: your-org/shared-workflows" --json repository --jq '.[].repository.fullName' | sort -u

# Count adoption rate
TOTAL=$(gh repo list your-org --limit 1000 --json name --jq 'length')
USING=$(gh search code "uses: your-org/shared-workflows" --json repository --jq '[.[].repository.fullName] | unique | length')
echo "Adoption: $USING / $TOTAL repos"

Set a target: 80% of active repos should use shared workflows within 6 months. The remaining 20% likely have legitimate reasons for custom pipelines.

Debugging Tips

Problem: Reusable workflow can't access the caller's code. Fix: The reusable workflow must include its own actions/checkout step. It runs on a separate runner with a clean workspace.

Problem: Composite action fails with "shell is required." Fix: Every run step in a composite action MUST specify shell: bash (or another shell). This is not optional.

Problem: Outputs from reusable workflows are empty. Fix: Chain outputs through job outputs, then workflow outputs. Both layers must be declared.

Problem: Reusable workflow can't be found at the specified ref. Fix: Ensure the workflow file exists at the exact path in the shared repo at the specified tag.

Problem: Secrets are undefined in reusable workflows. Fix: Secrets must be explicitly declared in the workflow_call trigger and passed by the caller.

Conclusion

Stop copying YAML between repos. Reusable workflows for full pipelines, composite actions for shared steps. Version them with tags, pin consumers to major versions, and pass secrets explicitly. Test your shared workflows on every PR. Migrate repos gradually with a standard template. Your pipeline code should follow the same DRY principles as your application code — because it IS code.

Share:

Was this article helpful?

Sarah Chen
Sarah Chen

CI/CD Engineering Lead

Automation evangelist who believes no deployment should require a human. I write pipelines, break pipelines, and write about both. Code-first, always.

Related Articles

More in CI/CD

View all →

Discussion