# GitHub Actions Environment Variables and Their Scope

> Default GitHub Actions environment variables, the precedence order between workflow, job and step env, and why a variable set in one step is empty in the next.

Source: https://latchkey.dev/learn/github-actions/github-actions-environment-variables-reference  
Updated: 2026-08-20

A variable exported in one `run` step is gone in the next, because each step is a separate shell. Persisting one means writing to `$GITHUB_ENV`, not exporting it.

Two rules explain nearly every environment-variable surprise in Actions: each `run` step is its own shell process, so nothing exported survives to the next step, and `env` values are evaluated by the runner before the shell sees them.

That second rule is why `${{ env.FOO }}` and `$FOO` can produce different results in the same line.

## Default variables worth knowing

| Variable | Value |
| --- | --- |
| `CI` | Always `true`. Changes behaviour in many tools |
| `GITHUB_WORKSPACE` | Checkout directory |
| `GITHUB_SHA` | Commit SHA that triggered the run |
| `GITHUB_REF` / `GITHUB_REF_NAME` | Full ref / short branch or tag name |
| `GITHUB_EVENT_NAME` | The trigger event |
| `GITHUB_RUN_ID` / `GITHUB_RUN_ATTEMPT` | Run identity; attempt > 1 means a re-run |
| `GITHUB_ENV` | File to append persistent variables to |
| `GITHUB_OUTPUT` | File to append step outputs to |
| `GITHUB_STEP_SUMMARY` | Markdown file rendered on the run summary |
| `RUNNER_OS` / `RUNNER_ARCH` | Runner platform |
| `RUNNER_TEMP` | Temp directory cleaned between jobs |

> `GITHUB_REF_NAME` is what you want for the branch name. `git rev-parse --abbrev-ref HEAD` returns the literal string `HEAD` on the detached checkout that `actions/checkout` produces.

## Persisting a value between steps

```.github/workflows/ci.yml
# WRONG: each run step is a separate shell
- run: export MY_VAR=hello
- run: echo "$MY_VAR"          # empty

# RIGHT: append to $GITHUB_ENV
- run: echo "MY_VAR=hello" >> "$GITHUB_ENV"
- run: echo "$MY_VAR"          # hello

# step OUTPUT rather than an env var
- id: build
  run: echo "version=1.2.3" >> "$GITHUB_OUTPUT"
- run: echo "${{ steps.build.outputs.version }}"

# multiline needs a delimiter
- run: |
    {
      echo 'NOTES<<EOF'
      cat CHANGELOG.md
      echo EOF
    } >> "$GITHUB_ENV"
```

> A variable written to `$GITHUB_ENV` is available in **subsequent** steps, never in the step that wrote it. That off-by-one catches everyone once.

## Precedence and evaluation order

- Step `env` overrides job `env`, which overrides workflow `env`.
- `${{ env.FOO }}` is substituted by the runner before the shell runs; `$FOO` is expanded by the shell at execution time.
- That difference matters for anything set during the same step, and for values containing shell metacharacters.
- Secrets are masked in logs, but a secret written to `$GITHUB_ENV` and later echoed deliberately can still be exposed through transformations.
- `vars` are configuration variables from the repository, organisation, or environment, and are not the same as `env`.

## Writing to the job summary

```.github/workflows/ci.yml
- run: |
    {
      echo "## Test results"
      echo ""
      echo "| Suite | Result |"
      echo "|-------|--------|"
      echo "| unit  | pass   |"
    } >> "$GITHUB_STEP_SUMMARY"
```

> Job summaries render Markdown on the run page and are the cheapest way to make a failure diagnosable without opening the log.

## Diagnose it: print the context before you change anything

Most workflow-expression bugs are not syntax errors, they are an expression reading something that is empty. GitHub resolves a missing property to an empty string instead of failing the run, so a wrong reference looks like a logic bug rather than a mistake. Dump the contexts first and you will usually see the answer immediately.

```.github/workflows/ci.yml
- name: Dump contexts
  run: |
    echo '--- github ---'   ; echo '${{ toJSON(github) }}'
    echo '--- needs ---'    ; echo '${{ toJSON(needs) }}'
    echo '--- steps ---'    ; echo '${{ toJSON(steps) }}'
    echo '--- matrix ---'   ; echo '${{ toJSON(matrix) }}'
    echo '--- inputs ---'   ; echo '${{ toJSON(inputs) }}'
```

> An empty `{}` or a blank line is the finding. It means the context is not populated at that point, which is a different problem from the value being wrong, and it needs a different fix.

## Check the context is allowed where you used it

Contexts are not available everywhere. The same expression can be valid in a step `if` and invalid in a job `if`, which is why an expression that works in one workflow fails when moved.

| Where you wrote it | Contexts available there |
| --- | --- |
| `run-name` | `github`, `inputs`, `vars` |
| `concurrency` | `github`, `inputs`, `vars` |
| Top-level `env` | `github`, `secrets`, `inputs`, `vars` |
| `jobs.<id>.if` | `github`, `needs`, `vars`, `inputs` |
| `jobs.<id>.steps.if` | `github`, `needs`, `strategy`, `matrix`, `job`, `runner`, `env`, `vars`, `steps`, `inputs` |
| `jobs.<id>.outputs` | Full access, including `secrets` |
| Reusable workflow `outputs` | `github`, `jobs`, `vars`, `inputs` |

> The most common trap in this table: `steps` and `matrix` are available in a **step** `if` but not in a **job** `if`. Moving a condition up a level silently breaks it.

## Catch it before it reaches CI

Every failure in this cluster is statically detectable. `actionlint` parses workflow expressions, checks context availability against the same rules above, and validates `needs` references, so these bugs never need to cost you a run.

```Terminal
# one-off
docker run --rm -v "$(pwd):/repo" --workdir /repo rhysd/actionlint:latest -color

# as a job, before anything expensive runs
- uses: actions/checkout@v4
- run: |
    bash <(curl -s https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)
    ./actionlint -color
```

## FAQ

### Why is my environment variable empty in the next step?

Each `run` step is a separate shell process, so an `export` does not survive. Append to `$GITHUB_ENV` instead, and note the value is available only in subsequent steps, not the one that wrote it.

### What is the difference between GITHUB_ENV and GITHUB_OUTPUT?

`$GITHUB_ENV` sets an environment variable for later steps in the same job. `$GITHUB_OUTPUT` sets a named output on a step, read as `steps.<id>.outputs.<name>`, and can be surfaced as a job output for other jobs.

### How do I get the branch name in GitHub Actions?

Use `GITHUB_REF_NAME`. Do not use `git rev-parse --abbrev-ref HEAD`, which returns the literal string `HEAD` because `actions/checkout` produces a detached checkout.

### Why does ${{ env.FOO }} behave differently from $FOO?

`${{ env.FOO }}` is substituted by the runner before the shell starts; `$FOO` is expanded by the shell during execution. A value set earlier in the same step is visible to the second form and not the first.

---

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
