GitHub Actions Reusable Workflows and Composite Actions for DRY Pipelines
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
| Scenario | Use |
|---|---|
| Full build-test-deploy pipeline | Reusable workflow |
| Shared setup steps (install, lint) | Composite action |
| Needs its own runner or secrets | Reusable workflow |
| Needs to run alongside other steps | Composite action |
| Cross-repo standardization | Either — 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.
Related Articles
Was this article helpful?
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
The Complete Guide to GitHub Actions CI/CD: From Zero to Production-Ready Pipelines
Build production-grade GitHub Actions CI/CD pipelines — from first workflow to reusable workflows, matrix builds, and deployment gates.
Hardening GitHub Actions: Permissions, OIDC, and Pinned Actions
Harden GitHub Actions security with least-privilege permissions, OIDC federation, SHA-pinned actions, and secrets management best practices.
GitHub Actions Matrix Builds for Multi-Platform Testing
Master GitHub Actions matrix builds to test across multiple OS versions, language versions, and configurations in parallel.
Fix GitHub Actions 'Resource not accessible by integration'
Resolve the GitHub Actions 'Resource not accessible by integration' error by fixing workflow permissions, GITHUB_TOKEN scopes, and repository settings.
GitLab CI Pipeline Optimization: Caching, DAG, and Parallel Jobs
Cut your GitLab CI pipeline time from 25 minutes to 6 with smart caching, DAG dependency graphs, parallel test splitting, and stage optimization.
Automated Semantic Versioning with Conventional Commits and release-please
Stop manually bumping versions. Use conventional commits and release-please to automate versioning, changelogs, and releases.
More in CI/CD
View all →GitLab CI Dynamic Child Pipelines For Monorepo Microservices Deployment
After a decade of wrestling with CI/CD systems and watching monorepo deployments go from "simple" to "nightmare" and back to "elegant," I've seen every per...
Fix GitHub Actions Workflow Stuck in 'Queued'
Diagnose and resolve GitHub Actions workflows that remain stuck in 'queued' status, covering runner availability, concurrency limits, and label mismatches.