Build the mental model
Pipelines get copied. One repository gets a good Node CI workflow, the next team pastes it in, and eighteen months later five repositories run five slightly different versions of the same idea: different Node versions, different cache keys, one of them still on a deprecated action. Nobody made a decision to diverge. Nobody had a place to make the fix once.
GitHub offers two mechanisms for sharing, and the split between them is about granularity. A reusable workflow is a whole workflow file whose trigger is on: workflow_call. It declares typed inputs, optionally declares secrets, and can declare outputs. A caller references it as jobs.<id>.uses: owner/repo/.github/workflows/file.yml@ref, and that caller job has no runs-on and no steps of its own, because the called workflow brings its own jobs and its own runners. You are sharing entire jobs.
A composite action is a sequence of steps packaged in an action.yml with runs.using: composite. A caller uses it inside a job, as one step among others, and it executes on that job's runner sharing that job's filesystem and working directory. You are sharing part of a job.
The choice follows directly from that. Install Node, restore the cache, run npm ci is a step sequence that other steps in the same job depend on having happened on the same machine, so it wants to be a composite action. Lint, typecheck, test, and build, on this matrix, in this order, is a whole pipeline, so it wants to be a reusable workflow.
Pin the reference to a tag or a commit SHA, never a branch. Using @main means every caller silently changes behaviour the day someone edits the shared file.
THREE REPOS CALLING ONE SHARED WORKFLOW
---------------------------------------
repo: api-service repo: web-app repo: worker
.github/workflows/ .github/workflows/ .github/workflows/
ci.yml ci.yml ci.yml
jobs.test.uses: --+ jobs.test.uses: --+ jobs.test.uses: --+
| | |
v v v
+-----------------------------------------------------------+
| my-org/ci-workflows |
| .github/workflows/node-ci.yml@v1 |
| on: workflow_call |
| inputs: node-version, run-lint |
| secrets: npm-token |
| jobs: test <- runs-on and steps live HERE |
+-----------------------------------------------------------+
one fix here reaches all three repos when you retag v1
composite action = a reusable SEQUENCE OF STEPS inside a job
reusable workflow = one or more whole JOBS, each with a runnerConnect it to a real scenario
Create a dedicated repository, say my-org/ci-workflows, and put node-ci.yml in its .github/workflows/ directory with on: workflow_call. Declare node-version as a string input with a default, run-lint as a boolean input, and npm-token as an optional secret. Inside the jobs you read them as inputs.node-version and secrets.npm-token, exactly as you would read any other context.
A consuming repository then reduces its entire CI file to a few lines: a job whose uses: points at my-org/ci-workflows/.github/workflows/node-ci.yml@v1, a with: block supplying node-version, and a secrets: block passing npm-token from its own repository secret.
Note what the caller job does not have: no runs-on, no steps. Adding either is a schema error, and it is the single most common mistake when converting an existing job into a call.
Two operational points. Secrets are not inherited automatically, so you pass each one explicitly, or write secrets: inherit when the callee should receive everything the caller has. And @v1 is a tag you move deliberately, so a fix lands in every caller when you retag rather than the instant you push. If the shared repository is private, its Actions access setting has to allow other repositories in the organisation to call it, otherwise every caller fails to resolve the reference.
Try the working example
name: Reusable Node CI
on:
workflow_call:
inputs:
node-version:
description: Node major version to test against
required: false
default: '20'
type: string
run-lint:
description: Whether to run the lint step
required: false
default: true
type: boolean
secrets:
npm-token:
description: Token for the private registry
required: false
outputs:
test-result:
description: Outcome of the test step
value: ${{ jobs.test.outputs.result }}
jobs:
test:
runs-on: ubuntu-latest
outputs:
result: ${{ steps.run-tests.outputs.outcome }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: npm
- name: Install dependencies
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.npm-token }}
- name: Lint
if: ${{ inputs.run-lint }}
run: npm run lint
- name: Test
id: run-tests
run: |
npm test
echo "outcome=passed" >> "$GITHUB_OUTPUT"
On its own this file never runs. There is no push or pull_request trigger, only workflow_call, so it executes solely when another workflow references it. When a caller does reference it, the test job runs on a runner belonging to this workflow, not the caller's. inputs.node-version resolves to whatever the caller passed under with:, or to the declared default of 20 when the caller passed nothing. The lint step runs only when run-lint evaluates true, which is also its declared default. secrets.npm-token is an empty string unless the caller explicitly passed it, so a caller that forgets it fails at install time against a private registry rather than at parse time, which is why the omission is easy to miss in review. The workflow's declared output, test-result, carries the test step's result back to the caller, which can read it through needs on a downstream job.5-minute try-it
Pick two repositories whose CI files started as copies of each other and diff them; the differences are the drift this lesson is about. Move the common part into a reusable workflow in a shared repository, tag it v1, and convert both repos to call it. Then take one step sequence whose steps must share a filesystem with what follows, such as setup plus cache plus install, and package that as a composite action instead, so you have built one of each and can feel where the boundary between them lies.
One important caution
Adding runs-on or steps to a job whose uses: points at a reusable workflow: the caller job supplies neither, and the workflow fails to parse.
Referencing a shared workflow at @main: every consuming repository changes behaviour the moment someone edits the shared file, with no release to review and nothing to roll back to.
GitHub Docs - Reusing workflows — CI/CD with GitHub Actions