Thirty repositories with the same pipeline copied into each is a maintenance problem that gets worse every month: a fix lands in three of them, an action version is bumped in seven, and nobody can say what any given repository actually runs.
A reusable workflow makes the pipeline a single artifact that repositories call.
Defining a callable workflow
Section titled “Defining a callable workflow”{/* .github/workflows/node-ci.yml in OWNER/shared-workflows */}name: Node CI
on: workflow_call: inputs: node-version: description: "Node major version to test against" type: string default: "24" run-lint: type: boolean default: true secrets: NPM_TOKEN: required: false outputs: coverage: description: "Line coverage percentage" value: ${{ jobs.test.outputs.coverage }}
jobs: test: runs-on: ubuntu-latest outputs: coverage: ${{ steps.cov.outputs.pct }} steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: ${{ inputs.node-version }} cache: npm - run: npm ci - if: inputs.run-lint run: npm run lint - run: npm test - id: cov run: echo "pct=93" >> "$GITHUB_OUTPUT"workflow_call is what makes it callable. Note the three declarations it supports — inputs,
secrets and outputs — and that inputs are typed: string, boolean, number or choice.
Calling with the wrong type is a workflow error rather than a confusing runtime failure.
Inside the called workflow, values come from the inputs context, not github.event.inputs.
Calling it
Section titled “Calling it”jobs: ci: uses: OWNER/shared-workflows/.github/workflows/node-ci.yml@v1 with: node-version: "22" run-lint: true secrets: NPM_TOKEN: ${{ secrets.NPM_TOKEN }}The uses is at job level, not step level. That is the structural difference from a composite
action and it has consequences: a calling job that uses a reusable workflow has no steps of its own,
no runs-on, and cannot add anything before or after. The reusable workflow brings its own jobs and
its own runners.
Reusable workflow or composite action?
Section titled “Reusable workflow or composite action?”They solve overlapping problems and the choice is usually clear once you know what each one cannot do:
| Reusable workflow | Composite action | |
|---|---|---|
| Referenced at | Job level | Step level |
| Can define multiple jobs | Yes | No |
Can choose runs-on | Yes | No — inherits the caller’s runner |
Can set permissions | Yes | No |
Can use strategy/matrix | Yes | No |
| Combined with other steps in a job | No | Yes |
| Nesting depth | Up to 4 levels | Up to 10 |
The short version: a composite action packages steps; a reusable workflow packages jobs. If what you want to share includes a matrix, several jobs, or a permissions block, it has to be a reusable workflow.
Versioning
Section titled “Versioning”The reference supports the same forms as an action:
uses: OWNER/repo/.github/workflows/ci.yml@v1 # moving major taguses: OWNER/repo/.github/workflows/ci.yml@a1b2c3d4… # exact commituses: OWNER/repo/.github/workflows/ci.yml@main # branch — avoiduses: ./.github/workflows/ci.yml # same repository@main means every repository re-runs whatever was pushed to that branch minutes ago. A mistake there
breaks thirty pipelines simultaneously, and there is no way to tell which version a past run used.
@v1 is the usual compromise: repositories get fixes automatically, and you promise not to make
breaking changes without moving to v2. For a workflow that handles deployment credentials, pin the
commit SHA — see pinning actions, which applies equally
here.
The local form, ./.github/workflows/ci.yml, always uses the caller’s own ref. It cannot be
pinned and does not need to be.
Permissions
Section titled “Permissions”A called workflow cannot grant itself more than the caller has:
jobs: ci: permissions: contents: read packages: write uses: OWNER/shared-workflows/.github/workflows/publish.yml@v1The effective permissions are the intersection of what the caller granted and what the called workflow
declares. This is a real security property: adopting someone else’s reusable workflow cannot silently
escalate your token. It is also the cause of the most common failure — a reusable workflow that needs
packages: write fails in a repository whose caller did not grant it, with an error at the API call
rather than at the uses line.
Nesting and its limits
Section titled “Nesting and its limits”A reusable workflow can call another, up to four levels including the caller. Loops are rejected.
Depth is easy to reach without noticing: a repository calls the org’s standard CI, which calls the platform team’s build workflow, which calls a language-specific one. Debugging at that depth is genuinely hard — the run’s job list shows the leaf jobs with no obvious indication of which layer produced them.
Access from other repositories
Section titled “Access from other repositories”A reusable workflow in a private repository is not callable from another repository unless that repository’s Actions settings explicitly allow it. In the source repository, under Settings → Actions → General → Access, choose whether workflows may be shared with the organisation or enterprise.
Availability of the organisation-wide options depends on the account’s plan and on organisation policy, so confirm what your organisation actually has rather than assuming. A public repository’s workflows are callable by anyone.
Outputs
Section titled “Outputs”Getting a value back requires two hops, and missing either produces an empty string with no error:
-
The step writes to
$GITHUB_OUTPUT. -
The job maps that step output into
jobs.<id>.outputs. -
The workflow maps the job output into
on.workflow_call.outputs.<name>.value. -
The caller reads
needs.<job-id>.outputs.<name>.
jobs: ci: uses: OWNER/shared-workflows/.github/workflows/node-ci.yml@v1
report: needs: ci runs-on: ubuntu-latest steps: - run: echo "coverage was ${{ needs.ci.outputs.coverage }}"An empty output is almost always a missing mapping at one of the layers rather than a missing value. See outputs for the same pattern between ordinary jobs.
Migrating without a flag day
Section titled “Migrating without a flag day”-
Pick the two or three repositories whose pipelines are already most similar.
-
Extract the common pipeline into a reusable workflow, parameterising exactly the differences you found — no more. Speculative inputs are the main way these become unmaintainable.
-
Convert one repository. Run both the old and new workflows in parallel for a week and compare.
-
Tag
v1and convert the rest. -
When a fourth repository needs something different, resist adding an input for it until a fifth needs the same thing. Two reusable workflows are usually better than one with eleven booleans.
Exercise
Section titled “Exercise”-
Create a repository holding a reusable workflow with a typed input and an output.
-
Call it from another repository at
@v1. Confirm the called workflow’s jobs appear in the caller’s run. -
Read the output in a dependent job. Then deliberately remove the workflow-level
outputsmapping and confirm the value becomes empty rather than erroring. -
Have the called workflow attempt an API call needing
packages: writewithout the caller granting it. Read the failure, then grant it and confirm the intersection rule. -
Add a matrix over the
uses:job and confirm the whole job graph repeats per matrix value.