# GitHub Actions environment variable not expanding in steps

> A GitHub Actions environment variable not expanding in steps has three separate causes, each with its own moment. Find which of the three you have.

Source: https://latchkey.dev/learn/github-actions/gha-env-var-not-expanding  
Updated: 2026-09-20

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.

```Reconstructed step log; the shell line is the runner's own format string
Run echo "Deploying $VERSION"
  echo "Deploying $VERSION"
  shell: /usr/bin/bash -e {0}
Deploying
```

## 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.

```.github/workflows/release.yml (illustrative)
- 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>`.

```.github/workflows/release.yml (illustrative)
- 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

1. Move the command that computes the value and the command that uses it into the same `run` block.
2. Use a plain shell variable, not GITHUB_ENV, because the file will not be read until this step has ended.
3. Remember that the block is one script under one shell, so a `cd` or an `export` earlier 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.

## How to prevent it

- Treat every `run` block 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 `run` blocks so an empty value fails loudly rather than silently.

## Four 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 |

> The shell line in the log excerpt above is worth reading on its own. On Linux with no `shell:` key, the runner picks the argument format registered for `sh`, which is `-e {0}`, while resolving the binary to bash if bash is present. That is why the printed line names bash but carries the sh options.

## 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.

```.github/workflows/release.yml (illustrative)
- 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.

## FAQ

### Why does export in one step not work in the next step?

Because the next step is a different process. The runner writes each `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?

No. The runner parses that file after the step's process has exited, in a finally block around the handler call, so nothing has copied the value into the environment or into the env context while your script is still running. Inside the same script you can use a plain shell variable; across steps you need the file.

### 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?

It is a warning from the GitHub Actions extension, produced by actions/languageservices when an expression reads a key the validator cannot see. For workflows the validator builds the env context only from the `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.

## References

- [GitHub Actions: store information in variables](https://docs.github.com/en/actions/reference/workflows-and-actions/variables)
- [actions/runner: ActionRunner.cs, ProcessFiles after the handler returns](https://github.com/actions/runner/blob/main/src/Runner.Worker/ActionRunner.cs)
- [actions/runner: FileCommandManager.cs, SetEnvFileCommand](https://github.com/actions/runner/blob/main/src/Runner.Worker/FileCommandManager.cs)
- [actions/languageservices: the expression validation evaluator](https://github.com/actions/languageservices/blob/main/languageservice/src/expression-validation/evaluator.ts)
- [GitHub Actions: workflow commands, setting an environment variable](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands)

---

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
