Skip to content
Latchkey

GitHub Actions Environment Variables and Their Scope

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

VariableValue
CIAlways true. Changes behaviour in many tools
GITHUB_WORKSPACECheckout directory
GITHUB_SHACommit SHA that triggered the run
GITHUB_REF / GITHUB_REF_NAMEFull ref / short branch or tag name
GITHUB_EVENT_NAMEThe trigger event
GITHUB_RUN_ID / GITHUB_RUN_ATTEMPTRun identity; attempt > 1 means a re-run
GITHUB_ENVFile to append persistent variables to
GITHUB_OUTPUTFile to append step outputs to
GITHUB_STEP_SUMMARYMarkdown file rendered on the run summary
RUNNER_OS / RUNNER_ARCHRunner platform
RUNNER_TEMPTemp directory cleaned between jobs

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"

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"

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) }}'

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 itContexts available there
run-namegithub, inputs, vars
concurrencygithub, inputs, vars
Top-level envgithub, secrets, inputs, vars
jobs.<id>.ifgithub, needs, vars, inputs
jobs.<id>.steps.ifgithub, needs, strategy, matrix, job, runner, env, vars, steps, inputs
jobs.<id>.outputsFull access, including secrets
Reusable workflow outputsgithub, jobs, vars, inputs

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

Frequently asked questions

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.

Related guides

References

Run this faster and cheaper on Latchkey managed runners - self-healing included. Start free → 30-day trial · No credit card