Reusable Workflows and Composite Actions
Stop copy-pasting CI and deploy logic across repos. Reusable workflows share whole jobs with inputs and secrets; composite actions bundle steps. When to use each, and the limits.
At some point every GitHub Actions setup hits the same wall: the same build-test-lint block, or the same deploy job, gets copy-pasted into a dozen workflows across a dozen repos. Then a build step changes and you have to hunt it down in all of them, and inevitably one gets missed. This is the DRY problem, and GitHub gives you two tools to solve it at different levels. Reusable workflows let one workflow call another whole workflow - entire jobs, wired up with inputs and secrets. Composite actions bundle a sequence of steps into a single action you drop into a job like any other. This guide covers both, when to reach for which, and the limits that will bite you if you do not know them.
The DRY problem, concretely
Say you have twenty services, each in its own repo, each with a CI workflow that does the same thing: check out, set up the language, install deps, run tests, run the linter, upload coverage. That is thirty lines of YAML duplicated twenty times. When you bump the Node version, or add a caching step, or switch coverage providers, you are editing twenty files and hoping you got them all. The duplication is not just tedious - it drifts. Repos fall out of sync, and "the CI" stops meaning one thing.
The fix is to define the shared logic once and reference it everywhere. The only real decision is what unit you are sharing: a whole job (or set of jobs), or a run of steps inside someone else's job. That distinction is exactly the split between reusable workflows and composite actions.
Reusable workflows: sharing whole jobs
A reusable workflow is a normal workflow file with one difference: it is triggered by workflow_call instead of push or pull_request. That makes it callable from other workflows. It declares an interface - the inputs it accepts and the secrets it needs - and the caller passes values in.
Here is a reusable deploy workflow. It lives at .github/workflows/deploy.yml in a repo (often a shared org/ci-workflows repo):
# .github/workflows/deploy.yml (the reusable workflow)
name: Deploy
on:
workflow_call:
inputs:
environment:
description: "Target environment (staging or production)"
required: true
type: string
image-tag:
description: "Container image tag to deploy"
required: false
type: string
default: "latest"
secrets:
REGISTRY_TOKEN:
required: true
outputs:
url:
description: "The deployed service URL"
value: ${{ jobs.deploy.outputs.url }}
jobs:
deploy:
runs-on: ubuntu-latest
outputs:
url: ${{ steps.release.outputs.url }}
steps:
- uses: actions/checkout@v4
- name: Log in to registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login -u ci --password-stdin
- name: Deploy
id: release
run: |
./deploy.sh --env "${{ inputs.environment }}" --tag "${{ inputs.image-tag }}"
echo "url=https://${{ inputs.environment }}.example.com" >> "$GITHUB_OUTPUT"
Two things to notice. Inputs are typed (string, boolean, or number) and can have defaults. Secrets are declared explicitly - a reusable workflow does not automatically see the caller's secrets. And this one passes an output back up: the deploy job exposes a url, and the workflow re-exports it under on.workflow_call.outputs so the caller can read it.
Calling a reusable workflow
The caller does not use steps to invoke it. It calls the workflow at the job level with uses:
# .github/workflows/ci.yml (the caller, in any repo)
name: CI
on:
push:
branches: [main]
jobs:
ship:
uses: my-org/ci-workflows/.github/workflows/deploy.yml@v1
with:
environment: production
image-tag: ${{ github.sha }}
secrets:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
notify:
needs: ship
runs-on: ubuntu-latest
steps:
- run: echo "Deployed to ${{ needs.ship.outputs.url }}"
The uses value is owner/repo/.github/workflows/deploy.yml@ref, where ref is a branch, tag, or full commit SHA - pin it (@v1 or a SHA) rather than tracking a moving branch, so an upstream change cannot silently alter your pipeline. Inputs go under with:, secrets under secrets:. A calling job that uses a reusable workflow cannot also define its own steps - the whole job is the call. Downstream jobs read the reusable workflow's outputs through needs.<job>.outputs.<name>, exactly as they would for a normal job's outputs.
If you want to forward every secret the caller has without listing each one, use secrets: inherit. Convenient for internal repos, but explicit passing is safer because it makes the secret surface obvious.
Composite actions: sharing a sequence of steps
A composite action solves a smaller-grained version of the same problem. Instead of sharing whole jobs, you bundle a run of steps into a single reusable action, then drop it into a job with uses: like any Marketplace action. Where a reusable workflow is "call this pipeline," a composite action is "run these five steps right here, in the middle of my job."
A composite action is defined by an action.yml file. The giveaway is runs.using: "composite":
# .github/actions/setup-node-app/action.yml
name: "Setup Node app"
description: "Checkout, set up Node with caching, and install dependencies"
inputs:
node-version:
description: "Node.js version"
required: false
default: "20"
runs:
using: "composite"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: "npm"
- name: Install dependencies
run: npm ci
shell: bash
Every run step in a composite action must specify shell: explicitly - that is the most common thing people forget, and it fails with a confusing error. Inputs work like any action's inputs, referenced as ${{ inputs.node-version }}. Composite actions can define outputs too, wired from a step's output.
Using it is the same as any action - a uses step inside a job:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: ./.github/actions/setup-node-app # local action in this repo
with:
node-version: "20"
- run: npm test
- run: npm run lint
The path ./.github/actions/setup-node-app points at a local action in the same repo. To share across repos, publish it in its own repo and reference it as my-org/setup-node-app@v1, the same way you would reference a Marketplace action. For more on authoring and publishing standalone actions, see the custom actions guide.
When to use which
The two tools operate at different altitudes, and that is the whole basis for choosing:
- Reach for a reusable workflow when you want to share whole jobs or a whole pipeline - the deploy job, the full build-test-scan sequence with its own
runs-on, matrix, and multiple jobs withneedsbetween them. It owns the runner and the job structure. - Reach for a composite action when you want to share a sequence of steps inside a job you already have - "check out, set up the toolchain, install deps." It does not own a runner; it runs on whatever runner the calling job provides, in the middle of that job's step list.
A simple test: if the thing you want to share includes runs-on and looks like a job, it is a reusable workflow. If it is a chunk of steps that slots into an existing job, it is a composite action. Many real setups use both - a reusable CI workflow whose jobs each call a composite action for the common setup steps.
The limits that bite
Both tools have hard limits worth knowing before you build a tower of them:
- Nesting depth. Reusable workflows can call other reusable workflows, but only up to four levels deep from the top-level caller. Composite actions can nest composite actions, capped at ten levels. Do not try to build a deep call tree; keep it shallow.
- Secrets are not automatic. A reusable workflow only sees the secrets you pass under
secrets:(or all of them viasecrets: inherit). A composite action cannot access secrets at all through asecretscontext - if a step inside it needs a secret, pass the value in as an input from the calling workflow (which read it fromsecrets). Passing secrets as inputs works but means the value flows through an input, so mask it and be deliberate. envdoes not flow into composite actions. Environment variables set in the calling workflow are not visible inside a composite action's steps. Pass what you need as inputs.- A calling job cannot mix a reusable-workflow
useswith its ownsteps. The job is either a call or a normal job, not both. GITHUB_ENVandGITHUB_OUTPUTstill work inside composite steps, so you can pass data between the steps of a composite action and export outputs - but remember theshell:requirement on everyrun.
The shape of it
The DRY problem in CI is always the same: the same logic copy-pasted until it drifts. Fix it by defining the logic once and referencing it. A reusable workflow shares whole jobs - triggered by on: workflow_call, typed inputs and declared secrets, called at the job level with uses: owner/repo/.github/workflows/x.yml@ref plus with: and secrets:, passing results back as outputs. A composite action shares a run of steps - an action.yml with runs.using: composite, dropped into a job with uses: like any action, remembering shell: on every run. Whole jobs go in reusable workflows; sequences of steps go in composite actions. Keep the nesting shallow, pass secrets and env explicitly, and one edit updates every repo at once instead of twenty.