How Do I Reuse a Workflow Across Repositories?
Publish it once as a reusable workflow with workflow_call, then call it from each repository with uses, a pinned tag, and explicit inputs and secrets.
Publish the shared logic once as a reusable workflow: a file in .github/workflows on the host repository with on: workflow_call, declaring the inputs and secrets it needs. Every other repository then calls it from a job with uses: OWNER/REPO/.github/workflows/FILE.yml@REF, passing those inputs and secrets explicitly and pinning REF to a tag.
Answer
A reusable workflow has two sides, and both are ordinary workflow files. The called side declares its interface with workflow_call. The caller side replaces a job's steps with a single uses line (GitHub reusable workflow reference, checked on 2026-08-13).
Here is the called workflow, living in a repository named acme/ci-workflows:
# acme/ci-workflows/.github/workflows/node-build.yml
name: node-build
on:
workflow_call:
inputs:
runner-label:
description: Runner label the calling repository wants this job on
type: string
default: warp-ubuntu-latest-x64-4x
node-version:
type: string
default: "22"
publish:
type: boolean
default: false
secrets:
NPM_TOKEN:
required: false
outputs:
artifact-name:
description: Name of the uploaded build artifact
value: ${{ jobs.build.outputs.artifact-name }}
jobs:
build:
runs-on: ${{ inputs.runner-label }}
outputs:
artifact-name: ${{ steps.pack.outputs.name }}
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: npm
- run: npm ci
- run: npm run build
- run: npm test
- id: pack
run: echo "name=dist-${{ github.sha }}" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v4
with:
name: dist-${{ github.sha }}
path: dist/
- if: ${{ inputs.publish }}
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}And here is a caller in a different repository:
# acme/checkout-service/.github/workflows/ci.yml
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
build:
uses: acme/ci-workflows/.github/workflows/[email protected]
permissions:
contents: read
with:
runner-label: warp-ubuntu-latest-x64-8x
node-version: "22"
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}Three rules decide whether that call resolves at all. The called workflow has to be in the same repository, in a public repository, or in a private repository whose Actions access policy names the caller; a private caller can reach private and public workflows, and a public caller can reach public ones only. Cross-repository calls always carry a ref after the @. GitHub supports no redirects for reusable workflows, so renaming the host repository breaks every caller until each one is edited (GitHub reusable workflow reference, checked on 2026-08-13).
Runner selection stays with the caller. GitHub evaluates runner assignment using only the caller's context and bills the caller, and the caller cannot borrow runners from the repository that hosts the workflow file (GitHub reusable workflow reference). For warp- labels that means the WarpBuild GitHub bot needs access to each calling repository, granted from the WarpBuild dashboard after signup rather than from the GitHub Marketplace (quick start documentation). One shared workflow can serve a Go service and an iOS app by changing the label a caller passes (cloud runners documentation).
Detail
Pass the runner label as an input
A calling job may only use name, uses, with, secrets, strategy, needs, if, concurrency, and permissions (GitHub reusable workflow reference, checked on 2026-08-13). runs-on is absent from that list, so a caller that needs a bigger machine cannot set one directly. Declaring a runner-label input and setting runs-on: ${{ inputs.runner-label }} gives the caller the choice back, with the default deciding what every repository that says nothing gets.
Rates below come from the cloud runners documentation and the pricing page. GitHub rates come from the GitHub Actions billing reference, checked on 2026-08-13.
runner-label value | Shape | Per minute | Nearest GitHub-hosted runner | Per minute |
|---|---|---|---|---|
| warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB | $0.004 | ubuntu-latest on a private repository | $0.006 |
| warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB | $0.008 | the 4-core Linux larger runner | $0.012 |
| warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB | $0.016 | the 8-core Linux larger runner | $0.022 |
| warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB | $0.032 | the 16-core Linux larger runner | $0.042 |
| warp-ubuntu-latest-arm64-4x | 4 vCPU, 16 GB | $0.006 | the 4-core Linux ARM64 larger runner | $0.008 |
The default is the interesting field once several repositories call the same file. Take twelve services that each burn 2,000 runner minutes a month, 24,000 minutes in total. On the 4x default that is $192.00 a month. Callers that pass warp-ubuntu-latest-x64-8x pay $0.016 for their own minutes without changing anything for the other repositories, and moving the default itself to 8x moves all twelve to $384.00 on their next run. One tag bump per repository is the throttle on that change.
Version by tag rather than by branch
A reusable workflow can be referenced by SHA, release tag, or branch name (GitHub reusable workflow reference, checked on 2026-08-13). The reference style decides who absorbs a mistake.
| Ref style | What a push to the shared repository does | Rollback |
|---|---|---|
@main | Reaches every caller on its next run | Revert on the shared repository, then wait for reruns |
@v3 moving major tag | Reaches every caller once the tag moves | Move the tag back |
@v3.2.0 immutable tag | Reaches nobody until a caller bumps its ref | Revert one line in one repository |
@a1b2c3d commit SHA | Reaches nobody until a caller bumps its ref | Revert one line in one repository |
Tag the shared repository on every change, and have callers pin the full version. A broken step then fails one pull request in one repository rather than every pipeline at once, and Dependabot can raise the bump on a schedule you control.
Re-runs behave differently depending on the ref, which is worth knowing before a mixed fleet confuses you. Re-running all jobs in a workflow uses the reusable workflow at the specified reference, while re-running failed jobs or a single job uses the reusable workflow from the same commit SHA as the first attempt (GitHub reusable workflow reference). A branch reference can therefore give two different results for what looks like the same button.
The limits that appear at scale
| Limit | Value | Effect on a shared workflow library |
|---|---|---|
| Nesting depth | Up to ten levels of connected workflows | A caller, a language template, and a deploy workflow underneath fit comfortably |
| Unique reusable workflows per file | 50, counting the whole nested tree | A matrix that fans out to per-language templates hits this before a plain caller does |
Caller env propagation | Not propagated to the called workflow | Anything the shared file needs arrives as an input, or from organization variables through the vars context |
| Token permissions | Same or more restrictive as you descend | A caller granting contents: read blocks any nested workflow from writing packages |
All four rows come from the GitHub reusable workflow reference, checked on 2026-08-13. The env rule is the one that catches migrations: a workflow that reads env.REGISTRY at the top of the caller sees nothing after the logic moves into a shared file, and the fix is an input with a default rather than a copied env block.
Secrets travel the same explicit path. List each one under secrets on both sides, or pass secrets: inherit when the caller and the shared workflow live in the same organization and you want the caller's whole secret set available (GitHub reusable workflow reference, checked on 2026-08-13). Explicit listing is the version that survives an audit, because the interface then states exactly what the shared file can read.
What stays in the caller
The caller keeps its triggers, its concurrency group, and its required status check names. Two details follow from that. ${{ github.workflow }} inside a called workflow resolves to the caller's workflow name, so reusing that value as a concurrency.group in both files makes a run cancel itself (GitHub reusable workflow reference). And the check name that branch protection matches comes from the caller's job id plus the called workflow's job name, so renaming a job inside the shared file changes the required check in every repository that pins the new tag.
Related Questions
Can the caller choose the runner the shared workflow runs on?
Not through runs-on, since that keyword is absent from the list of keywords a calling job may use. Declare a runner-label input on the called workflow, point runs-on at it, and each caller passes the size it wants while the default covers the rest. The runner catalog and pricing page lists every label and rate, and running reusable workflows at scale covers the rollout across dozens of repositories.
Should I call a reusable workflow by branch or by tag?
By tag or commit SHA. A branch reference reaches every caller on the next run, so a single push can break the whole fleet, and re-running failed jobs pins to the first attempt's SHA anyway. Tags keep each change to one pull request in one repository. The reusable workflow glossary entry covers the syntax in isolation.
When is a composite action the better shape?
A composite action packages a sequence of steps inside an existing job, so it inherits the runner, the checkout, and the working directory the job already has. A reusable workflow brings its own jobs and its own runner selection, which is what you want when the shared unit is a whole build. Writing a composite action walks through the other half of that choice.
Start with one shared workflow, one tag, and one caller, then measure a full pipeline before you fan the pattern out; speeding up GitHub Actions covers the measurements worth taking first.
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.