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

> github actions unexpected value runs-on usually means the job is a reusable-workflow caller, not that the key is indented wrongly. Here is the rule.

Source: https://latchkey.dev/learn/github-actions/gha-unexpected-value-runs-on-in-ci  
Updated: 2026-09-21

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.

```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
```

## 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.

## 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.

## 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.

| 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 |

> Ordering explains a confusing family of reports. A caller job that lists `environment` before `uses` gets `Unexpected value 'uses'` and a complaint that `runs-on` is missing, because `environment` narrowed it to the ordinary shape first. One public pull request records exactly that four-line report and works out the same rule from it.

## 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.

> For the general case of this message on keys other than `runs-on`, see [GitHub Actions Unexpected Value at a workflow line](/learn/github-actions/github-actions-unexpected-value-line-number).

## 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.

## FAQ

### 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.

## References

- [actions/runner: workflow-v1.0.json, the job-factory and workflow-job definitions](https://github.com/actions/runner/blob/main/src/Sdk/WorkflowParser/workflow-v1.0.json)
- [actions/runner: Schema/TemplateSchema.cs, TryMatchKey narrowing the candidate definitions](https://github.com/actions/runner/blob/main/src/Sdk/WorkflowParser/ObjectTemplating/Schema/TemplateSchema.cs)
- [aureliojargas/github-actions-sandbox#2: runs-on under defaults, with both reported lines](https://github.com/aureliojargas/github-actions-sandbox/pull/2)
- [kyma-project/application-connector-manager#928: a caller job narrowed by environment, and the four lines it produced](https://github.com/kyma-project/application-connector-manager/pull/928)
- [GitHub Docs: workflow syntax, for the keys each kind of job accepts](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
