# GitHub Actions Unexpected value true workflow_call, and when it fires

> GitHub Actions Unexpected value true workflow_call is checked in two passes. See which one catches a literal and which waits for the call.

Source: https://latchkey.dev/learn/github-actions/github-actions-reusable-workflow-input-type-mismatch  
Updated: 2026-09-20

GitHub Actions Unexpected value true workflow_call is a type check that runs in two passes, and which pass catches yours decides what you see. Literal values are judged when the calling workflow is compiled, before any job exists, while a value built from an expression skips that pass entirely and is judged only when the call is actually made.

## What this error means

One of two things happens and they look nothing alike. Either the calling workflow is annotated and no run appears, naming an input or quoting a value, or the workflow validates cleanly, the run starts, and the called job fails to begin with an annotation about a value. The second version is the confusing one, because the file that failed is the caller and the value it is complaining about is not written anywhere in it: the value came out of an expression that resolved at call time.

```Message text from TemplateStrings.UnexpectedValue in actions/runner
Invalid workflow file: .github/workflows/release.yml#L14
(Line: 14, Col: 15): Unexpected value 'true'
```

## Common causes

### A boolean or number input was given a quoted literal

The most common single cause, and the fastest to fix. YAML quoting turns the value into a string token, and the definition built from a boolean or number input accepts only its own token type with no string fallback.

### The value came from an expression, so no early check applied

An expression under `with` is skipped by the early pass because the contexts it needs are not available yet. The workflow file is therefore valid, and the mismatch surfaces only when the call resolves and the expanded text meets the declared type.

### The key is not declared by the called workflow at all

A renamed or misspelled input produces a different message that names the key and the file it was not found in. This one is caught early and is the easiest of the group to read, because the message is specific rather than generic.

### A required input was dropped during a refactor

Marking an input required means the caller is checked for it during the early pass. In our experience this surfaces when a called workflow gains a required input and the callers are updated in a separate change.

## How to fix it

### Remove the quotes and let YAML carry the type

Write `deploy: true` rather than `deploy: 'true'`, and `retries: 3` rather than `retries: '3'`. The token type YAML produces is the whole of what the check looks at, so unquoting the literal is usually the entire fix.

```.github/workflows/release.yml (illustrative)
with:
  deploy: true
  retries: 3
```

### Cast an expression result at the call site

Wrap the expression in `fromJSON` so the text becomes a real boolean or number before it meets the declared type. Do this in the caller, where the value is built, rather than in the called workflow, where the type has already been decided.

```.github/workflows/release.yml (illustrative)
with:
  deploy: ${{ fromJSON(needs.plan.outputs.should_deploy) }}
```

### Declare the input as a string when it will always arrive as text

1. Change the input's `type` to `string` in the called workflow.
2. Compare it explicitly wherever it is used, for example against the literal text true.
3. Update every caller in the same change, because a caller passing an unquoted boolean will now be sending a boolean token to a string input.
4. Say in the input description that the value is text, so the next caller does not guess.

### Validate the caller before you push it

The editor extension validates against the same schema the parser uses, so an unquoted or misquoted literal is visible while you type. It cannot check an expression for you, for exactly the same reason the early pass cannot, so treat a clean editor as evidence about literals only.

## How to prevent it

- Keep literal inputs unquoted unless the exact characters matter, then quote them deliberately.
- Cast expression results with `fromJSON` at the call site, never inside the called workflow.
- Add a required input and update its callers in one change.
- Give every `workflow_call` input a description that names the shape it expects.

## What each pass can see

The converter that handles a reusable workflow call runs the same routine twice with a flag. In the early pass it walks the called workflow's declared inputs, checks that every required input was supplied, checks that every key in the caller's `with` block is one the called workflow declares, and type-checks any value it can read as a literal. In the late pass it does the same walk with expressions already expanded and keeps the resulting values.

The distinction that matters is a single condition inside the type check. After handing the value to the template evaluator against the declared type, the routine returns nothing early if this is the early pass and the value contains an expression token. Expressions are not evaluated during the early pass, because the contexts they would need have not been added yet, so nothing can be concluded about their type and nothing is claimed.

That is why a boolean input fed by `${{ needs.setup.outputs.flag }}` cannot be rejected when you push. There is no value yet. The check that will judge it happens when the call is made and the expression has become a string.

Adyen/adyen-android#2902 is that sequence written down by somebody who paid for it. A `workflow_call` output, which is always a string, was passed straight into an input declared `type: boolean`; the caller validated, the run started, and the job failed during input evaluation with `Unexpected value 'true'` only after an hour-long publish step had already finished. The fix in that pull request is the one this page recommends: wrap the value in `fromJSON()` so the text becomes a real boolean before it meets the declared type.

| What you wrote under `with` | Caught by the early pass | What decides it |
| --- | --- | --- |
| A key the called workflow does not declare | yes | the check for parameters with no declaration |
| A required input left out | yes | the required input check |
| `flag: 'true'` against a boolean input | yes | the declared type, read as a literal |
| `flag: ${{ steps.x.outputs.y }}` | no | the same type, once the call resolves |

> The first two rows produce messages that name the input: an invalid parameter that is not defined in the referenced workflow, or an input that is required but not provided while calling. The type rows produce the schema reader's generic Unexpected value instead, because the type check is delegated to the templating library.

## Why a quoted true is refused and an unquoted one is not

The declared type is turned into a schema definition before the value is evaluated: a boolean input becomes a boolean definition with a context list, a number input a number definition, a string input a string definition. The boolean definition matches exactly one thing, a token that is already a boolean, and it offers no string fallback.

YAML decides which token you wrote. `flag: true` is a boolean token and matches. `flag: 'true'` is a string token, matches nothing the definition accepts, and the evaluator records Unexpected value quoting the text. This is the whole of the rule, and it explains why the fix is usually to delete two quote characters rather than to change anything structural.

It also explains a trap in the other direction. A value like `version: 3.10` is a number token, so a string input receives something the string definition has to convert, and the conversion goes through the token's own rendering rather than through your source text. Quote anything whose exact characters matter.

```.github/workflows/release.yml (illustrative)
jobs:
  release:
    uses: ./.github/workflows/deploy.yml
    with:
      deploy: true        # boolean token, matches a boolean input
      version: '3.10'     # string token, keeps the trailing zero
```

## What to do about the pass that cannot check you

Since an expression is invisible to the early pass, you carry the risk yourself. The reliable move is to convert the text into the declared type at the point where you build it, rather than hoping the type survives a trip through a string. `fromJSON` turns the text true into a boolean and the text 3 into a number, which is exactly what a boolean or number input wants.

The alternative, and often the better one, is to declare the input as a string and compare it explicitly inside the called workflow. That removes the conversion entirely, at the cost of one comparison. Our page on reusable workflow input types covers the trap that makes this attractive, which is that a boolean input holding text is truthy whatever the text says.

Whichever you choose, do it once at the boundary. A workflow that casts in the caller and compares as a string in the callee has two rules for one value, and the next person to touch it will only find one of them.

```.github/workflows/release.yml (illustrative)
jobs:
  release:
    uses: ./.github/workflows/deploy.yml
    with:
      deploy: ${{ fromJSON(needs.setup.outputs.flag) }}
```

## Why there is no recorded run on this page

Both passes happen before a job is created, so neither produces a runner log. The early pass runs while the calling workflow is compiled, and the late pass runs when the call is resolved, which is still upstream of anything being queued. A Latchkey runner has nothing to observe in either case, and a recording would have to be of a run that succeeded, which is not the subject.

The two passes and the condition that separates them are one routine and one flag in the published converter, and the boolean definition that refuses a quoted literal is a few lines beside it. Reading those is more reliable than inferring the boundary from the timing of an annotation.

## FAQ

### Why did my caller validate but fail when the job started?

Because the value came from an expression. The early pass over the `with` block skips type checking for any value containing an expression token, since expressions are not evaluated at that point. The type is applied later, when the call resolves and the expression has produced text.

### Why is 'true' in quotes rejected by a boolean input?

Quoting makes it a string token. The definition built from a boolean input matches a boolean token and nothing else, with no string fallback, so the evaluator records an Unexpected value naming the text it found. Removing the quotes makes YAML produce a boolean token instead.

### How do I pass a step or job output to a boolean input?

Convert it at the call site with `fromJSON`, which turns the text true into a real boolean. Outputs are always text, so without the conversion you are relying on a late check that will either reject the value or accept a string into a boolean-shaped slot.

### Which mistakes in a with block are caught before the run starts?

An input the called workflow does not declare, a required input that was not supplied, and a literal whose type does not match the declaration. Anything built from an expression is not checked at that point, because the value does not exist yet.

## References

- [actions/runner: WorkflowTemplateConverter.cs, ConvertToWorkflowJobInputs](https://github.com/actions/runner/blob/main/src/Sdk/WorkflowParser/Conversion/WorkflowTemplateConverter.cs)
- [actions/runner: the published workflow schema, workflow_call input definitions](https://github.com/actions/runner/blob/main/src/Sdk/WorkflowParser/workflow-v1.0.json)
- [GitHub Actions: reuse workflows](https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows)
- [GitHub Actions: expressions, fromJSON](https://docs.github.com/en/actions/reference/workflows-and-actions/expressions)
- [Adyen/adyen-android#2902: a workflow_call string reaching a boolean input, and the late failure](https://github.com/Adyen/adyen-android/pull/2902)

---

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
