Runner Label

A runner label is the string in a GitHub Actions runs-on key that GitHub matches against registered runners to decide which machine picks up the job.

A runner label is the string a GitHub Actions runner advertises when it registers, and the same string a workflow writes in its runs-on key to ask for that kind of machine. GitHub hands a queued job to an online runner whose label set contains every label the job requested, so the label is the routing key that connects a job to a machine.

Labels carry no meaning to GitHub beyond that match. Most reports of a job that never starts, or a job that lands on the wrong hardware, resolve to a label that nobody is advertising or a label that describes the machine inaccurately.

Definition

A runner label is an arbitrary string associated with a runner in GitHub's records. Every runner has a set of labels. Every job carries a request, written in runs-on. GitHub compares the request against the label sets of the runners in scope and delivers the job to one that satisfies it.

Where labels come from

Labels reach a runner in three ways.

Default labels. A self-hosted runner picks up self-hosted plus an operating system label (linux, windows, or macOS) and an architecture label (x64, ARM, or ARM64) when the agent registers. GitHub assigns these, and they cannot be removed.

Custom labels. The registration script accepts them directly, as in ./config.sh --labels build,large-disk, and they can also be added afterwards in the runner settings for a repository, organization, or enterprise, or through the self-hosted runner endpoints of the REST API (GitHub REST API for self-hosted runners, checked on 2026-08-13).

Fleet labels published by the operator. GitHub-hosted runners come with labels GitHub publishes and controls, such as ubuntu-latest, ubuntu-24.04, windows-latest, macos-latest, and image-pinned variants like ubuntu-24.04-arm. Larger hosted runners take a label chosen when the runner is created in organization settings. Any other provider that registers machines against your organization does so through the same runner registration API, so the labels it publishes are custom labels in GitHub's model.

How matching works

Matching is a subset test in one direction. Every label named in runs-on must appear on the runner. Labels the runner carries beyond that set are ignored.

A runner advertising self-hosted, linux, x64, and build is therefore eligible for a job requesting [self-hosted, linux] and for a job requesting [self-hosted, linux, x64, build]. The same runner is skipped for a job requesting [self-hosted, linux, x64, gpu], because gpu is missing.

Labels are one of three eligibility filters. The runner also has to be online and idle, and it has to be reachable by the workflow: registered at a scope that covers the repository, and inside a runner group that allows the repository. A label cannot widen either of those boundaries.

When several runners qualify, the first one to acknowledge the job message claims it. There is no ranking by size, cost, or idle time, so a fleet mixing shapes under one shared label gives no control over which shape a job lands on.

The shapes of runs-on

runs-on accepts a single string, an array of labels, an object that names a runner group, or an expression that evaluates to any of those.

jobs:
  single-label:
    runs-on: ubuntu-latest

  several-labels:
    runs-on: [self-hosted, linux, x64]

  group-and-labels:
    runs-on:
      group: production-runners
      labels: [linux, x64]

  from-an-expression:
    runs-on: ${{ github.ref == 'refs/heads/main' && 'self-hosted' || 'ubuntu-latest' }}

The array form is the one that surprises people. It reads like a list of alternatives and behaves like a list of requirements, so adding a label to the array narrows the pool of eligible runners rather than widening it.

Label conventions

Once an organization runs more than one fleet, the label stops being a single word and starts being a record with fields in a fixed order. A common scheme encodes the operator prefix, the image, the architecture, and the machine size, so a reader of the workflow file can tell what a job lands on without opening a dashboard.

Two properties make that work. The string is opaque to GitHub, so any separator is allowed. And the string is the only part of the routing decision visible in the repository, so the fields chosen for it are the fields reviewers get to see in a pull request.

Some fleets extend the convention with an options suffix after a separator such as a semicolon. GitHub still treats the whole string as one label and matches it as one unit. The operator parses the suffix on its side after the job is claimed.

Example

Start with a job on a GitHub-hosted runner. ubuntu-latest is the label GitHub publishes for its default hosted Linux image.

name: test
on:
  push:
    branches: [main]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: make test

Now take an organization with three registered self-hosted runners, all online and idle, and work through what each request matches.

RunnerLabels it advertises
runner-aself-hosted, linux, x64, build
runner-bself-hosted, linux, ARM64, build
runner-cself-hosted, macOS, ARM64, release
Value of runs-onEligible runnersWhat happens
[self-hosted, linux]runner-a, runner-bEither machine can claim the job, and the architecture the job gets is whichever one answers first.
[self-hosted, linux, x64]runner-aOne machine matches, so the job is deterministic and waits if that machine is busy.
[self-hosted, build, release]noneThe two labels live on different machines. No single runner carries both, so nothing matches.
[self-hosted, linux, x64, gpu]nonegpu is advertised by no runner.
[self-hosted, Linux, x64]depends on the typed stringA label that differs from the registered string by a character is a different routing key. Copy the label from the runner settings page rather than retyping it.

An unmatched label queues the job with no error

The third and fourth rows above are the failure mode worth internalizing. A job whose labels match no runner is not rejected. GitHub accepts the workflow run, creates the job, and leaves it in the queue waiting for a runner that never appears. The run view shows the job as pending with no logs and no annotation naming the missing label.

The wait is bounded but long. GitHub cancels a job that has been queued for 24 hours (GitHub Actions limits, checked on 2026-08-13). A typo in a label therefore turns into a red run a full day after the pull request was opened, which is why the label string deserves the same review attention as the steps below it.

Labels that encode image, architecture, and size

Managed fleets publish label sets built on the convention described above. The labels below come from the WarpBuild runner catalog, and the table splits each one into the fields the string encodes (cloud runners documentation, checked on 2026-08-13).

LabelPrefixImageArchitectureSize segment
warp-ubuntu-latest-x64-4xwarpubuntu-latestx644x
warp-ubuntu-2604-arm64-8xwarpubuntu-2604arm648x
warp-macos-26-arm64-12xwarpmacos-26arm6412x
warp-windows-2025-vs2026-x64-16xwarpwindows-2025-vs2026x6416x

Two reading rules follow from the scheme. The image field either names a version (ubuntu-2604, macos-26) or a moving pointer (ubuntu-latest), and a moving pointer resolves to whichever version the operator currently publishes behind it, so pinning the version is how a workflow stays on one image across a rollout. The trailing segment names the size class, and the machine behind each size is listed in the documentation rather than derivable from the string.

Because architecture is a field in the label, a matrix can fan the same steps across architectures by varying only the label:

jobs:
  build:
    strategy:
      matrix:
        runner:
          - warp-ubuntu-latest-x64-4x
          - warp-ubuntu-latest-arm64-4x
    runs-on: ${{ matrix.runner }}
    steps:
      - uses: actions/checkout@v4
      - run: make build

An options suffix rides on the same string. Here the job asks for a specific base machine and passes one option through the label:

jobs:
  integration:
    runs-on: warp-ubuntu-latest-x64-2x;snapshot.key=my-project-snapshot
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/integration-test.sh

To GitHub that whole value is one label, matched as one unit against the runner's label set. Everything after the semicolon is interpreted by the fleet operator after the job is claimed.

Checking a label before blaming the workflow

When a job sits in the queue, work through the eligibility filters in the order GitHub applies them.

  1. Read the exact label off the runner settings page for the repository, organization, or enterprise, and compare it character by character with the value in runs-on.
  2. Confirm the runner is registered at a scope the workflow's repository can reach. A repository-scoped runner is invisible to every other repository.
  3. Confirm the runner group allows this repository, and check whether the group also restricts which workflows may use it by path, branch, tag, or commit.
  4. Confirm at least one runner carrying the full label set is online and idle rather than busy with another job.
  5. For an array request, check that a single runner carries every label, since a request spread across two machines never matches.

The runner troubleshooting documentation walks the same checks for a managed fleet, including runner group access and workflow restrictions.

FAQ

What is a runner label in GitHub Actions?

A runner label is a string attached to a runner when it registers with GitHub. A workflow job lists labels in its runs-on key, and GitHub gives the job to an online runner that advertises every label the job asked for.

Does a runner label change what a machine can do?

No. Labels are opaque routing strings. GitHub does not check that a runner labeled x64 is an x86-64 machine or that a runner labeled large has more cores. Whoever registers the runner picks the strings, so the accuracy of a label is a convention held by the registrar.

Why is my job queued forever after I changed the label?

No online runner in scope advertises every label in runs-on. Common causes are a typo, a runner registered to a different repository, a runner group that excludes the repository, and a request whose labels are spread across two different machines. GitHub cancels a job that waits 24 hours in the queue.

Can one job request more than one runner label?

Yes. The array form of runs-on lists several labels and a runner must carry all of them to be eligible. Labels the runner carries beyond that set are ignored, so a request is a subset test against each runner's label set.

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.