This is the third post in our GitHub and GitHub Actions series, building on your first CI workflow.
An organization with more than a handful of repositories eventually notices the same pattern we've flagged repeatedly throughout this blog — for Terraform modules, for Ansible roles: the same steps, copy-pasted across every repository's workflow file, quietly drifting apart the first time someone updates one copy and not the others. GitHub Actions has two distinct answers to this, for two different scopes of reuse.
Composite actions: reusable steps, within or across repositories
A composite action packages a sequence of steps into a single, reusable action — callable from any job, the same way you'd call actions/checkout@v4.
# .github/actions/setup-node-app/action.yml
name: "Set up Node app"
description: "Checkout, install Node, and install dependencies"
inputs:
node-version:
description: "Node.js version"
default: "20"
runs:
using: "composite"
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: "npm"
- run: npm ci
shell: bash# .github/workflows/ci.yml
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-node-app
with:
node-version: "22"
- run: npm testThree steps (setup Node, cache, install) collapse into one reusable call, with node-version exposed as a real input — the same design principle we've applied to Terraform module variables and Ansible role defaults throughout this blog: a good reusable unit's interface is its inputs, not its internals. Note shell: bash is required on run: steps inside a composite action, unlike in a normal workflow, where it's inferred automatically.
Reusable workflows: sharing entire jobs, across repositories
A composite action reuses steps within a job. A reusable workflow goes a level higher — an entire callable workflow, typically shared across many repositories from one central location.
# .github/workflows/reusable-deploy.yml (in a shared/central repository)
name: Reusable Deploy
on:
workflow_call:
inputs:
environment:
required: true
type: string
secrets:
AWS_ACCESS_KEY_ID:
required: true
AWS_SECRET_ACCESS_KEY:
required: true
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: eu-west-2
- run: ./deploy.sh ${{ inputs.environment }}# .github/workflows/ci.yml (in any repository that wants to use it)
jobs:
deploy-staging:
uses: your-org/shared-workflows/.github/workflows/reusable-deploy.yml@main
with:
environment: staging
secrets:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}workflow_call is the trigger that makes a workflow callable from another workflow, rather than only runnable directly. This is the mechanism an organization uses to standardize an entire deployment process — every repository calling the same, centrally-maintained workflow, rather than each repository maintaining its own near-identical copy of deploy logic that drifts over time exactly the way we've described happening with copy-pasted Terraform and Ansible configuration elsewhere in this blog.
Composite action or reusable workflow — which one
- Composite action: reusing a handful of steps inside a job you're otherwise writing yourself — setup logic, a repeated build step, a notification step at the end.
- Reusable workflow: standardizing an entire job or process — a full deploy pipeline, a full release process — consistently across many repositories, ideally maintained in one central location.
A useful rule of thumb: if what you're duplicating is "a few steps," reach for a composite action; if it's "this entire workflow file, except for one or two values that differ," reach for a reusable workflow instead.
Versioning shared actions and workflows properly
- uses: your-org/shared-actions/setup-node-app@v1.2.0 # pinned, safe
- uses: your-org/shared-actions/setup-node-app@main # unpinned, riskyThe exact same principle as pinning a Terraform module's ref or a provider version, covered earlier in this blog: @main means the shared action's behavior can change underneath every repository using it, the moment anyone merges a change to main — with no corresponding change in any consuming repository's own history to explain why a build suddenly behaves differently. Pin to a tagged release, and bump that version deliberately, the same way you'd bump any other dependency.
What to actually remember from this post
- Composite actions reuse a sequence of steps — the right tool when you're duplicating "a few steps" across jobs or repositories.
- Reusable workflows (
workflow_call) reuse an entire job or process — the right tool for standardizing something as complete as a deployment pipeline across many repositories. - A reusable unit's interface is its inputs — design composite actions and reusable workflows the same deliberate way you'd design a Terraform module's variables.
- Pin versions of shared actions and workflows —
@mainexposes every consumer to every change, immediately and without warning.
Next in the series: Securing GitHub Actions: Secrets, OIDC, and Least Privilege, where we cover the security side of exactly the deploy pattern shown above.
