DEV Community

Cover image for GitHub Actions reusable workflow input type mismatch
Kay
Kay

Posted on Originally published at latchkey.dev

GitHub Actions reusable workflow input type mismatch

A GitHub Actions reusable workflow input type mismatch is the caller and the called workflow disagreeing about what a value is. workflow_call inputs are declared with a type and checked against it, so a quoted string sent to a boolean input, or an expression the typed key cannot accept, stops the run before any job starts.

The three workflow_call input types against what a caller may pass into each of them
Three declared types, one list of contexts the with block accepts, and one cast for everything that arrives as text.

What this error means

One of two things happens, and they look nothing alike. The loud one is a validation failure in the caller: the run never starts, and the annotation names the input and the file it was not found in, or names the value it could not use. The quiet one is worse, because the run is green. A boolean input arrives holding text, the called workflow writes if: inputs.flag against it, and the condition is true whether the text says true or false, so a deploy that was supposed to be gated runs on every call. The tell for the quiet version is that toggling the caller changes nothing at all: the job runs identically with the flag set and cleared, and the only way to see it is to print the value rather than branch on it.

```Annotation, quoted from catalyst/catalyst-moodle-workflows#159
Invalid input, disable_phpcpd is not defined in the referenced workflow




## Common causes

### A quoted literal sent to a boolean or number input

YAML quoting decides the type before Actions sees it. `publish: true` is a boolean, `publish: 'true'` is a string, and the declared type is checked against what YAML produced. This is the version that passes validation in some shapes and then behaves as text inside the called workflow.

### The value came from an expression, so it is text

Every step output is a string by definition, and a job output built from one is a string as well. Feeding it straight into a typed key gives you either a rejection or a truthy string, depending on where the mismatch lands. The cast has to be explicit because nothing casts it for you.

### The with block reached for a context it is not allowed

Only six contexts are available in `jobs.<job_id>.with.<with_id>`. Referencing `env`, `secrets` or `steps` there produces an unrecognized name, and the typed key then rejects the whole value. Secrets have their own key, `jobs.<job_id>.secrets`, and that one does accept the secrets context.

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

A rename on one side, a copied caller, or an input removed from a shared workflow. The parser is explicit about it, naming the key and the file: this is the one failure in the set that tells you precisely what to change.

## How to fix it

### Pass literals unquoted and let YAML carry the type

1. Write `true` and `false` without quotes for a boolean input, and bare digits for a number.
2. Keep quotes for strings, where they are harmless and sometimes necessary.
3. Check the called workflow declares the type you think it does before changing the caller.



```.github/workflows/ci.yml (illustrative)
call:
    uses: ./.github/workflows/deploy.yml
    with:
      publish: true
      retries: 3
      environment: 'staging'
Enter fullscreen mode Exit fullscreen mode

Cast expression results with fromJSON

When the value comes from a step or a job output, wrap it. The cast belongs in the caller, at the moment the text becomes a typed input, and it keeps the called workflow honest about what it declared.

```.github/workflows/ci.yml (illustrative)
with:
publish: ${{ fromJSON(needs.decide.outputs.publish) }}
retries: ${{ fromJSON(needs.decide.outputs.retries) }}




### Compare explicitly inside the called workflow

A condition that reads a bare input is only safe if the input really is a boolean. Comparing against the value you expect makes the job behave the same way whether the input arrived typed or as text, which is a useful property while a shared workflow has callers you do not control.



```.github/workflows/deploy.yml (illustrative)
jobs:
  ship:
    if: ${{ inputs.publish == true || inputs.publish == 'true' }}
    runs-on: ubuntu-latest
    steps:
      - run: ./publish.sh
Enter fullscreen mode Exit fullscreen mode

Move secrets to the key that accepts them

A secret passed through with: is both a type problem and a disclosure problem, because inputs are not masked the way secrets are. Declare it under on.workflow_call.secrets and pass it with jobs.<job_id>.secrets, or use secrets: inherit to pass the caller secrets through, which works for a called workflow in the same repository and for one in the same organization or enterprise.

```.github/workflows/ci.yml (illustrative)
call:
uses: ./.github/workflows/deploy.yml
with:
publish: true
secrets:
registry-token: ${{ secrets.REGISTRY_TOKEN }}




## How to prevent it

- Declare every `workflow_call` input with the type it really is, and give it a default where one makes sense.
- Pass boolean and number literals unquoted, and cast anything that came from an expression with `fromJSON`.
- Keep secrets in the secrets key, where the secrets context is available and the value is masked.
- Version a shared workflow by tag, so a removed input cannot break every caller on the same afternoon.

## A minimal pair that produces it

These two files are written for this page and have never been run. The called workflow declares a boolean, the caller passes the quoted text `true`, and the condition inside the called workflow reads the input directly. Nothing is red. The deploy runs whatever the caller passes, because a non-empty string is truthy and the text `false` is a non-empty string.



```Two files, illustrative
# .github/workflows/deploy.yml (called)
on:
  workflow_call:
    inputs:
      publish:
        type: boolean
        required: true
jobs:
  ship:
    if: ${{ inputs.publish }}
    runs-on: ubuntu-latest
    steps:
      - run: ./publish.sh

# .github/workflows/ci.yml (caller)
jobs:
  call:
    uses: ./.github/workflows/deploy.yml
    with:
      publish: 'true'
Enter fullscreen mode Exit fullscreen mode

What workflow_call promises about types

The type is not optional and the set is small. The workflow syntax reference for on.workflow_call.inputs.<input_id>.type says it is "Required if input is defined for the on.workflow_call keyword", and that "The value of this parameter is a string specifying the data type of the input. This must be one of: boolean, number, or string."

The obligation on the caller is stated just as plainly under jobs.<job_id>.with.<with_id>: "The identifier must match the name of an input defined by on.workflow_call.inputs.<inputs_id> in the called workflow. The data type of the value must match the type defined by on.workflow_call.inputs.<input_id>.type in the called workflow." A key the called workflow does not declare is rejected with the line at the top of this page, which is the exact text the workflow parser emits.

The with block has a context list of its own

A caller that passes a literal is easy. A caller that passes an expression has a second constraint, and it is the one that produces the confusing annotations. The context availability table lists jobs.<job_id>.with.<with_id> as accepting exactly six: github, needs, strategy, matrix, inputs and vars. No env, no secrets, no steps, no job, no runner.

Use one of the missing ones and you get two complaints on the same line, because the name is rejected and then the value that contained it is rejected too. actions/runner#1492 shows the pairing on a boolean-typed key, continue-on-error rather than a with: input, and the two complaints are the same pair: "Unrecognized named-value: 'env'. Located at position 10 within expression: contains(env.fullySupportedScalaVersions, matrix.scalaVersion)" followed by "Unexpected value '${{ contains(env.fullySupportedScalaVersions, matrix.scalaVersion) }}'". The second half is the typed key refusing what the first half left behind.

Cast the value where it becomes text

Step outputs are the usual source of a boolean that is not one. The expressions reference states it as a rule: "steps.<step_id>.outputs.<output_name> evaluates as a string." So a flag computed in a step arrives at the caller as text, and a typed key will not take it as a boolean.

fromJSON is the documented way across. It "returns a JSON object or JSON data type for value", which turns the text true into a real boolean and the text 3 into a real number. Put the cast in the caller, where the type is being crossed, rather than inside the called workflow where the declaration has already promised a type.

```.github/workflows/ci.yml, corrected (illustrative)
jobs:
decide:
runs-on: ubuntu-latest
outputs:
publish: ${{ steps.flags.outputs.publish }}
steps:
- id: flags
run: echo "publish=true" >> "$GITHUB_OUTPUT"

call:
needs: decide
uses: ./.github/workflows/deploy.yml
with:
publish: ${{ fromJSON(needs.decide.outputs.publish) }}




## Why there is no recorded run on this page

Both halves are decided by the workflow parser: the loud one before the run starts, the quiet one when the expression is evaluated. Neither is something a script on a runner can produce, and neither is transient, so there is nothing to detect, retry or repair. The annotation at the top is quoted from a public issue and the two files above are illustrative.

> If the message names `inputs` rather than your key, the called workflow is not the problem: [GitHub Actions unrecognized named-value: inputs](https://latchkey.dev/learn/github-actions/gha-inputs-context-not-available-non-reusable).

## FAQ

### Why does my reusable workflow treat a boolean input as always true?

Because the value arrived as text and a non-empty string is truthy, so `if: inputs.flag` is true for the text false as well. Pass the literal unquoted from the caller, or compare explicitly inside the called workflow. Printing the value once is faster than reasoning about it, because the condition itself gives you no signal.

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

Cast it. The expressions reference says a step output "evaluates as a string", so surface it as a job output and wrap it in `fromJSON(needs.decide.outputs.publish)` in the caller. The function returns a real JSON data type, so the text true becomes a boolean and the text 3 becomes a number.

### What does "Invalid input, X is not defined in the referenced workflow" mean?

The caller passed a key the called workflow never declared under `on.workflow_call.inputs`. It is usually a rename on one side or a copied caller, and it is the one message in this family that names exactly what to change. catalyst/catalyst-moodle-workflows#159 is a public report of it after an input was dropped from a shared workflow.

### Can I use secrets or env in the with block of a reusable workflow?

No. The context availability table lists `jobs.<job_id>.with.<with_id>` as accepting `github`, `needs`, `strategy`, `matrix`, `inputs` and `vars`, and nothing else. A secret belongs in `jobs.<job_id>.secrets`, which does accept the secrets context, and an environment variable has to be lifted into one of the six first.

## References

- [GitHub Actions: workflow syntax, on.workflow_call.inputs.<input_id>.type](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#onworkflow_callinputsinput_idtype)
- [GitHub Actions: contexts reference, context availability](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#context-availability)
- [GitHub Actions: expressions, fromJSON](https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#fromjson)
- [catalyst/catalyst-moodle-workflows#159: input not defined in the referenced workflow](https://github.com/catalyst/catalyst-moodle-workflows/issues/159)
Enter fullscreen mode Exit fullscreen mode

Top comments (0)