Skip to main content
ship it and sleep

GitHub Actions in Depth: Reusable Workflows, Composite Actions, and Avoiding the Copy-Paste Pipeline

7 min read Chapter 7 of 66

GitHub Actions in Depth

The e-commerce platform has five service repos. Each repo needs a CI pipeline that builds a Docker image, runs tests, scans for vulnerabilities, and updates the infra repo. The pipeline structure is identical across services. The image name changes. The test commands change. The Locust scenarios change. Everything else is the same.

The team copies the workflow from the checkout service to each new repo. Within three months, the five copies have diverged. The catalog pipeline still uses actions/checkout@v3 because nobody updated it. The payments pipeline has an extra security scan step that was never propagated to the other services. The frontend pipeline has a Lighthouse CI step that would be useful for the other services but was never extracted into a shared component.

This is the copy-paste pipeline problem. It gets worse linearly with the number of services and exponentially with the number of pipeline changes. Five services with one change per month means five manual updates per month. At twenty services, it is untenable.

Reusable workflow vs composite action decision tree

The diagram presents a decision tree for choosing between a reusable workflow and a composite action. The first decision point asks whether the shared logic needs to orchestrate multiple jobs or just wrap multiple steps. Multi-job orchestration leads to reusable workflows. Step-level wrapping leads to composite actions. A second branch under reusable workflows asks whether the caller needs to customize the job matrix, and under composite actions asks whether the action needs its own action.yml with typed inputs. Both paths end with concrete examples from the e-commerce platform.

The Failure

The team discovers a vulnerability in the docker/build-push-action at version 5. GitHub publishes a security advisory. The team needs to update all five repos to version 6. They update the checkout service first, verify it works, then update the other four. The inventory service update fails because the workflow was last touched six months ago and uses a different caching strategy. The frontend service update reveals that it was still on version 4. Two days of work across five repos for a single action version bump.

With a shared workflow, the update happens once, in one repository. All five services pick up the change on their next pipeline run.

The Mechanism

GitHub Actions provides two mechanisms for pipeline reuse:

Reusable workflows are complete workflow files that can be called from other workflows using uses: org/repo/.github/workflows/file.yml@ref. The caller passes inputs, secrets, and receives outputs. The reusable workflow defines jobs, steps, and the full execution graph. The caller cannot modify the jobs inside the reusable workflow.

Composite actions are step-level abstractions defined in an action.yml file. A composite action bundles multiple steps into a single step that callers reference with uses: org/repo/path@ref. The caller can place the composite action step inside any job alongside other steps.

The distinction: reusable workflows control the job graph. Composite actions control a sequence of steps within a job. Use reusable workflows when you want to standardize the entire pipeline structure (build → test → scan → promote). Use composite actions when you want to standardize a specific operation (Docker build with caching, Trivy scan with threshold tuning) that callers embed in their own job structure.

The Implementation

The Shared Workflow Repository

The e-commerce platform uses a dedicated repository for shared pipeline components:

acme/.github/
├── workflows/
│   └── ci-service.yml          # Reusable workflow: full CI pipeline
└── actions/
    ├── docker-build/
    │   └── action.yml           # Composite action: build + push + cache
    ├── trivy-scan/
    │   └── action.yml           # Composite action: scan with thresholds
    └── update-infra/
        └── action.yml           # Composite action: update image tag in infra repo

Reusable Workflow: Full CI Pipeline

# acme/.github/workflows/ci-service.yml
# HARDENED: Single source of truth for all service pipelines
name: CI Service Pipeline

on:
  workflow_call:
    inputs:
      service-name:
        required: true
        type: string
        description: "Service name (e.g., checkout-service)"
      image:
        required: true
        type: string
        description: "Full image reference without tag (e.g., ghcr.io/acme/checkout-service)"
      run-contract-tests:
        required: false
        type: boolean
        default: false
      locust-scenario:
        required: false
        type: string
        default: ""
        description: "Path to Locust file, empty to skip performance test"
      locust-p99-threshold:
        required: false
        type: number
        default: 500
    secrets:
      INFRA_REPO_TOKEN:
        required: true
      REGISTRY_TOKEN:
        required: true

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    outputs:
      image-tag: ${{ github.sha }}
      image-digest: ${{ steps.build.outputs.digest }}
    steps:
      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.REGISTRY_TOKEN }}

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

  test:
    runs-on: ubuntu-latest
    needs: [build]
    strategy:
      fail-fast: false
      matrix:
        suite:
          - unit
          - integration
    steps:
      - uses: actions/checkout@v4

      - name: Start test dependencies
        if: matrix.suite == 'integration'
        run: docker compose -f docker-compose.test.yml up -d --wait

      - name: Run ${{ matrix.suite }} tests
        run: |
          docker run --rm \
            ${{ matrix.suite == 'integration' && '--network=host' || '' }} \
            ${{ inputs.image }}@${{ needs.build.outputs.image-digest }} \
            ./run-${{ matrix.suite }}-tests.sh

      - name: Cleanup
        if: matrix.suite == 'integration' && always()
        run: docker compose -f docker-compose.test.yml down

  contract-test:
    if: inputs.run-contract-tests
    runs-on: ubuntu-latest
    needs: [build]
    steps:
      - uses: actions/checkout@v4
      - name: Run contract tests
        run: |
          docker run --rm \
            ${{ inputs.image }}@${{ needs.build.outputs.image-digest }} \
            ./run-contract-tests.sh

  scan:
    runs-on: ubuntu-latest
    needs: [build]
    steps:
      - uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ inputs.image }}@${{ needs.build.outputs.image-digest }}
          exit-code: 1
          severity: CRITICAL,HIGH
          format: table

  promote:
    runs-on: ubuntu-latest
    needs: [build, test, scan]
    if: github.ref == 'refs/heads/main' && !cancelled() && !contains(needs.*.result, 'failure')
    steps:
      - uses: actions/checkout@v4
        with:
          repository: acme/ecommerce-infra
          token: ${{ secrets.INFRA_REPO_TOKEN }}

      - name: Update staging image
        run: |
          cd overlays/staging/${{ inputs.service-name }}
          kustomize edit set image \
            ${{ inputs.image }}=${{ inputs.image }}:${{ needs.build.outputs.image-tag }}

      - name: Commit
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add .
          git commit -m "${{ inputs.service-name }}: promote ${{ needs.build.outputs.image-tag }} to staging"
          git push

Caller Workflow in a Service Repo

# checkout-service/.github/workflows/ci.yml
# HARDENED: Calls shared workflow, service-specific configuration only
name: CI
on:
  push:
    branches: [main]
  pull_request:

jobs:
  ci:
    uses: acme/.github/workflows/ci-service.yml@v2
    with:
      service-name: checkout
      image: ghcr.io/acme/checkout-service
      run-contract-tests: true
      locust-scenario: load-tests/locustfile.py
      locust-p99-threshold: 800
    secrets:
      INFRA_REPO_TOKEN: ${{ secrets.INFRA_REPO_TOKEN }}
      REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}

The entire pipeline for the checkout service is 20 lines. The build logic, test orchestration, scanning, and promotion are defined once in the shared workflow. Five services, one pipeline definition, one place to update.

Composite Action: Docker Build with Caching

# acme/.github/actions/docker-build/action.yml
# HARDENED: Standardized Docker build with caching and metadata
name: Docker Build
description: Build and push a Docker image with GHA cache and metadata

inputs:
  image:
    required: true
    description: "Full image reference without tag"
  context:
    required: false
    default: "."
  registry-username:
    required: true
  registry-password:
    required: true

outputs:
  digest:
    description: "Image digest"
    value: ${{ steps.build.outputs.digest }}
  tag:
    description: "Image tag (short SHA)"
    value: ${{ steps.meta.outputs.version }}

runs:
  using: composite
  steps:
    - name: Image metadata
      id: meta
      uses: docker/metadata-action@v5
      with:
        images: ${{ inputs.image }}
        tags: type=sha,prefix=,format=short

    - name: Login to registry
      uses: docker/login-action@v3
      with:
        registry: ghcr.io
        username: ${{ inputs.registry-username }}
        password: ${{ inputs.registry-password }}

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

The Gate

The shared workflow itself is a gate. When the platform team updates the security scan thresholds or adds a new gate (SBOM generation, license compliance), every service picks up the change on the next pipeline run. No service can skip the new gate unless the reusable workflow explicitly allows it via an input flag.

The version reference (@v2) on the uses: line in the caller workflow is also a gate. The platform team can make breaking changes on main of the shared workflow repo and tag a new version when ready. Services migrate to the new version on their own schedule. This prevents a shared workflow change from breaking all five pipelines simultaneously.

The Recovery

When a shared workflow change breaks a service pipeline, the service team can pin to the previous version tag (@v1) while the platform team investigates. This is the advantage of version-tagged references over @main: the service pipeline is insulated from in-progress changes to the shared workflow.

When a composite action update causes failures, the same pinning strategy applies. Pin to the previous SHA or tag, fix the action, test it, and release a new version.