Skip to content
Latchkey LogoLatchkey home

GitHub Actions unexpected value runs-on on a job that cannot have one

GitHub Actions unexpected value runs-on means the validator was reading a mapping whose allowed keys do not include runs-on when it reached yours. The mapping is rarely the workflow root and the indentation is usually right: far more often the job had already been decided to be a reusable-workflow caller, and a caller job is a different shape with a different set of keys.

Two job shapes side by side, showing which keys each one accepts and where runs-on sits
The two key lists are read from the job-factory and workflow-job definitions in the shipped workflow-v1.0.json. The log line is quoted from aureliojargas/github-actions-sandbox#2.

What this error means

The workflow does not start and the report names a runs-on: line that looks perfectly ordinary. Check what else is in that job before you check the indentation. A job containing uses: is a caller job, and a caller job accepts name, uses, with, secrets, needs, if, permissions, concurrency and strategy, and nothing else; runs-on is not on that list, because the machine is chosen inside the workflow being called. The other place the key is refused is the workflow-level defaults: mapping, which accepts only run. Both read as an indentation problem and neither is one.

Actions log, quoted from aureliojargas/github-actions-sandbox#2
The workflow is not valid. .github/workflows/defaults.yml 
(Line: 3, Col: 3): Unexpected value 'runs-on' .github/workflows/defaults.yml
(Line: 6, Col: 5): Required property is missing: runs-on

A job is one of two shapes, and the first key decides which

In the workflow schema that ships with the runner, a job is a choice between two mappings. One of them, the ordinary job, requires runs-on and allows steps, container, services, environment, timeout-minutes, continue-on-error, outputs, defaults and the rest. The other, the caller job, requires uses and allows a much shorter list.

The reader does not decide up front which one you meant. It reads keys in order, and each key that exists in only one of the two candidates removes the other from consideration. Once one shape is left, every later key is judged against that shape alone, and a key the survivor does not know is reported as an unexpected value. So the first discriminating key in the job wins, and the error lands on whichever conflicting key came second.

KeyOrdinary jobCaller job (uses:)
runs-onRequiredNot allowed
stepsAllowedNot allowed
uses, with, secretsNot alloweduses required, the others allowed
environment, container, timeout-minutesAllowedNot allowed
needs, if, permissions, concurrency, strategy, nameAllowedAllowed

Common causes

The job calls a reusable workflow, so it cannot choose a runner

The largest cause and the one that looks least like a mistake. A job with uses: is a caller, and the caller shape has no runs-on. Whoever added the line was usually trying to move that work onto a different runner, which is a legitimate goal that this key cannot serve.

runs-on was placed under defaults, at the workflow or job level

The defaults mapping accepts only run, and run accepts only shell and working-directory. Setting a default runner for every job is not something the schema supports, so the attempt is rejected and the job that needed the key is then reported as missing it.

Another caller-only or job-only key appeared first

Because narrowing happens key by key, the reported key is the second conflicting one rather than the wrong one. A job with environment above uses gets a complaint about uses; one with uses above environment gets a complaint about environment. In our experience this is what makes the reports feel arbitrary.

A de-indent moved the key out of its job

The classic explanation, and still real. It is distinguishable at a glance from the column number in the report: the workflow root is column 1, and a key inside a job sits at the same column as its siblings.

How to fix it

Look at the job body before the indentation

  1. Open the line the report names and read the whole job it belongs to.
  2. If the job has a uses: key, it is a caller job and runs-on has to go.
  3. If the line is under defaults:, it is in a mapping that accepts only run.
  4. Only if neither is true, check the column number and compare it against the job's other keys.

Pass the runner through the called workflow as an input

Where the caller genuinely needs different hardware, declare an input on the called workflow and use it in that workflow's own runs-on. This is the supported route and it keeps the choice with the job that actually runs.

.github/workflows/build.yml (illustrative)
on:
  workflow_call:
    inputs:
      runner:
        type: string
        default: ubuntu-latest

jobs:
  build:
    runs-on: ${{ inputs.runner }}
    steps:
      - uses: actions/checkout@v5

Put runner defaults somewhere the schema supports

Since defaults cannot carry a runner, use the mechanisms that can. A workflow-level env entry referenced from each job's runs-on keeps one place to edit, and a matrix does the same for a set of jobs.

.github/workflows/ci.yml (illustrative)
env:
  RUNNER_LABEL: ubuntu-latest

jobs:
  test:
    runs-on: ${{ env.RUNNER_LABEL }}
    steps:
      - run: echo ok

Read the second message as well as the first

  1. Validation reports several findings from one pass, comma separated or on their own lines.
  2. A Required property is missing: runs-on alongside the rejection tells you where the key should have been.
  3. Fixing the placement usually clears both, so do not chase them one at a time.

Why a caller job has no runs-on to give

This is not an arbitrary restriction. A job with uses: does not run anything itself; it hands control to another workflow file, and that file declares its own jobs with their own runs-on values. There is no machine for the caller to choose, so there is no key for choosing one.

The practical consequence is that a runner change cannot be made from the calling side. If a caller job needs to run somewhere else, the called workflow needs an input, and the runner label has to be passed through it. That is more work than editing one line, which is why people keep trying to put runs-on on the caller and getting this message.

.github/workflows/ci.yml (illustrative)
  call:
    uses: ./.github/workflows/build.yml
    with:
      runner: ubuntu-latest
    secrets: inherit

The other refusal, which is the workflow-level defaults block

The quoted report above is not a caller job at all. Someone put runs-on under the workflow-level defaults: key, reasoning that if defaults can set a default shell for every job then it might set a default runner too. The shipped schema says otherwise: defaults at the workflow level has exactly one property, run, which itself has only shell and working-directory. The job-level defaults is the same shape.

What makes that report unusually clear is its second line. Because runs-on was consumed at the wrong level, the job that needed it did not have it, so the same validation pass reported both the rejected key and the missing required one. Two messages, one mistake, and the second is often the more legible of the two.

When it really is the workflow root

The explanation everybody reaches for first does exist. The workflow root mapping allows nine keys and runs-on is not among them, so a runs-on: at column 1 is refused, and a de-indent that pulls the key out of its job produces exactly that.

It is easy to confirm and easy to rule out, which is why it belongs at the end of the ladder rather than the start: look at the column number in the report. A column of 1 means the workflow root. A column matching the other keys inside a job means the job shape is what you are arguing with, not the indentation.

What is published, and what is not

GitHub's pre-run validation service is closed, so this page does not claim to describe what that service runs. The two-shape rule, the key lists and the narrowing behaviour are read from the parser and the workflow schema that actions/runner publishes, and the message template itself is one line in the runner's generated string resources.

The reason to trust the description anyway is that the reports line up with it. A caller job rejecting environment, timeout-minutes, continue-on-error and runs-on, all of which are absent from the caller shape in the published schema, is a pattern several public repositories have hit and fixed in exactly the way the schema predicts.

Why there is no recorded run on this page

This failure happens before a job exists. The file is rejected at validation, no runner is ever assigned, and there is nothing for a reproduction on our infrastructure to reproduce: the same file rejected on a Latchkey runner would be rejected at exactly the same moment, by the same service, without a machine being involved. A recorded run would show a workflow that never started, which is a picture of an absence. The behaviour is read from the published schema instead, and the log is quoted from a public repository that hit it.

How to prevent it

  • Treat a job with uses: as a different kind of job, and keep its key list short on purpose.
  • Give reusable workflows a runner input from the start, so callers never need to reach for runs-on.
  • Remember that defaults only ever carries run, at both the workflow and the job level.
  • Use an editor with the workflow schema attached, so the shape is enforced while you type rather than on push.

Frequently asked questions

Why can I not set runs-on on a job that calls a reusable workflow?
Because that job does not run anything itself. It hands control to another workflow file, whose own jobs declare their runners. The published schema gives caller jobs a short key list that has no runs-on in it, and the validator refuses anything outside that list.
Why does the error name a different key from the one I think is wrong?
Because the parser narrows between the two job shapes key by key. The first key that belongs to only one shape decides, and any later key from the other shape is the one reported. Reordering the job changes which key the message names without changing the underlying mistake.
Can I set a default runner for every job in a workflow?
Not through defaults, which accepts only run, and that only accepts shell and working-directory. Put the label in a workflow-level env entry and reference it from each job's runs-on, or drive the jobs from a matrix.
How do I tell an indentation mistake from a job-shape mistake?
Read the column in the report. Column 1 means the key landed at the workflow root, which is the de-indent case. A column matching the job's other keys means the key is inside a job and the job shape is what rejected it, which usually means there is a uses: in there.

Related guides

References

Wherever runs-on is allowed, it is the one line that moves the work. Latchkey, $0.0025/min at 2 vCPU. Start free → 30-day trial · No credit card