Reusable Workflows vs Composite Actions: When to Use Which
Reusable Workflows vs Composite Actions
The Failure
The platform team builds a composite action that wraps the entire CI pipeline: build, test, scan, and promote. It works on paper. In practice, the composite action runs all steps sequentially in a single job on a single runner. Tests, scanning, and building cannot run in parallel. The pipeline that took 11 minutes as a DAG of parallel jobs now takes 17 minutes as a sequential composite action.
The team rewrites it as a reusable workflow with parallel jobs. It works. Then the payments team needs a custom security scan step that the reusable workflow does not support. They fork the reusable workflow into their own copy. The copy-paste problem returns.
The solution is two layers: reusable workflows for pipeline orchestration (the job graph) and composite actions for standardized operations within those jobs (Docker build, Trivy scan, infra repo update).
The Mechanism
Feature Comparison
| Feature | Reusable Workflow | Composite Action |
|---|---|---|
| Defines jobs | Yes | No (steps only) |
| Parallel execution | Yes (via job DAG) | No (sequential steps) |
| Separate runner per job | Yes | No (runs on caller’s runner) |
| Can be nested | Yes (4 levels max) | Yes (10 levels max) |
| Secrets handling | secrets: keyword, inherit option | Must pass as inputs (visible in logs if echoed) |
| Runner selection | Defined in callee | Inherits from caller |
| Matrix support | Callee defines its own matrix | Caller wraps in matrix |
| Maximum workflow depth | 4 (caller → reusable → reusable → reusable) | No practical limit for nesting |
| Caller visibility | Separate workflow run in Actions UI | Inline within caller job |
The Decision Rule
Use a reusable workflow when:
- The shared logic involves multiple jobs that should run in parallel
- The shared logic needs its own runner selection (e.g., a build job on a large runner)
- The shared logic should appear as a separate workflow run in the GitHub Actions UI
- Secrets must be handled without passing them through input parameters
Use a composite action when:
- The shared logic is a sequence of 2-5 steps that belong inside a job
- The caller needs to mix the shared steps with custom steps in the same job
- The shared logic is an operation (Docker build, scan, deploy step) not a pipeline
When both could work, prefer composite actions for operations and reusable workflows for pipelines. One reusable workflow calls multiple composite actions. That is the two-layer architecture.
The Implementation
Two-Layer Architecture
Caller workflow (service repo)
└── calls: Reusable workflow (shared repo) # Pipeline orchestration
├── Job: build
│ └── uses: composite action docker-build # Operation
├── Job: test (matrix)
├── Job: scan
│ └── uses: composite action trivy-scan # Operation
└── Job: promote
└── uses: composite action update-infra # Operation
The reusable workflow defines the job graph. Each job uses composite actions for standardized operations. The service team controls what enters the pipeline by choosing which reusable workflow to call and what inputs to provide. The platform team controls how each operation works by updating the composite actions.
Extending the Pipeline Without Forking
The payments team needs a SAST scan that other services do not. Instead of forking the reusable workflow, they use the reusable workflow for the standard pipeline and add a custom job in their caller workflow:
# HARDENED: Standard pipeline plus service-specific gate
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
standard-ci:
uses: acme/.github/workflows/ci-service.yml@v2
with:
service-name: payments
image: ghcr.io/acme/payments-service
run-contract-tests: true
secrets:
INFRA_REPO_TOKEN: ${{ secrets.INFRA_REPO_TOKEN }}
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
sast-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run CodeQL analysis
uses: github/codeql-action/analyze@v3
with:
languages: java
promotion-gate:
runs-on: ubuntu-latest
needs: [standard-ci, sast-scan]
if: github.ref == 'refs/heads/main'
steps:
- run: echo "All gates passed, including SAST"
The standard-ci job runs the shared pipeline. The sast-scan job runs in parallel. The promotion-gate job ensures both must pass. The payments team gets their custom gate without modifying the shared workflow or maintaining a fork.
The Gate
The version reference on the reusable workflow (@v2) acts as a stability gate. The platform team can develop v3 of the shared workflow on the main branch. Services continue using @v2 until the platform team validates the new version and services opt in to the migration.
Semantic versioning for reusable workflows:
- Patch (
v2.0.1): Bug fixes, no input/output changes. Safe to auto-update. - Minor (
v2.1.0): New optional inputs, new jobs. Backward compatible. - Major (
v3.0.0): Input changes, removed features, structural changes. Requires migration.
# Tag management in the shared workflow repo
git tag v2.1.0
git push origin v2.1.0
# Move the major version tag to the latest minor
git tag -f v2 v2.1.0
git push origin v2 --force
Services referencing @v2 automatically get minor and patch updates. Services referencing @v2.0.0 stay pinned to an exact version. The platform team documents which approach each service should use based on stability requirements.
The Recovery
When a shared workflow update breaks a service:
- The service team pins to the previous version:
@v2.0.0instead of@v2 - The platform team investigates the breakage using the service’s pipeline logs
- The fix is applied to the shared workflow and released as a new patch or minor version
- The service team updates their reference
When a composite action update breaks a step within a shared workflow, the platform team reverts the action change and releases a patch. Since composite actions are referenced by the reusable workflow (not by service repos directly), the fix propagates automatically.