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"
outputs:
cache-hit:
description: "Whether the dependency cache was reused"
value: ${{ steps.install.outputs.cache-hit }}
runs:
using: "composite"
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: "npm"
- id: install
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. The outputs block shows composite actions can expose values back to the calling job too, not just accept inputs — exactly the same two-directional interface a Terraform module has with its variables and outputs.
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
outputs:
deployed-url:
description: "The URL that was deployed to"
value: ${{ jobs.deploy.outputs.url }}
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
outputs:
url: ${{ steps.deploy-step.outputs.url }}
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
- id: deploy-step
run: |
./deploy.sh ${{ inputs.environment }}
echo "url=https://${{ inputs.environment }}.novuspark.com" >> "$GITHUB_OUTPUT"# .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. The outputs: block on workflow_call mirrors the composite action's outputs pattern — a reusable workflow can hand a computed value (a deployed URL, a build version) back to whatever called it.
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.
Combining both in one real pipeline
These two mechanisms aren't mutually exclusive — a reusable workflow's own steps are a natural place to call a composite action:
# .github/workflows/reusable-deploy.yml
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v4
- uses: your-org/shared-actions/setup-node-app@v1.2.0
with:
node-version: "20"
- run: npm run build
- run: ./deploy.sh ${{ inputs.environment }}A centrally-maintained reusable workflow, itself built from a centrally-maintained composite action, is genuinely the shape most mature organizations converge on: the composite action standardizes the "how do we set up this kind of project" logic, and the reusable workflow standardizes "how do we deploy this kind of project" — each solving duplication at the scope it's actually suited to.
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.
Testing a composite action or reusable workflow before other teams depend on it
A shared action or workflow used across many repositories deserves its own test coverage — a bug in it doesn't just break one project, it breaks everything that calls it, immediately, the moment the affected version is referenced:
# .github/workflows/test-composite-action.yml (in the repo that owns the action)
name: Test setup-node-app action
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-node-app
with:
node-version: "20"
- run: node --version | grep "v20" || exit 1Testing the action against its own repository's pull requests — before a version tag is cut and other repositories start depending on it — catches a regression before it ever reaches a consumer, the same "test before merge, not after a downstream break" principle covered for Molecule-tested Ansible roles earlier in this blog, applied here to shared CI building blocks specifically.
Documenting a shared action's inputs and outputs
A README.md inside a composite action's own directory, listing every input, its default, and every output, is worth writing even though the action.yml file technically documents the same thing — a consuming team skimming a repository for available shared actions rarely wants to open and parse the YAML schema directly just to understand what a given action does and what it needs from them. This is the same "write the README even though the interface technically documents itself" instinct covered for Terraform modules earlier in this blog — the variables and outputs describe what a module accepts, not why it's shaped the way it is.
Passing secrets through nested reusable workflows
A reusable workflow calling another reusable workflow internally needs secrets explicitly passed at each level — they don't propagate automatically through a chain of calls:
jobs:
deploy:
uses: ./.github/workflows/inner-deploy.yml
secrets:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}secrets: inherit (used in the multi-environment example in a later post) is a shortcut for passing every secret the caller has access to, but explicit per-secret passing — shown here — is worth preferring for any reusable workflow that shouldn't need broad access to everything the calling repository happens to have configured.
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, and they can expose outputs, not just accept inputs.
- 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. - The two combine naturally — a reusable workflow's own steps commonly call a composite action, solving duplication at two different scopes in the same pipeline.
- A reusable unit's interface is its inputs and outputs — 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.
