Reusable Workflow

A reusable workflow is a GitHub Actions workflow that declares on: workflow_call, so other workflows call it as a job and share one pipeline definition.

A reusable workflow is a GitHub Actions workflow file that declares on: workflow_call, so other workflows can call it as a job rather than copying its steps. One definition of a pipeline then lives in one file, and every caller across repositories or across jobs in the same repository runs that same definition.

The pattern exists because build, test, and release logic tends to be duplicated into every repository that needs it. A reusable workflow turns that duplication into a single file plus a one line reference at each call site.

Definition

A reusable workflow has two halves. The called workflow is an ordinary workflow file that lists workflow_call among its triggers and optionally declares typed inputs, secrets, and outputs. The caller job is a job in another workflow that points at the called workflow with uses instead of listing steps.

The file has to sit directly in the .github/workflows directory of the repository that owns it. Subdirectories of that path are not supported for reusable workflows (GitHub reusable workflows documentation, checked on 2026-08-13). The caller references the file by path and ref: owner/repo/.github/workflows/build.yml@v1, where the ref is a commit SHA, a tag, or a branch. A workflow in the same repository can use the short form ./.github/workflows/build.yml, which always resolves to the ref of the caller's own commit.

What a caller job may contain

A job that calls a reusable workflow accepts a fixed set of keys: name, uses, with, secrets, strategy, needs, if, concurrency, and permissions. GitHub rejects steps and runs-on in that job, because the called workflow owns its own jobs and each of those jobs declares its own runs-on. Machine selection therefore stays inside the reusable file, which is why changing runner labels for a whole organization can be a single edit in one repository.

strategy is worth calling out. A caller job can fan a matrix across the same reusable workflow, so one file drives a build across several language versions or target platforms while the pipeline logic stays in one place.

Inputs, secrets, and outputs

Values cross the boundary through three declared channels:

  • Inputs are declared under on.workflow_call.inputs and passed by the caller under with. Each input has a type, and the value must match: boolean, number, or string.
  • Secrets are declared under on.workflow_call.secrets and passed by name, or passed as a group with secrets: inherit when the caller and the called workflow belong to the same organization or enterprise. Environment secrets cannot be passed in, because on.workflow_call has no environment keyword.
  • Outputs are declared under on.workflow_call.outputs, each mapped to a job output inside the called workflow. The caller reads them as needs.<job_id>.outputs.<name>.

Environment variables do not cross the boundary. Anything set in an env context at the workflow level of the caller stays in the caller, so values that the called workflow needs have to arrive as inputs.

Permissions travel one way. GitHub allows the GITHUB_TOKEN permissions of a called workflow to be maintained or reduced through the chain, and rejects any attempt to elevate them, so delegating work to a reusable file is a way to lose access rather than gain it.

Limits worth knowing before you nest

Two documented ceilings shape how far this pattern scales (GitHub reusable workflows reference, checked on 2026-08-13):

LimitDocumented value
Unique reusable workflows called from a single workflow file50
Levels of connected workflows10, meaning the top level caller plus nine levels below it

Secrets pass only to the workflow a file calls directly. In a chain from A to B to C, workflow C receives a secret from A only when A passed it to B and B passed it on to C.

Reusable workflow compared with composite action

Both remove duplication, and they operate at different layers.

DimensionReusable workflowComposite action
Unit sharedA whole workflow, with jobsA sequence of steps
Where it runsIts own jobs, each on its own runnerInside a job the caller already started
Declares runs-onYes, per job in the called fileNo, it inherits the caller's machine
Called fromjobs.<id>.usessteps[*].uses
Log viewSeparate jobs in the run graphSteps folded into the calling job
File location.github/workflows of the owning repositoryAny path containing action.yml

Example

The called workflow below declares one input, one secret, and one output. It lives at .github/workflows/build.yml in a repository named acme/ci.

name: build
on:
  workflow_call:
    inputs:
      node-version:
        required: true
        type: string
      publish:
        required: false
        type: boolean
        default: false
    secrets:
      npm-token:
        required: true
    outputs:
      artifact-name:
        description: Name of the uploaded build artifact
        value: ${{ jobs.build.outputs.artifact-name }}

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      artifact-name: ${{ steps.pack.outputs.name }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
      - run: npm ci
      - run: npm test
      - id: pack
        env:
          NODE_AUTH_TOKEN: ${{ secrets.npm-token }}
        run: |
          npm pack
          echo "name=$(ls *.tgz)" >> "$GITHUB_OUTPUT"
      - if: inputs.publish
        run: npm publish

A workflow in any repository that is allowed to reach acme/ci calls it as a job. The calling job carries no steps of its own:

name: pull-request
on: [pull_request]

jobs:
  build:
    uses: acme/ci/.github/workflows/build.yml@v1
    with:
      node-version: "22"
    secrets:
      npm-token: ${{ secrets.NPM_TOKEN }}

  report:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: echo "built ${{ needs.build.outputs.artifact-name }}"

In the run graph, build appears as its own job with the steps from the called file, and report reads the output the called workflow published. Bumping @v1 to @v2 across the calling repositories is the whole upgrade, and the pipeline steps themselves are edited once in acme/ci.

The same shape with a matrix keeps the fan out at the call site while the steps stay in one file:

jobs:
  build:
    strategy:
      matrix:
        node-version: ["20", "22"]
    uses: acme/ci/.github/workflows/build.yml@v1
    with:
      node-version: ${{ matrix.node-version }}
    secrets: inherit

FAQ

What is the difference between a reusable workflow and a composite action?

A reusable workflow is a whole workflow file with its own jobs, and each of those jobs declares its own runs-on and gets its own machine. A composite action is a bundle of steps that runs inside a job the caller already started, on the caller's machine. Use a reusable workflow when you want to share job structure, and a composite action when you want to share a sequence of steps.

Can a reusable workflow live in a different repository?

Yes. The caller references it as owner/repo/.github/workflows/file.yml@ref, where ref is a commit SHA, a tag, or a branch. The file has to sit directly in the .github/workflows directory of the repository that owns it, because GitHub does not support subdirectories of that path for reusable workflows.

Why are the caller's environment variables missing inside the reusable workflow?

GitHub does not propagate variables set in an env context at the workflow level of the caller into the called workflow. Pass the values you need as typed inputs under with, and send values back out through outputs declared on on.workflow_call.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.