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
| 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 |
Persisting a value between steps
# 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
envoverrides jobenv, which overrides workflowenv. ${{ env.FOO }}is substituted by the runner before the shell runs;$FOOis 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_ENVand later echoed deliberately can still be exposed through transformations. varsare configuration variables from the repository, organisation, or environment, and are not the same asenv.
Writing to the job summary
- 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.
- 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 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 |
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.
# 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 -colorFrequently asked questions
Why is my environment variable empty in the next step?
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?
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.