Running Reusable Workflows Across Many Repositories
A shared workflow holds up across fifty repositories on three things: a tagged version contract, a runner label input per caller, and a staged rollout.
One reusable workflow can serve fifty repositories, and what decides whether it holds up is the contract around the file: a tagged reference every caller pins, an input for anything a repository needs to vary, and a rollout that reaches repositories in waves rather than all at once. The shared YAML is the small part of the job, and the version contract and the rollout order are what keep one edit from turning fifty pipelines red.
This guide covers taking an inventory of callers before changing anything, the compatibility rules that decide a version bump, a called workflow that takes its runner label as an input so each repository picks its own size, and a cost model for fifty repositories running one shared file.
Diagnosis
Start with an inventory, because the blast radius of the next change is the number of repositories on each ref. One search lists every caller and the ref it pins:
gh search code --owner acme \
"uses: acme/ci-workflows/.github/workflows/service-build.yml" \
--limit 200 --json repository,path,textMatchesGroup the results by the string after the @. A fleet split across @main, @v2, and three patch tags behaves differently under one push than a fleet where every caller pins a full version, and the table below is the usual set of shapes.
| Symptom | Underlying cause | What it costs |
|---|---|---|
Every caller pins @main | The shared repository carries no tags, so the branch is the only ref available | One push edits fifty pipelines, and the revert lands after the failures do |
| Several repositories carry near identical copies of the file | Someone needed a different runner size or one extra step and forked instead of adding an input | Fixes land in one copy while the others drift |
A caller sets runs-on and the workflow is rejected | A calling job may only use name, uses, with, secrets, strategy, needs, if, concurrency, and permissions | The repository forks the shared file to get a bigger machine |
| One merge on the shared repository broke every caller | A new input shipped as required rather than with a default | Fifty red pipelines from one commit |
| Required checks disappeared after an upgrade | The check name comes from the caller's job id plus the called workflow's job name, so a job rename renames the check | Branch protection blocks merges until each repository's settings are edited |
| Callers stopped resolving after a repository rename | GitHub supports no redirects for reusable workflows | Every caller fails until its uses line is edited by hand |
Rows three, five, and six come from the GitHub reusable workflow reference, checked on 2026-08-13.
Two structural facts sit behind all six. Runner assignment and billing stay with the caller, so the shared file cannot hand a repository a machine it does not have access to; the mechanics of that are in how to reuse a workflow across repositories. And nothing about a caller's ref is enforced centrally, so the version discipline has to live in the shared repository's release process and in each caller's update path.
Fix
Write the version contract down
The public interface of a reusable workflow is wider than the input list. It covers the inputs, the secrets, the outputs, the job names that branch protection matches, and the permissions the file needs from a caller. Treat a change to any of those as a version event, tag every change on the shared repository, and have callers pin the full version.
| Change to the shared file | Version bump | What each caller does |
|---|---|---|
| Add an input that carries a default | Minor | Nothing |
| Add an optional secret | Minor | Nothing |
| Add an output | Minor | Nothing |
| Add a step that runs inside the existing jobs | Minor | Nothing, once the canary wave is green |
| Change the default value of an existing input | Major | Review the new default before bumping |
| Add a required input | Major | Edit the with block |
| Remove or rename an input | Major | Edit the with block |
| Rename a job inside the shared file | Major | Update the required check names in branch protection |
| Require a permission the file did not need before | Major | Edit the permissions block |
The deprecation path is what turns a major bump from an outage into a schedule. Accept both the old and the new spelling for two minor releases, warn on the old one, and delete it at the next major:
- name: Deprecation notice
if: ${{ inputs.node_version != '' }}
run: |
echo "::warning title=Deprecated input::node_version is replaced by node-version and is removed in v4"Publish v3.4.1 style tags for callers that want a fixed target, and keep a moving v3 for teams that accept patches automatically. A changelog in the shared repository listing the bump type per release is what makes a Dependabot pull request reviewable in thirty seconds.
Let each repository choose its runner size
A calling job cannot set runs-on, so a repository that needs a bigger machine either accepts the library default or forks the file. Declaring a runner-label input and pointing runs-on at it gives the choice back, and an empty default plus a fallback expression keeps callers that say nothing on the library default.
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-x64-32x | 32 vCPU, 128 GB | $0.064 | the 32-core Linux larger runner | $0.082 |
| warp-ubuntu-latest-arm64-4x | 4 vCPU, 16 GB | $0.006 | the 4-core Linux ARM64 larger runner | $0.008 |
One shared library can build a Go service on a Linux label and an iOS app on a macOS label with the same version contract. Which labels an organization should standardize on is covered in a runner label strategy for a growing org.
The label a caller passes is worth routing through a variable rather than a literal. A repository variable overrides an organization variable of the same name, and an unset variable resolves to the empty string (GitHub variables reference, checked on 2026-08-13). Setting CI_RUNNER_LABEL once at the organization level and overriding it on the four repositories that need more cores gives one central knob and a per repository escape hatch, with no edit to the shared file.
Stage the rollout
A pinned tag means a change reaches a repository only when that repository merges a bump, which is what makes waves possible.
| Wave | Repositories | Advance when |
|---|---|---|
| 0, canary | 1 high traffic repository | 20 green runs and no new warning annotations |
| 1 | 5, spread across languages and runner sizes | 48 hours with no new failures |
| 2 | 20 | 48 hours, and the p95 job duration matches wave 1 |
| 3 | The remaining 24 | Steady state |
Dependabot raises the bump pull requests for the github-actions ecosystem, which covers reusable workflow refs alongside action refs. Group the shared library into one pull request per repository and cap the open count so a wave does not flood reviewers (Dependabot options reference, checked on 2026-08-13). Stage the waves by enabling the schedule for wave 1 repositories after the canary passes rather than by editing fifty files at once.
When the shared repository needs to kick off work in other repositories instead of waiting for their next push, that is a separate mechanism; see triggering workflows across repositories.
Configuration
The called workflow carries the interface, the fallback label, and a fail-fast check that a caller pinned the major version it thinks it pinned.
# acme/ci-workflows/.github/workflows/service-build.yml
name: service-build
on:
workflow_call:
inputs:
runner-label:
description: "Runner label for the build job. Empty falls back to the library default."
type: string
default: ""
go-version:
type: string
default: "1.24"
run-integration-tests:
type: boolean
default: true
contract-version:
description: "Major version the caller expects."
type: string
default: "3"
secrets:
REGISTRY_TOKEN:
required: false
outputs:
image-digest:
description: Digest of the image this run pushed
value: ${{ jobs.build.outputs.image-digest }}
jobs:
build:
runs-on: ${{ inputs.runner-label != '' && inputs.runner-label || 'warp-ubuntu-latest-x64-4x' }}
outputs:
image-digest: ${{ steps.push.outputs.digest }}
steps:
- name: Check the contract version
if: ${{ inputs.contract-version != '3' }}
run: |
echo "::error title=Contract mismatch::caller expects v${{ inputs.contract-version }}, this file is v3"
exit 1
- uses: actions/checkout@v5
- uses: actions/setup-go@v5
with:
go-version: ${{ inputs.go-version }}
cache: true
- run: go build ./...
- run: go test ./...
- if: ${{ inputs.run-integration-tests }}
run: go test -tags=integration ./...
- uses: Warpbuilds/build-push-action@v6
id: push
with:
context: .
push: true
profile-name: service-builder
tags: ghcr.io/acme/${{ github.event.repository.name }}:${{ github.sha }}Each caller is the same nine lines with a different label source and a pinned tag.
# 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
packages: write
with:
runner-label: ${{ vars.CI_RUNNER_LABEL }}
contract-version: "3"
secrets:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}The bump path is a Dependabot config committed once per calling repository.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: tuesday
groups:
ci-workflows:
patterns:
- "acme/ci-workflows*"
open-pull-requests-limit: 3Four details carry the behavior. runner-label defaults to the empty string so the fallback expression in runs-on decides, which keeps the library default in one file. contract-version turns a caller pinned to the wrong major into a one second failure with a readable message instead of a confusing input error. The caller reads vars.CI_RUNNER_LABEL, so moving a repository to a larger machine is a settings change rather than a pull request. And the Dependabot group means one review per repository per wave, whatever the shared library released that week.
Where the shared library also pins a custom runner image, the image build and the runner operations behind it can be driven from the API (automation documentation), which keeps the image version and the workflow version moving on the same release.
Cost or Time Model
The interesting cost lever in a shared library is the default label, because it multiplies by every caller that never overrides it.
Assumptions
| Input | Value | Source |
|---|---|---|
| Repositories calling the shared workflow | 50 | Your caller inventory |
| Runs per repository per month | 120 | Your workflow run history |
| Total runs per month | 6,000 | 50 times 120 |
| Job duration on the 4 vCPU label | 7.0 minutes | Your run logs |
| Runner rates | $0.004 to $0.064 per minute | pricing page, checked 2026-08-13 |
What the default label costs across the fleet
Job durations below are placeholders to replace with your own run logs, and they flatten as cores grow because checkout and image push are network bound.
| Default label | vCPU | Minutes per run | Rate per minute | Fleet cost per month |
|---|---|---|---|---|
| warp-ubuntu-latest-x64-2x | 2 | 11.4 | $0.004 | $273.60 |
| warp-ubuntu-latest-x64-4x | 4 | 7.0 | $0.008 | $336.00 |
| warp-ubuntu-latest-x64-8x | 8 | 5.1 | $0.016 | $489.60 |
| warp-ubuntu-latest-x64-16x | 16 | 4.3 | $0.032 | $825.60 |
Moving the library default from the 4 vCPU label to the 8 vCPU label costs $153.60 a month at this volume and takes 1.9 minutes off every run in the fleet, including the forty repositories whose builds were already fast enough. The per caller override is the cheaper shape: leave the default at 4 vCPU and let the ten repositories that need it pass the 8 vCPU label. That fleet costs $366.72 a month, which is $30.72 above the flat 4 vCPU fleet and $122.88 below the flat 8 vCPU fleet, and it still removes 2,280 minutes of wall clock a month from the ten repositories that were waiting.
The same 42,000 minutes on the 4 vCPU default bill at $336.00, against $504.00 at GitHub's published $0.012 per minute for the 4-core Linux larger runner (GitHub Actions billing reference, checked on 2026-08-13). Every figure here carries its rate, its source, and a checked-on date, and the durations are yours to substitute.
How long a change takes to reach fifty repositories
| Rollout style | Repositories changed on the next run | Time to reach all 50 | Rollback |
|---|---|---|---|
Callers pin @main | 50 | One push | Revert the shared repository, then wait for reruns |
Callers pin a moving @v3 | 50 | One tag move | Move the tag back |
Callers pin @v3.4.1, Dependabot weekly, four waves | 1 | About 9 days at the wave gates above | Revert one line in one repository, and stop the next wave |
The nine days is the cost of the staged version, and the thing it buys is that a bad release is a single red pull request rather than fifty broken pipelines and an org-wide message. For a library that changes weekly, that trade usually pays for itself the first time a step regresses.
Ordering this work against the other levers in a pipeline is covered in the guide to speeding up GitHub Actions.
FAQ
How do I change a shared workflow without breaking fifty repositories?
Tag every change on the shared repository, have callers pin the full version, and move repositories in waves. A pinned tag reaches nobody until a caller merges a bump, so a bad change fails one pull request in one repository and the rollback is one line. The reusable workflow glossary entry covers the reference syntax on its own.
What counts as a breaking change to a reusable workflow?
Anything a caller has to edit. A new required input, a removed or renamed input, a changed default that changes behavior, a job rename that renames the required status check, and any permission the file starts needing all belong in a major version. Additive changes ship with a default and go out as a minor version, which is what lets Dependabot merge them on a schedule.
How do repositories with different build sizes share one workflow?
Declare a runner-label input and point runs-on at it, since a calling job cannot set runs-on itself. Each caller passes the label it wants, an empty value falls back to the library default in the shared file, and rates run from $0.004 to $0.064 per minute across the Linux x64 sizes on the pricing page.
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.