GitHub Actions composite action inputs not passed to run steps
GitHub Actions composite action inputs not passed is almost always the same mistake, and it is a mistake about the runner rather than about your YAML: a composite action never receives its inputs as environment variables. The runner has a routine that turns inputs into prefixed environment variables, and the composite handler is the one handler that does not call it.
# inside a composite action
runs:
using: composite
steps:
- shell: bash
run: echo "token is $INPUT_TOKEN" # empty, always01Symptom
What this error means
The caller passes values under with, the action declares matching inputs, and inside the action the value is empty. Nothing is red. A shell variable that was never set expands to an empty string, so a command built around it either runs with a missing argument or fails much later with a message about the argument rather than about the input. If the same action code is copied out into an ordinary workflow step it usually works, which is the detail that sends people looking at the caller.
Three handlers, and only two of them set environment variables
The runner has a separate handler for each kind of action, and the difference between them is the whole of this page. A shared base class provides a routine that walks the declared inputs and adds one environment variable per input, named by upper casing the input name, replacing spaces with underscores and prefixing it. That is where the familiar prefixed variables come from.
Two handlers call it. The one for JavaScript actions calls it, which is why an action written in JavaScript can read its inputs from the process environment as well as through the toolkit. The one for container actions calls it, which is how a Docker action receives them at all, since a container has no other channel.
The composite handler does not call it, and contains no reference to those variables anywhere. What it does instead is build a dictionary of the inputs and install it as the inputs entry in the expression values for every step it runs. So inside a composite action the inputs exist as an expression context and nowhere else. There is no environment variable to read, not an empty one, not a missing one, simply nothing of that kind was created.
This is why the failure is so quiet. A shell asked for a variable that was never set does not complain, it substitutes an empty string, and the rest of the command carries on around the hole.
| Action kind | Inputs as environment variables | Inputs as an expression context |
|---|---|---|
| JavaScript action | yes, one prefixed variable per input | not applicable |
| Container action | yes, one prefixed variable per input | not applicable |
| Composite action | no, the handler never creates them | yes, through the inputs context |
| An ordinary workflow step | no, there are no action inputs | not applicable |
02Cause
Common causes

The script reads a prefixed environment variable
The single commonest cause. The habit comes from JavaScript actions, where those variables really are set, and from documentation about the toolkit. Inside a composite action the handler never creates them, so the read yields an empty string silently.
The input is not declared in the action metadata
A value the caller passes but the action does not list is dropped, with a warning naming the key and listing the accepted ones. The step continues, which is why it is easy to miss in a long log.
A declared default filled in for a value that never arrived
The input resolves, just not to what the caller thought they sent. This looks like a wrong value rather than an empty one, which sends people to the wrong half of the problem.
The expression was written for a different context
Inside a composite action the value is in the inputs context. Expressions written against a workflow context copied from a calling workflow refer to something that does not exist there, and resolve to nothing.
03Fix
How to fix it
Reference the input through the inputs context
Replace every prefixed environment variable read with an expression against the inputs context. This is the channel the composite handler provides and the only one it provides.
runs:
using: composite
steps:
- shell: bash
run: echo "using ${{ inputs.token }}"Map to a step environment variable for untrusted values
An expression is substituted into the script text before the shell parses it. Mapping it to a step level environment variable and quoting the reference keeps the value out of the script body and out of the shell parser.
- shell: bash
env:
MESSAGE: ${{ inputs.message }}
run: printf '%s\n' "$MESSAGE"Declare every input the caller passes
- Open the action metadata and list the declared inputs.
- Compare that list with the keys the caller passes under
with. - Add any missing declaration, with a description and a default where one makes sense.
- Search the job log for the warning about accepted inputs to confirm nothing else is being dropped.
Check defaults before you check the caller
When the value is wrong rather than empty, read the declared default first. A default silently standing in for a value the caller believed it sent is a five second check and it is right more often than the caller is wrong.
The one channel that works, and how to use it safely
Inside a composite action, a declared input is read through the inputs context in an expression. That is the mechanism the handler provides and it is the only one. An expression is substituted into the step before the shell sees it, so the value arrives as text in the script rather than as a variable the shell looks up.
That substitution is worth thinking about for a second, because it has a consequence. A value containing quotes, newlines or shell metacharacters is pasted into the script literally, and the shell then parses the result. For anything that might contain user controlled text, map the expression to a step level environment variable and read that variable in the script instead, quoted. The value then travels through the environment rather than through the text of the script, and the shell never parses it.
The same applies to a value you would rather not have appear in a generated script at all. Mapping it to an environment variable on the step keeps it out of the script body.
runs:
using: composite
steps:
- shell: bash
run: echo "deploying ${{ inputs.target }}"
# safer for anything that might contain metacharacters
- shell: bash
env:
TARGET: ${{ inputs.target }}
run: printf '%s\n' "$TARGET"Two other reasons a composite input is empty
The first is that the input was never declared. An input that the caller passes but the action does not list is not available anywhere, and the runner says so with a warning rather than an error, so the step keeps going with the value missing. Our page on that warning covers the name comparison it comes from.
The second is a default that quietly filled the gap. An input declared with a default and not passed by the caller resolves to the default, which is correct behavior and looks identical to a value that failed to arrive. If a value appears to be wrong rather than empty, check the default before checking the caller.
A third thing that is not a cause but gets blamed: a composite step must declare its shell. That requirement produces a loud failure when the action metadata is loaded, naming the missing property and the line, so it cannot be confused with an empty input.
Why there is no recorded run on this page
The failure here produces an empty string and no message, so a recorded run would be a log of a command that ran with a missing argument. It would show the downstream symptom rather than the cause, which is the opposite of what a reader needs, and it would show our action rather than theirs.
The claim this page rests on is structural and can be read directly: the routine that creates the prefixed variables lives in the shared handler base, two handlers call it, and the composite handler neither calls it nor mentions those variables. That is a statement about which code paths exist, and it is checkable in a few minutes against the source, which makes it worth more than a screenshot.
There is no repair available either. A runner cannot know that an empty variable was meant to hold an input, because an empty variable is also a perfectly normal thing for a script to have.
04Prevent
How to prevent it
- Treat prefixed input environment variables as a JavaScript and container action feature only.
- Declare every input in the action metadata, including ones with defaults.
- Route any input that could contain arbitrary text through a step environment variable.
- Echo the inputs once at the top of a composite action while you are developing it.
Frequently asked questions
Does a composite action set INPUT_ environment variables?
How do I read an input inside a composite action?
Why does the same code work in a workflow step but not in my action?
Why is there no error when an input is empty?
Related guides
References
- actions/runner: Handler.cs, the routine that creates one environment variable per input
- actions/runner: CompositeActionHandler.cs, which installs the inputs context instead
- GitHub Actions: metadata syntax for actions, inputs and composite runs
- GitHub Actions: create a composite action
- GitHub Actions documentation