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,textMatches

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

SymptomUnderlying causeWhat it costs
Every caller pins @mainThe shared repository carries no tags, so the branch is the only ref availableOne push edits fifty pipelines, and the revert lands after the failures do
Several repositories carry near identical copies of the fileSomeone needed a different runner size or one extra step and forked instead of adding an inputFixes land in one copy while the others drift
A caller sets runs-on and the workflow is rejectedA calling job may only use name, uses, with, secrets, strategy, needs, if, concurrency, and permissionsThe repository forks the shared file to get a bigger machine
One merge on the shared repository broke every callerA new input shipped as required rather than with a defaultFifty red pipelines from one commit
Required checks disappeared after an upgradeThe check name comes from the caller's job id plus the called workflow's job name, so a job rename renames the checkBranch protection blocks merges until each repository's settings are edited
Callers stopped resolving after a repository renameGitHub supports no redirects for reusable workflowsEvery 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 fileVersion bumpWhat each caller does
Add an input that carries a defaultMinorNothing
Add an optional secretMinorNothing
Add an outputMinorNothing
Add a step that runs inside the existing jobsMinorNothing, once the canary wave is green
Change the default value of an existing inputMajorReview the new default before bumping
Add a required inputMajorEdit the with block
Remove or rename an inputMajorEdit the with block
Rename a job inside the shared fileMajorUpdate the required check names in branch protection
Require a permission the file did not need beforeMajorEdit 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 valueShapePer minuteNearest GitHub-hosted runnerPer minute
warp-ubuntu-latest-x64-2x2 vCPU, 8 GB$0.004ubuntu-latest on a private repository$0.006
warp-ubuntu-latest-x64-4x4 vCPU, 16 GB$0.008the 4-core Linux larger runner$0.012
warp-ubuntu-latest-x64-8x8 vCPU, 32 GB$0.016the 8-core Linux larger runner$0.022
warp-ubuntu-latest-x64-16x16 vCPU, 64 GB$0.032the 16-core Linux larger runner$0.042
warp-ubuntu-latest-x64-32x32 vCPU, 128 GB$0.064the 32-core Linux larger runner$0.082
warp-ubuntu-latest-arm64-4x4 vCPU, 16 GB$0.006the 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.

WaveRepositoriesAdvance when
0, canary1 high traffic repository20 green runs and no new warning annotations
15, spread across languages and runner sizes48 hours with no new failures
22048 hours, and the p95 job duration matches wave 1
3The remaining 24Steady 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: 3

Four 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

InputValueSource
Repositories calling the shared workflow50Your caller inventory
Runs per repository per month120Your workflow run history
Total runs per month6,00050 times 120
Job duration on the 4 vCPU label7.0 minutesYour run logs
Runner rates$0.004 to $0.064 per minutepricing 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 labelvCPUMinutes per runRate per minuteFleet cost per month
warp-ubuntu-latest-x64-2x211.4$0.004$273.60
warp-ubuntu-latest-x64-4x47.0$0.008$336.00
warp-ubuntu-latest-x64-8x85.1$0.016$489.60
warp-ubuntu-latest-x64-16x164.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 styleRepositories changed on the next runTime to reach all 50Rollback
Callers pin @main50One pushRevert the shared repository, then wait for reruns
Callers pin a moving @v350One tag moveMove the tag back
Callers pin @v3.4.1, Dependabot weekly, four waves1About 9 days at the wave gates aboveRevert 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.