Skip to main content
RunBook Academy

Git, CI/CD & GitOpsXL · RunnersRunners

Runner labels and selection — matching workflows to runners, and the trust model

Intermediate⏱ ~20 mingit

What you'll learn

  • Explain how labels work: strings attached to runners, matched by workflow runs-on
  • Read and write a runs-on label expression correctly
  • Identify the four label trust classes: hosted defaults, self-hosted untrusted, self-hosted prod, GPU/specialised
  • Recognise the default-routing risk: self-hosted runners with no label receive every workflow
  • Design a label taxonomy that maps cleanly onto the team pool structure

Prerequisites

Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x

Not yet marked complete on this device.

A runner label is a string attached to a runner. A workflow’s runs-on field is a list of strings. The forge matches the workflow to a runner when every label in runs-on is present on the runner. The match is exact, all-or-nothing: a runner missing one label does not take it.

Labels are how a workflow says “I need a Linux x64 host with production secrets” and a runner says “I have those properties”. They are also how the team keeps the wrong job from landing on the wrong host.

The mechanics

flowchart LR
    W["Workflow\nruns-on: [self-hosted,\nlinux, x64, prod]"] --> M["Forge matches\nall labels"]
    R1["Runner\n[self-hosted, linux,\nx64, prod]"] --> M
    R2["Runner\n[self-hosted, linux,\narm64]"] --> M
    R3["Runner\n[self-hosted, linux,\nx64, gpu]"] --> M
    M -->|"match"| R1
    M -->|"no 'prod'"| R2
    M -->|"no 'gpu' label\nneeded"| R3

The match: the workflow wants every label. The runner has extra labels and that’s fine — a runner tagged [self-hosted, linux, x64, prod, gpu] matches a workflow asking for [self-hosted, linux, x64, prod]. The runner having more than the workflow needs does not disqualify it.

# A workflow that wants a specific runner class
jobs:
  build:
    runs-on: [self-hosted, linux, x64, prod]
    steps:
      - uses: actions/checkout@v4

Built-in hosted labels

GitHub-hosted runners come with a fixed label set:

  • ubuntu-latest, ubuntu-22.04, ubuntu-20.04
  • windows-latest, windows-2022
  • macos-latest, macos-14, macos-13

The team chooses among them; the team cannot add to them. The labels change meaning over time — ubuntu-latest today is ubuntu-22.04 plus patches, and will become ubuntu-24.04 when GitHub publishes that image generation.

Self-hosted labels

Self-hosted runners are labelled at registration:

# Register with three labels
./config.sh --url "$REPO_URL" --token "$REGISTRATION_TOKEN" \
  --labels "linux,x64,prod"
flowchart LR
    A["config.sh\n--labels linux,x64,prod"] --> B[".runner file"]
    B --> C["Forge sees labels:\nself-hosted, linux, x64, prod"]

The team chooses the labels. There is no schema; any string works. The self-hosted label is implicit — every self-hosted runner has it — but workflows conventionally include it for clarity.

The default-routing trap

flowchart TB
    R["Self-hosted runner\n(no labels)"] -->|"matches\nanything"| J1["Trusted PR job"]
    R --> J2["Fork PR job"]
    R --> J3["Random branch push"]

A self-hosted runner registered with no labels matches every runs-on expression that does not explicitly exclude it. Including fork pull requests from any contributor. Including branches the team has never seen.

This is the canonical misconfiguration. An engineer adds a self-hosted runner for “internal builds”, forgets to label it, and discovers six months later that fork-PR jobs have been running on the same host as production builds. The trust model has been silently violated.

A label taxonomy

The labels a team picks encode the team’s pool structure. A clean taxonomy follows:

flowchart TB
    subgraph SH["Self-hosted"]
        P["prod"]
        S["staging"]
        U["untrusted\n(fork PRs)"]
        G["gpu"]
        A["arm64"]
    end
    subgraph H["Hosted"]
        UB["ubuntu-latest"]
        ML["macos-latest"]
    end
  • prod / staging / untrusted — trust class. The workflow picks the pool by picking the trust class.
  • linux / windows / macos — OS family. Most workflows need this.
  • x64 / arm64 — architecture. Workflows building multi-arch images need this.
  • gpu — specialised hardware. A workflow that needs CUDA asks for gpu.
  • Per-team labelsteam-frontend, team-data, etc., to carve out capacity for high-priority teams.

Workflows that want a production Linux x64 runner:

runs-on: [self-hosted, linux, x64, prod]

Workflows that want a fork-PR sandbox:

runs-on: [self-hosted, linux, untrusted]

The same workflow, two pools, separated by label.

Labels are a trust model

flowchart LR
    W["Workflow"] -->|"runs-on: [untrusted]"| U["Untrusted pool\n(no secrets,\nno IAM)"]
    W2["Workflow"] -->|"runs-on: [prod]"| P["Prod pool\n(full secrets,\nIAM, VPC)"]

The label expression is not just a routing hint. It is a statement about which trust class the workflow belongs to, which secrets the runner mounts, and which network the runner can reach. A misconfigured label can land a production job on an untrusted pool (the workflow loses its secrets), or a fork-PR job on a production pool (the runner loses its isolation).

Production discipline

  1. Every self-hosted runner has at least one label. No bare runners in production.
  2. Every workflow specifies runs-on explicitly. No defaulting to “any available runner”.
  3. Labels reflect the trust model. A runner pool with production secrets is labelled prod; a runner pool with no secrets is labelled untrusted. The label is the trust class.
  4. Document the label taxonomy. New engineers need to know what labels exist and how to combine them.
  5. Audit label drift. A runner that was supposed to be prod and is now prod,gpu is a different runner for some workflows; the team should know.

Cross-course references

  • Linux for Production Sysadmins - Part XXIV (ImmutInf) covers the host-tagging patterns that map onto runner labels.
  • Ansible for Production Sysadmins - Part XXXVII (RepoArch) covers the pool-architecture patterns that labels express.
  • Kubernetes for Production Sysadmins - Parts XXXII-XXXV (RBAC) cover the trust-model patterns that runner labels embody.

Quiz

Knowledge check · 4 questions

  1. Q1. A self-hosted runner is registered with `config.sh --labels ""` (no labels). Which workflows can the forge send to it?

  2. Q2. A runner with labels `[self-hosted, linux, x64, prod, gpu]` can match a workflow asking for `[self-hosted, linux, x64, prod]`.

  3. Q3. Name three categories of runner labels and what each category expresses.

  4. Q4. Design a runner label taxonomy for a team that needs to separate production builds from fork-PR builds from GPU builds, and identify the misconfiguration to avoid.

    Team T runs three workflows: `prod-build` (production CI, needs AWS IAM), `fork-pr` (external contributions, no IAM), and `ml-train` (GPU training, needs CUDA). They use self-hosted runners for all three.

Passing score: 75%. Answers are checked in this browser.