A Runner Label Strategy for a Growing Org
Map every workload class to one runner label, pass that label into workflows as an input, and migrate one class at a time. Catalog rows, YAML, and arithmetic.
Last verified:
A runner label strategy is a fixed map from workload class to runner label, held in one place and referenced by every workflow, so changing the size of a class is one edit rather than one pull request per repository. The map holds four to six entries on Linux plus one entry per other platform, and every entry names a class of work such as lint or integration tests rather than a repository, a team, or a machine.
This guide covers the symptoms of label sprawl and how to tell them apart, the rules that keep a map small, the class map with catalog rows and rates, workflow YAML that takes the label as an input, the migration path from stock labels to warp- labels one class at a time, and the arithmetic that prices the whole scheme. For the plain definition of the term see the runner label entry.
Diagnosis
Label sprawl arrives in two opposite shapes, and both start the same way: someone copies a runs-on line from a workflow that already worked.
One label everywhere. The first workflow in the organization needed 16 vCPU for a compile step, so every workflow written afterwards asks for 16 vCPU. A lint job that runs a single-threaded formatter for 40 seconds holds 16 vCPU while it does it. Nothing fails, so nothing surfaces the problem except the invoice.
A different label in every repository. Sixty repositories each pinned a size, an OS version, and sometimes an alias by hand. Raising one class from 4 vCPU to 8 vCPU now costs sixty pull requests across sixty codeowners, so it never happens and the sizes stay wrong in whichever direction they were first written.
Both shapes produce the same downstream problems.
| Symptom | Cause | Where to look |
|---|---|---|
| Small jobs bill at large-runner rates | One label reused across every workload class | runs-on lines grouped by job name across the org |
| A size change stalls for a quarter | The label is hardcoded in every workflow | Count of files containing runs-on: per repository |
| Two lines in per-label reporting for one machine | An alias and its canonical label both in use, such as warp-ubuntu-2404-x64-4x beside warp-ubuntu-latest-x64-4x | The aliases column of the cloud runners catalog |
| A pinned OS label outlives its reason | warp-ubuntu-2204-x64-4x chosen for one dependency that was removed two years ago | Version-pinned labels with no comment explaining the pin |
| A job queues and never starts after a label edit | No online runner advertises the label, or the runner group excludes the repository | Common issues and runner groups |
| Cost per team is unanswerable | Labels encode hardware and nothing about the work | Whether any label maps to a workload class |
The last row is the one that compounds. A label that says 16x answers what the machine is. It cannot answer what the machine was doing, which is the question finance asks and the question that decides whether the size was right.
Fix
Six rules produce a map small enough to hold in one page and stable enough to survive a year of new repositories.
One label per workload class. Classes are defined by their resource shape and their tolerance for wall clock, so lint, unit tests, integration tests, and compile-and-package are usually four distinct classes and everything else folds into one of them.
Keep the class name outside the label. The catalog string is the label. The class name lives in the variable that holds the string, such as RUNNER_UNIT or RUNNER_BUILD. Workflows reference the variable, so the class name is the stable interface and the label underneath is free to change.
One spelling per class. Every size has an alias in the catalog, and a fleet running both spellings reads as two fleets in per-label reporting. Pick the canonical label, write it in the map, and treat any alias found in a workflow as a defect.
Pin the OS only where the toolchain needs it. Use the latest form by default. When a pin is required, write the reason next to the map entry, because a pin with no stated reason will be inherited forever.
Change the size in one place. If raising a class means editing more than one file, the map is not yet the source of truth.
Review the map against duration data every quarter. Sizes are chosen from job durations, and job durations move as the codebase grows. Right sizing GitHub Actions runners covers how to read the per-job numbers that set each entry.
The class map
This map covers all four platforms in seven entries, with shapes and per-minute rates from the cloud runners catalog and the pricing page, checked on 2026-08-13.
| Class | Variable | Label | vCPU | RAM | Rate per minute |
|---|---|---|---|---|---|
| Lint and small checks | RUNNER_LINT | warp-ubuntu-latest-x64-2x | 2 | 8 GB | $0.004 |
| Unit tests | RUNNER_UNIT | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 |
| Integration tests | RUNNER_INTEGRATION | warp-ubuntu-latest-x64-8x | 8 | 32 GB | $0.016 |
| Compile and package | RUNNER_BUILD | warp-ubuntu-latest-x64-16x | 16 | 64 GB | $0.032 |
| ARM64 packaging | RUNNER_ARM | warp-ubuntu-latest-arm64-8x | 8 | 32 GB | $0.012 |
| iOS and macOS | RUNNER_MACOS | warp-macos-15-arm64-6x | 6 | 22 GB | $0.08 |
| Windows | RUNNER_WINDOWS | warp-windows-latest-x64-4x | 4 | 16 GB | $0.016 |
Seven entries is a ceiling worth defending. Every entry is a thing to review each quarter, and a map with twenty entries goes unreviewed, which returns the organization to sprawl with extra steps.
Configuration
Pass the label rather than writing it
GitHub's contexts reference lists vars and inputs among the contexts available to jobs.<job_id>.runs-on (checked on 2026-08-13), so both halves of the scheme work with stock workflow syntax. Organization variables hold the map (variables reference), and a reusable workflow takes the label as a workflow_call input (reusing workflow configurations).
The reusable workflow lives once, in my-org/.github/.github/workflows/test.yaml:
name: test
on:
workflow_call:
inputs:
runner:
description: Runner label from the organization class map
type: string
default: warp-ubuntu-latest-x64-4x
jobs:
test:
runs-on: ${{ inputs.runner }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npm testEach repository calls it and names a class rather than a size:
name: pr
on: pull_request
jobs:
lint:
uses: my-org/.github/.github/workflows/test.yaml@v1
with:
runner: ${{ vars.RUNNER_LINT }}
unit:
uses: my-org/.github/.github/workflows/test.yaml@v1
with:
runner: ${{ vars.RUNNER_UNIT }}
integration:
strategy:
matrix:
shard: [1, 2, 3, 4]
runs-on: ${{ vars.RUNNER_INTEGRATION }}
steps:
- uses: actions/checkout@v4
- run: ./scripts/integration.sh --shard=${{ matrix.shard }}/4
build:
needs: [unit, integration]
runs-on: ${{ vars.RUNNER_BUILD }}
steps:
- uses: actions/checkout@v4
- run: ./scripts/build.shRaising the unit test class across all sixty repositories is now one edit to RUNNER_UNIT in organization settings. Running reusable workflows across many repositories covers the release tagging that keeps @v1 meaningful once the shared workflow starts changing.
One failure mode is worth knowing before you rely on this. A variable that does not exist evaluates to an empty string, and an empty string passed through with overrides the default in the called workflow, so the job arrives with no label to match and the run fails at that job. Set every variable in the map at the organization level and let a repository override it only when a real exception exists.
Labels also stop at the runner group boundary. A correct label in a repository the group excludes still queues forever, which reads as a label bug and is not one. Runner groups and common issues cover that check.
Migrating one class at a time
Rank the classes by billed minutes per month, then move the largest one first, because that is where a wrong size costs the most and where a week of observation tells you the most.
- Route the class through a variable while it still points at the stock label. Set
RUNNER_UNITtoubuntu-latestand ship the workflow edits. Nothing changes about where jobs run, so this step is reviewable on its own. - Flip the variable to the
warp-label. One edit in organization settings moves every repository in the class at once. - Hold for a week. Watch the class for tooling gaps rather than for speed. Two documented disparities catch teams out: Ubuntu 24.04 ARM64 runners set the work dir to
/runner/_workwhere GitHub uses/home/runner/work/, and macOS runners cannot run Docker or nested virtualization. - Take the next class. Repeat until every class points at a catalog label.
The revert path is the same single edit, which is what makes the ordering safe. A class that misbehaves goes back to its stock label in seconds and the remaining classes stay migrated.
| Class | Stock label today | Class map target | Shape |
|---|---|---|---|
| Lint and small checks | ubuntu-latest | warp-ubuntu-latest-x64-2x | 2 vCPU, 8 GB |
| Unit tests | ubuntu-latest | warp-ubuntu-latest-x64-4x | 4 vCPU, 16 GB |
| Integration tests | 8-core Linux larger runner | warp-ubuntu-latest-x64-8x | 8 vCPU, 32 GB |
| Compile and package | 16-core Linux larger runner | warp-ubuntu-latest-x64-16x | 16 vCPU, 64 GB |
| ARM64 packaging | ubuntu-24.04-arm | warp-ubuntu-latest-arm64-8x | 8 vCPU, 32 GB |
| iOS and macOS | macos-latest | warp-macos-15-arm64-6x | 6 vCPU, 22 GB |
| Windows | windows-latest | warp-windows-latest-x64-4x | 4 vCPU, 16 GB |
Cost or Time Model
Every Linux x64 size costs the same per vCPU minute
Divide each catalog rate by its vCPU count: $0.004 divided by 2, $0.008 divided by 4, $0.016 divided by 8, $0.032 divided by 16, and $0.064 divided by 32 all come to $0.002 per vCPU minute. Linux ARM64 works out at $0.0015 per vCPU minute, since $0.012 divided by 8 gives that figure.
That flat curve is what makes the class map worth building. Size is free for a job that uses every core it is given, because doubling the cores halves the minutes at the same per vCPU rate. Size is pure waste for a job that cannot use them, because the minutes stay flat while the rate doubles. The class map exists to keep the second kind of job off the large label.
Pricing a gate before and after
Take a repository whose pull request gate runs four jobs, at 2,000 runs per month across the organization.
Everything on one 16 vCPU label:
- lint 1.5 minutes, unit tests 4 minutes, integration 9 minutes, compile 12 minutes, all at $0.032 per minute.
- 26.5 minutes multiplied by $0.032 is $0.848 per run.
- 2,000 runs multiplied by $0.848 is $1,696 per month.
The same gate on the class map, with the smaller jobs taking longer on smaller machines:
- lint 1.5 minutes at $0.004 is $0.006.
- unit tests 6 minutes at $0.008 is $0.048.
- integration 10 minutes at $0.016 is $0.16.
- compile 12 minutes at $0.032 is $0.384.
- $0.598 per run, and 2,000 runs multiplied by $0.598 is $1,196 per month.
The gate takes $500 per month less and finishes at the same time, because the four jobs run in parallel and the 12 minute compile job sets the wall clock in both cases. Shrinking the jobs that were never on the critical path is the part of right sizing that costs nothing in feedback time.
The list-price arithmetic behind two classes
warp-ubuntu-latest-x64-2x (2 vCPU, 8 GB) costs $0.004 per minute against $0.006 per minute for GitHub-hosted ubuntu-latest (2 vCPU, 8 GB on private repositories): 33 percent lower list price. GitHub list price checked on 2026-08-13.
warp-ubuntu-latest-x64-16x (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price. GitHub list price checked on 2026-08-13. Both GitHub figures come from the Actions minute multipliers reference.
Every cost figure on this page carries its number, its source, and the date it was checked, and the same numbers appear on the pricing page.
FAQ
How many runner labels should a growing organization standardize on?
One label per workload class rather than one per repository or per team. Four Linux x64 classes cover most organizations (lint, unit tests, integration tests, compile and package), plus one ARM64 class and one class per other platform. Seven entries is a workable ceiling, because each entry has to be reviewed against duration data every quarter and a map nobody reviews drifts back into sprawl.
Should the runner label be hardcoded in each workflow or passed in?
Pass it in. GitHub's contexts reference lists vars and inputs among the contexts available to jobs.<job_id>.runs-on, so a job can read an organization variable directly or take the label as a workflow_call input. Keep a literal default on the reusable workflow input so a caller that omits the value still starts, and set the variable at the organization level so a repository that never overrides it inherits the class map.
How do I move from GitHub-hosted labels to warp- labels without a big-bang change?
One workload class at a time, ordered by billed minutes. Route the class through a variable first, change the variable, hold for a week, then take the next class. Because the label lives in one place, the revert is the same single edit as the migration, so no repository needs a pull request either way.
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.