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.

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.
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-onA 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.
| Key | Ordinary job | Caller job (uses:) |
|---|---|---|
runs-on | Required | Not allowed |
steps | Allowed | Not allowed |
uses, with, secrets | Not allowed | uses required, the others allowed |
environment, container, timeout-minutes | Allowed | Not allowed |
needs, if, permissions, concurrency, strategy, name | Allowed | Allowed |
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
- Open the line the report names and read the whole job it belongs to.
- If the job has a
uses:key, it is a caller job andruns-onhas to go. - If the line is under
defaults:, it is in a mapping that accepts onlyrun. - 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.
on:
workflow_call:
inputs:
runner:
type: string
default: ubuntu-latest
jobs:
build:
runs-on: ${{ inputs.runner }}
steps:
- uses: actions/checkout@v5Put 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.
env:
RUNNER_LABEL: ubuntu-latest
jobs:
test:
runs-on: ${{ env.RUNNER_LABEL }}
steps:
- run: echo okRead the second message as well as the first
- Validation reports several findings from one pass, comma separated or on their own lines.
- A
Required property is missing: runs-onalongside the rejection tells you where the key should have been. - 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.
call:
uses: ./.github/workflows/build.yml
with:
runner: ubuntu-latest
secrets: inheritThe 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
defaultsonly ever carriesrun, 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?
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?
Can I set a default runner for every job in a workflow?
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?
uses: in there.Related guides
References
- actions/runner: workflow-v1.0.json, the job-factory and workflow-job definitions
- actions/runner: Schema/TemplateSchema.cs, TryMatchKey narrowing the candidate definitions
- aureliojargas/github-actions-sandbox#2: runs-on under defaults, with both reported lines
- kyma-project/application-connector-manager#928: a caller job narrowed by environment, and the four lines it produced
- GitHub Docs: workflow syntax, for the keys each kind of job accepts
- GitHub Actions documentation