GitHub Actions environment variable not expanding in steps
A GitHub Actions environment variable not expanding in steps is almost never one problem. Three unrelated mechanisms produce the same empty string, and they are separated by when the value is supposed to exist: before the script file is written, while the process runs, or after the step has already ended.

What this error means
A step prints a sentence with a hole in it. The word you expected is simply absent, there is no error, the step is green, and the job carries on and does the wrong thing with an empty value. Nothing in the log names a variable, because nothing went wrong from the runner's point of view: it handed the shell a script and the shell ran it. If you are editing in VS Code you may also see a yellow squiggle under the expression that says the context access might be invalid, which is a separate signal from a separate program and is discussed below rather than treated as the cause.
Run echo "Deploying $VERSION"
echo "Deploying $VERSION"
shell: /usr/bin/bash -e {0}
DeployingFour moments, and which one your value missed
The runner does not have a single notion of "a variable". It has a template substitution that happens before anything runs, a process environment that it builds for each step, a file the step can append to, and a shell that does its own expansion inside the script. A value is empty when you read it from a moment earlier than the one that sets it.
The order below is not a convention, it is the order in which the runner's own code executes. ActionRunner builds the environment dictionary and creates the handler, the handler substitutes any expressions and writes the script to a temporary file, the process runs, and only then, in a finally block, does the runner call ProcessFiles to read whatever the step appended to the file named by GITHUB_ENV.
Read your own failing line against that order and the answer is usually immediate. If the text you expected was supposed to come from an expression, it was decided before the script existed. If it was supposed to come from an earlier step's shell, it never left that shell. If it was supposed to come from GITHUB_ENV inside the same step, the file had not been read yet.
| Where the value comes from | Moment it becomes readable | Readable in the same step |
|---|---|---|
An expression in ${{ }} | before the script file is written | yes, it is already text |
env: on the workflow, job or step | when the step environment is built | yes |
A shell assignment or export | inside that one process | yes, and nowhere else |
A line appended to $GITHUB_ENV | after the step process exits | no |
Common causes
The value was set in an earlier step's shell and never left it
Each run key becomes its own script file and its own process. An export or a plain assignment lives in that process and dies with it, so the next step starts from the environment the runner built, not the environment your script finished with. This is the commonest version by a wide margin, and it is the one that looks most like a bug because the assignment obviously worked when you tested it locally in one shell.
The value was appended to $GITHUB_ENV and read in the same step
Writing to the file is not the same as the runner having read it. The parse happens after the step process exits, so the variable arrives for the next step and not for the rest of this one. Splitting the step in two is usually the whole fix.
An expression was used where a shell variable was meant, or the reverse
${{ env.NAME }} is replaced with text before the script exists, so it can only carry values that were already known. $NAME is expanded by the shell while the script runs, so it can carry values the runner put in the process environment. Mixing them up produces an empty slot in one direction and, on Windows with the default PowerShell shell, a literal $NAME in the other.
The name is right but the scope is not
An env: map on a step is only on that step. An env: map on a job does not reach a called reusable workflow. In our experience this one surfaces after a refactor rather than during first authoring, because the workflow worked until the step moved.
How to fix it
Write the value to $GITHUB_ENV and read it in a later step
Append NAME=value to the file whose path is in the GITHUB_ENV variable, then read it as an ordinary shell variable or through the env context in any step after this one. Quote the variable when you use it, so a value containing spaces survives.
- id: version
run: |
v=$(node -p "require('./package.json').version")
echo "VERSION=$v" >> "$GITHUB_ENV"
- run: echo "Deploying $VERSION"Use a step output when only one later step needs the value
Outputs are scoped to the step that produced them and read through the steps context, which makes the dependency visible in the file instead of hiding it in a shared environment. Give the step an id, append to the file named by GITHUB_OUTPUT, and read steps.<id>.outputs.<name>.
- id: version
run: echo "value=1.2.3" >> "$GITHUB_OUTPUT"
- run: echo "Deploying ${{ steps.version.outputs.value }}"Keep the work in one step when the value is only needed there
- Move the command that computes the value and the command that uses it into the same
runblock. - Use a plain shell variable, not GITHUB_ENV, because the file will not be read until this step has ended.
- Remember that the block is one script under one shell, so a
cdor anexportearlier in it does apply later in it.
Do not chase the editor warning unless the run agrees with it
If the workflow runs correctly and the only complaint is the yellow squiggle, you are looking at the extension's view of a context it cannot see into. Declare the variable in an env: map if you want the warning gone and the value is static. If the value is only known at runtime, accept the warning, because the alternative is writing a workflow to satisfy a validator rather than a runner.
Why $GITHUB_ENV cannot work inside the step that writes it
This is the one people argue with, because the file is right there and the shell can read it back. The runner does not read it back. In ActionRunner the file command manager is initialized before the handler is created, the handler runs, and the call that parses the file sits in a finally block after the handler has returned. Until that call happens, nothing has copied your line into the environment dictionary or into the env context.
So a step that appends a line and then echoes the variable prints nothing, and a step that appends a line and then reads ${{ env.NAME }} prints nothing for a second reason on top of the first: the expression was substituted before the script was written, so it could not have seen a file that did not exist yet. The next step sees both.
The same timing explains a friendlier case. A composite action's steps are separate steps to the runner, so a value written to GITHUB_ENV in one of them is visible in the next one inside the same action.
- name: Compute
run: echo "VERSION=1.2.3" >> "$GITHUB_ENV"
- name: Use
run: echo "Deploying $VERSION"The editor warning that is not this error
VS Code's GitHub Actions extension validates expressions with actions/languageservices, and when an expression reads a key the validator cannot find it records a warning whose text is built as "Context access might be invalid: " followed by the key name. The severity in that code is the string "warning", not "error". GitHub does not send this message; your editor does.
For a workflow file, the extension builds the env context out of the env: maps it can see in the file itself: the step's, then the job's, then the workflow's. A variable that only ever exists because an earlier step appended it to GITHUB_ENV is in none of those maps, so the warning fires on a workflow that runs perfectly. The same code deliberately marks the env and matrix contexts incomplete when it is validating an action.yml rather than a workflow, precisely so that this warning does not fire there.
That makes the warning a poor diagnosis and a decent smoke alarm. It cannot tell you that a runtime variable is missing, and it will tell you a correct workflow is suspicious. If the warning is the only thing you have, you have not yet observed the failure.
Why there is no recorded run on this page
There is nothing to record. The failure this page is about produces an empty string, and an empty string in a log is indistinguishable from a value that was genuinely empty for a legitimate reason. A recorded Latchkey run would show a line reading "Deploying" with nothing after it, which is exactly what a correct workflow deploying a release named by an empty input would also show. The recording would be evidence of a blank, not evidence of a cause.
What decides the cause is the order of four operations inside the runner, and that order is readable in ActionRunner and in the script handler without running anything. The one thing a run would add is a timestamp, which is not the disputed part.
How to prevent it
- Treat every
runblock as its own process, and pass values between them deliberately. - Split the step that computes a value from the step that consumes it, so GITHUB_ENV has a boundary to cross.
- Prefer step outputs over GITHUB_ENV when exactly one later step needs the value.
- Quote shell variables in
runblocks so an empty value fails loudly rather than silently.
Frequently asked questions
Why does export in one step not work in the next step?
run block to its own script file and executes it, so anything the shell set lives and dies inside that one execution. The runner rebuilds the step environment from its own dictionary each time, and that dictionary only changes when you append to the file named by GITHUB_ENV or when an action sets a variable.Can I read a variable in the same step that writes it to GITHUB_ENV?
What is the difference between $NAME and ${{ env.NAME }} in a run step?
${{ env.NAME }} is replaced with literal text by the runner before the script file is written, so the value has to be known at that moment. $NAME is expanded by the shell while the script is running, so it can pick up anything in the process environment. On Windows the default shell is PowerShell, where $NAME is PowerShell syntax rather than bash syntax, which is why copied examples often print nothing there.What does "Context access might be invalid" mean in VS Code?
env: maps written in the file, so a variable created at runtime through GITHUB_ENV always triggers it. It is not an error from GitHub and it does not stop the run.Related guides
References
- GitHub Actions: store information in variables
- actions/runner: ActionRunner.cs, ProcessFiles after the handler returns
- actions/runner: FileCommandManager.cs, SetEnvFileCommand
- actions/languageservices: the expression validation evaluator
- GitHub Actions: workflow commands, setting an environment variable
- GitHub Actions documentation