GitHub Actions in Depth: Reusable Workflows, Composite Actions, and Avoiding the Copy-Paste Pipeline
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.
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.