Skip to content

GitHub Actions Reusable Workflows

Lesson 2 of 11Intermediate → Advanced5 min readGitHub Actions & CI/CD · Advanced ActionsVerified: workflow_call syntax, August 2026

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.

{/* .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.

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.

They solve overlapping problems and the choice is usually clear once you know what each one cannot do:

Reusable workflowComposite action
Referenced atJob levelStep level
Can define multiple jobsYesNo
Can choose runs-onYesNo — inherits the caller’s runner
Can set permissionsYesNo
Can use strategy/matrixYesNo
Combined with other steps in a jobNoYes
Nesting depthUp to 4 levelsUp 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.

The reference supports the same forms as an action:

uses: OWNER/repo/.github/workflows/ci.yml@v1 # moving major tag
uses: OWNER/repo/.github/workflows/ci.yml@a1b2c3d4… # exact commit
uses: OWNER/repo/.github/workflows/ci.yml@main # branch — avoid
uses: ./.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.

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@v1

The 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.

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.

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.

Getting a value back requires two hops, and missing either produces an empty string with no error:

  1. The step writes to $GITHUB_OUTPUT.

  2. The job maps that step output into jobs.<id>.outputs.

  3. The workflow maps the job output into on.workflow_call.outputs.<name>.value.

  4. 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.

  1. Pick the two or three repositories whose pipelines are already most similar.

  2. 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.

  3. Convert one repository. Run both the old and new workflows in parallel for a week and compare.

  4. Tag v1 and convert the rest.

  5. 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.

  1. Create a repository holding a reusable workflow with a typed input and an output.

  2. Call it from another repository at @v1. Confirm the called workflow’s jobs appear in the caller’s run.

  3. Read the output in a dependent job. Then deliberately remove the workflow-level outputs mapping and confirm the value becomes empty rather than erroring.

  4. Have the called workflow attempt an API call needing packages: write without the caller granting it. Read the failure, then grant it and confirm the intersection rule.

  5. Add a matrix over the uses: job and confirm the whole job graph repeats per matrix value.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

Want production-ready workflow templates? The Professional Toolkit has five, with permissions set correctly.