GitHub Actions Unexpected Value at a workflow line
A GitHub Actions Unexpected Value annotation means your YAML parsed and then failed the workflow schema, at the line and column the message gives you. The parser prints the same six words for two different findings, and which one you have is decided by whether the quoted text is a key you wrote on the left of a colon or a value you wrote on the right.

What this error means
The file is valid YAML. Nothing is wrong with the indentation as far as any YAML linter is concerned, and the editor shows no red. GitHub still refuses to queue the run, and the annotation on the commit lists one or more lines, each with a line number, a column number, and a quoted piece of your own file. The quoted piece is the diagnosis. If it is a key, the schema has no property by that name in the position you put it. If it is a value, the schema has a fixed set of values for that key and yours is not one of them. Several of these usually arrive together, because one structural mistake pushes a whole block into a place where none of its keys belong, and the parser reports every key in the block rather than stopping at the first. The last line of the annotation is often the one that explains the rest.
Invalid workflow file: .github/workflows/deploy.yml#L1
(Line: 1, Col: 3): Unexpected value 'build'
(Line: 69, Col: 3): Unexpected value 'deploy'
(Line: 215, Col: 3): Unexpected value 'composer-validate'
(Line: 1, Col: 3): Required property is missing: jobsA minimal workflow that produces it
This file is written for this page and has never been run. It carries one mistake of each kind, so the annotation comes back with two lines rather than one. step on the left of a colon is not a property of a job, and read on the right of permissions: is not one of the two shorthand strings the schema accepts there.
Both lines say the same thing and mean different things. The first is a key the schema cannot place. The second is a key the schema knows perfectly well, holding a value it will not take.
name: deploy
on: push
permissions: read
jobs:
build:
runs-on: ubuntu-latest
step:
- uses: actions/checkout@v7
- run: makeCommon causes
A key the schema has no property for
Usually a typo, and usually a singular where the schema wants a plural: step for steps, run-on for runs-on, output for outputs. The reader has no fuzzy matching, so a key one letter out is as unknown as a key you invented. The quoted text in the message is the key exactly as you wrote it, which is the fastest way to see the typo you have been reading past.
A block indented under the wrong parent
This is the one that produces a page of errors from a single edit. Every key in the block is now being checked against the wrong definition, so every key that does not happen to exist there is reported. The annotation quoted above is this case: the jobs: wrapper was lost in a squash, so three job ids became top-level keys and the root mapping lost its only required property.
A value outside the fixed set for that key
Some keys take free text and some take a closed list. secrets: on a calling job takes a mapping or the single word inherit, and nothing else; permissions: takes a mapping or read-all or write-all. A near miss such as inherits or a bare read is reported as an unexpected value rather than as a bad option, because to the reader they are the same finding.
The parser narrowed to the other shape first
The key is real, but an earlier key in the same block eliminated the definition it belongs to. In our experience this is the least obvious of the four, because the error points at the line you wrote last while the cause is a line above it that the message never names.
How to fix it
Read the column number, not just the line
- Take the column from the annotation and count characters on that line.
- If the column lands on text before a colon, you have the key branch: the name is wrong or it is in the wrong place.
- If it lands after a colon, you have the value branch: the key is fine and the value is not on the list.
- Work through the reported lines from the bottom up, because the last one is usually the structural cause of the others.
Fix the key, or move the block that carries it
For a typo, correct the spelling. For a misindented block, put it back under the parent that owns it rather than renaming keys to suit where it landed. The corrected version of the illustrative file is below: steps under the job, and permissions as a mapping.
name: deploy
on: push
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: makeWhen the key is real, reorder or split the job
A job that calls a reusable workflow may only carry the keys in the middle and right columns of the table above. If one of the left-column keys is there because you needed it, that work belongs in a separate job rather than on the caller. Setting env: on a caller is the common one, and there is nowhere to put it: a called workflow does not inherit the caller environment.
jobs:
deploy:
uses: ./.github/workflows/deploy-impl.yml
with:
target: production
secrets: inheritValidate before you push
The schema in actions/runner is published beside the parser that loads it, and the editor tooling ships its own copy, so a local check is reading a published definition rather than an approximation. Either the VS Code extension or a command-line linter will show you the annotation on the line you are editing instead of after a push.
actionlint .github/workflows/deploy.ymlOne message, two branches of the reader
The parser is open source twice over. actions/runner carries the C# copy GitHub publishes, and actions/languageservices carries the TypeScript copy that the VS Code extension runs. Both spell the message from the same template, TemplateStrings.resx in the runner giving it as a format string with one slot.
The key branch fires at the end of the loop that reads a mapping. For each key in turn the reader asks the schema whether any candidate definition has a property by that name, then whether the mapping accepts loose keys. If neither is true there is nowhere to put the key, and the reader reports it and skips whatever was under it. That last step matters when you are reading an annotation: a rejected key takes its entire value with it, so one bad key can silence every error inside the block it introduced.
The value branch fires while reading a scalar. The reader collects every scalar definition the schema allows in that position and looks for one that matches the literal. If the literal is not a string it converts it and tries once more. Only after both attempts does it report, which is why a number where a string was wanted usually passes and a misspelled keyword does not.
// key branch, template-reader.ts: no property and no loose key type
this._context.error(nextKey, `Unexpected value '${nextKey.value}'`);
// value branch, template-reader.ts: no scalar definition matched the literal
this._context.error(literal, `Unexpected value '${literal.toString()}'`);Why a real key is sometimes rejected anyway
The third case is the one that sends people in circles, because the key is spelled right and documented and still comes back unexpected. It happens where the schema offers a choice. A job is defined as one of two shapes, and so is a step, and the reader narrows to one of them while it is reading, using your own keys as the evidence.
The narrowing rule is in TemplateSchema.TryMatchKey. When a key exists in some candidate definitions and not others, the ones that do not have it are removed from the list. The first key unique to one shape therefore decides the shape, and every later key belonging only to the other shape is reported as unexpected. Order in the file is what settles it, so moving two lines can change which half of the job gets the error.
For a job the split is below. A caller job that sets env: before it sets uses: has already been read as a normal job by the time uses arrives, which is how a correct reusable-workflow call earns three rejections in a row.
| Only on a normal job | Only on a call job | On both |
|---|---|---|
| runs-on, steps | uses | name, needs |
| env, environment | with | if, permissions |
| container, services | secrets | concurrency, strategy |
| outputs, defaults | ||
| timeout-minutes | ||
| continue-on-error |
Why there is no recorded run on this page
The pages in this library that carry a log carry it because we ran the job and kept the output. This failure never reaches a runner. The schema check happens when GitHub reads the file, before a job is queued and before any machine is assigned, so there is no run to record and nothing on a runner that could have behaved differently. The annotation above is quoted from a public repository rather than produced here, and the workflows on this page are illustrative.
How to prevent it
- Copy the shape of a job from a file that already runs, rather than from memory.
- Keep reusable-workflow callers in their own jobs, so no left-column key ever lands on one.
- Read annotations from the bottom up, since the structural error is usually reported last.
- Run a workflow linter in a pre-commit hook, where the cost of being wrong is seconds.
Frequently asked questions
What does Unexpected value mean in a GitHub Actions workflow?
Why do I get several Unexpected value lines from one mistake?
jobs: wrapper produced three of them, one per job id, followed by a missing-property line for jobs itself. Fixing the wrapper cleared all four.Why is uses rejected on a job when the documentation shows it?
env: or timeout-minutes: appearing before uses: removes the reusable-workflow shape from the candidates, and uses, with and secrets are then unknown keys.Does the line number in the annotation point at the real problem?
Related guides
References
- actions/languageservices: workflow-parser template-reader.ts, both Unexpected value branches
- actions/runner: workflow-v1.0.json, the job and step one-of definitions
- just-another-job-application-tracker#208: a lost jobs wrapper and the annotation it produced
- GitHub Actions: workflow syntax reference
- GitHub Actions documentation