runs-on

runs-on is the GitHub Actions job key that selects which runner picks the job up, by a single label, a list of labels, or a runner group object.

runs-on is the key inside a GitHub Actions job that selects which runner picks that job up. It accepts a single label, a list of labels that one runner must all carry, or an object naming a runner group with labels to filter inside that group.

The value is a routing key rather than a machine specification. GitHub compares what the job asked for against the labels each online runner advertises, hands the job to one runner that satisfies the request, and leaves the job queued when nothing matches.

Definition

runs-on sits at jobs.<job_id>.runs-on and is required for every job that runs steps. A job that calls a reusable workflow through uses omits the key, because the called workflow declares runners for its own jobs. GitHub documents the key in the workflow syntax reference, checked on 2026-08-13.

The three shapes

ShapeYAMLWhat it selects
Stringruns-on: ubuntu-latestany online runner advertising that one label
Arrayruns-on: [self-hosted, linux, x64]any online runner advertising every label in the list
Objectruns-on: { group: production-runners, labels: [linux, x64] }a runner inside the named group that also carries the listed labels

The object form is the only shape that reaches a runner group by name. Groups are an organization-level or enterprise-level grouping around a fleet, and the group itself controls which repositories and workflows may queue work onto it.

How matching works

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

Job selectorLabels the runner advertisesResult
[self-hosted, linux]self-hosted, linux, x64, ubuntu-24.04eligible
[self-hosted, linux, x64]self-hosted, linux, arm64skipped, no x64
[self-hosted, linux, x64, db]self-hosted, linux, x64skipped, no db
ubuntu-latestubuntu-latest on a hosted pooleligible

Two consequences follow. Each label added to the array narrows the set of runners that can serve the job, so a longer selector is a stricter filter. And the array behaves as an AND with no OR form available, so a job that should accept either of two fleets uses an expression that resolves to one label rather than a list.

Labels themselves are opaque strings. GitHub does not verify that a runner labeled x64 is an x86-64 machine or that a runner labeled large has more than two cores. Whoever registers the runner chooses what it advertises, and keeping those strings honest is the registrar's job. The reserved labels such as ubuntu-latest, windows-latest, and macos-latest are the exception, since they point at GitHub's own hosted pools and cannot be claimed by a self-hosted runner. A registered self-hosted runner automatically advertises self-hosted plus its operating system and architecture, along with any custom labels supplied at registration.

Expressions in the value

runs-on accepts expressions, and they resolve before the job starts. ${{ matrix.runner }}, ${{ inputs.runner }}, and ${{ vars.DEFAULT_RUNNER }} are all valid, which is how one workflow file targets different fleets per branch, per input, or per matrix leg.

Because the expression resolves to an ordinary string, a misspelled label is never a syntax error. The job is accepted, queued, and then waits, showing no logs and no failure. GitHub cancels a job that has been queued for 24 hours waiting on a self-hosted runner (GitHub Actions limits, checked on 2026-08-13), so a label mismatch reports as a cancellation a day after the push.

Example

This job asks for a GitHub-hosted Linux machine using the reserved label:

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

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm test

Sending the same job to a different fleet is a one line edit. The steps, the trigger, and the action versions stay where they are:

jobs:
  unit-tests:
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm test

The job now waits for a runner advertising warp-ubuntu-latest-x64-4x instead of one from GitHub's hosted Linux pool. Labels of that shape pack the operating system, the architecture, and the machine size into one string, and the WarpBuild cloud runners documentation lists the full set of them.

Combining several labels into one selector

The array form composes labels into a single selector. Each entry is a condition the runner must satisfy:

jobs:
  integration:
    runs-on: [self-hosted, linux, x64, db]
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/integration-test.sh

self-hosted restricts the job to registered runners, linux and x64 pin the platform, and db picks out the subset of that fleet prepared with a local database. Drop db and the job becomes eligible for every Linux x64 runner in scope. Add a fifth label and the eligible set shrinks again, down to zero if no runner carries all five.

The object form applies the same subset test inside a named group:

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

An expression covers the case where the fleet changes per matrix leg, since the label is resolved per job:

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

FAQ

What does runs-on mean in GitHub Actions?

runs-on is the key inside a job that selects which runner executes that job. It holds a label, a list of labels the runner must all carry, or an object naming a runner group plus labels to filter inside it. GitHub routes the queued job to an online runner that advertises everything the key asked for.

Can runs-on take more than one label?

Yes, as a YAML array. Every label in the array must be present on the runner, so each entry narrows the eligible set. The array behaves as an AND. There is no OR form, so a job that should accept either of two labels uses an expression that resolves to one label instead.

What happens when no runner matches runs-on?

The job stays queued with no logs, because GitHub never assigns it. A typo in a label produces the same symptom as an empty fleet. GitHub cancels a job that has waited 24 hours for a self-hosted runner, so a mismatch surfaces as a silent failure a day later.

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.