Fix: GitHub Actions Environment Variables Not Available Between Steps
Part of: Docker, DevOps & Infrastructure
Quick Answer
How to fix GitHub Actions env vars and outputs not persisting between steps — GITHUB_ENV, GITHUB_OUTPUT, job outputs, and why echo >> $GITHUB_ENV is required.
The Error
An environment variable set in one step is undefined in the next:
steps:
- name: Set version
run: export VERSION=1.2.3
- name: Use version
run: echo "Version is $VERSION"
# Output: "Version is " ← Empty — export doesn't persist between stepsOr a step output isn’t available in a later step:
steps:
- name: Get commit hash
id: commit
run: echo "::set-output name=hash::$(git rev-parse --short HEAD)"
- name: Use hash
run: echo "Hash is ${{ steps.commit.outputs.hash }}"
# Output: "Hash is " ← Empty — ::set-output is deprecatedOr a job output isn’t accessible in a dependent job:
jobs:
build:
outputs:
version: ${{ steps.get-version.outputs.version }}
deploy:
needs: build
steps:
- run: echo "${{ needs.build.outputs.version }}"
# Output: "" ← Empty — output not wired correctlyWhy This Happens
Each step in a GitHub Actions workflow runs in its own shell process. Shell environment variables (set with export or VAR=value) are not inherited by subsequent steps — they only exist for the duration of the step that set them. This is by design: each step is conceptually a fresh shell invocation, so the runner can mix bash, PowerShell, Python, and JavaScript steps within a single job without polluting state across boundaries. The price is that any variable you want to share has to go through one of three explicit mechanisms: $GITHUB_ENV (env file), $GITHUB_OUTPUT (step outputs), or job outputs: blocks (cross-job).
The second source of confusion is the workflow-command syntax. GitHub Actions used to support ::set-output name=x::value and ::set-env name=X::value as in-band commands echoed to stdout. Both were deprecated for security reasons (they made it possible for command output to inject environment variables) and replaced by file-based equivalents. Code copied from a 2021 tutorial often uses the old syntax and silently produces empty outputs on modern runners.
Concrete root causes:
- Using
exportinstead ofGITHUB_ENV—exportsets a variable only for the current shell process. To pass variables to subsequent steps, write to the$GITHUB_ENVfile. - Using deprecated
::set-output— the::set-outputworkflow command was deprecated in 2022 and disabled in 2023. Use$GITHUB_OUTPUTinstead. - Step output not referenced correctly — outputs must be referenced with
${{ steps.<step-id>.outputs.<name> }}and the step must have anid. - Job output not declared in
outputsblock — job-level outputs must be explicitly declared in the job’soutputsblock to be available to dependent jobs. - Multi-line values not escaped — multi-line env var values written to
GITHUB_ENVrequire the heredoc syntax. Writing a newline character directly corrupts the file format. - Composite action outputs not declared — outputs from inside a composite action must be declared in the action’s
outputs:block to be visible to the calling workflow.
Version History That Changes the Failure Mode
The mechanism for sharing data between steps has changed twice in the runner’s history, and tutorials, Stack Overflow answers, and copy-pasted workflows from 2020–2022 are full of patterns that no longer work. Knowing the timeline tells you immediately whether a snippet you found online is still valid.
::set-env(original) — until July 2020, you set environment variables by echoing a workflow command:echo "::set-env name=VERSION::1.2.3". This was widely used and is documented in countless tutorials.GITHUB_ENVfile (Jul 2020) — GitHub Security Lab published an advisory demonstrating that::set-envcould be exploited to inject environment variables from logged output. The fix replaced::set-envwith writing to the$GITHUB_ENVfile.::set-envwas disabled on November 16, 2020.GITHUB_PATHfile (Jul 2020) — same change applied to::add-path, which was replaced by writing to$GITHUB_PATH.- Reusable workflows (Nov 2021) — GitHub introduced
workflow_callreusable workflows. These have their ownoutputs:declaration and pass data between jobs differently from composite actions. Aneeds.<job>.outputs.<name>reference works across reusable workflow boundaries but only if the called workflow exposes the output explicitly. ::set-outputdeprecation (Oct 2022) — GitHub announced deprecation of::set-outputand::save-statein favor of$GITHUB_OUTPUTand$GITHUB_STATE. The deprecation notice landed in runner v2.297.0.::set-outputdisabled (May/Jun 2023) — the older syntax was disabled on runners after a transition period. Workflows still using::set-outputstarted producing empty outputs with a warning in the log.- Composite actions parity (2022–2023) — composite actions gained support for
if:,continue-on-error, and proper output declarations. Older composite actions written without theoutputs:block at the top level cannot expose outputs to the caller. - runner v2.310+ (late 2023) improved masking behavior for outputs containing secret-like strings.
If you see an empty value where you expected a step output, the first check is the runner version (runner.os and the runner image’s IMAGES_AGENT_VERSION) combined with the syntax you used. Anything written with ::set-output after mid-2023 returns empty silently. Anything written with >> $GITHUB_OUTPUT and referenced through ${{ steps.<id>.outputs.<name> }} works as long as the step has an id.
Fix 1: Use GITHUB_ENV for Environment Variables
To share environment variables between steps, write to the $GITHUB_ENV file using the NAME=VALUE format:
steps:
- name: Set version
run: echo "VERSION=1.2.3" >> $GITHUB_ENV
- name: Use version
run: echo "Version is $VERSION"
# Output: "Version is 1.2.3" ✓Set multiple variables:
steps:
- name: Set build variables
run: |
echo "VERSION=1.2.3" >> $GITHUB_ENV
echo "BUILD_DATE=$(date -u +%Y-%m-%d)" >> $GITHUB_ENV
echo "COMMIT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: Use variables
run: |
echo "Version: $VERSION"
echo "Date: $BUILD_DATE"
echo "SHA: $COMMIT_SHA"Set a variable from command output:
steps:
- name: Get package version
run: echo "PKG_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV
- name: Print version
run: echo "Package version: $PKG_VERSION"Set a multi-line variable (requires heredoc syntax):
steps:
- name: Set multi-line variable
run: |
EOF_MARKER=$(openssl rand -hex 8)
echo "CHANGELOG<<$EOF_MARKER" >> $GITHUB_ENV
echo "Line 1 of changelog" >> $GITHUB_ENV
echo "Line 2 of changelog" >> $GITHUB_ENV
echo "$EOF_MARKER" >> $GITHUB_ENV
- name: Use multi-line variable
run: echo "$CHANGELOG"Common Mistake: Writing
export VAR=valueworks within the same step’s shell but is completely invisible to subsequent steps. Always use>> $GITHUB_ENVfor cross-step variables.
Fix 2: Use GITHUB_OUTPUT for Step Outputs
The ::set-output syntax was disabled in May 2023. Use $GITHUB_OUTPUT instead:
# Old way (deprecated and disabled)
- name: Get hash
id: commit
run: echo "::set-output name=hash::$(git rev-parse --short HEAD)"
# New way — write to $GITHUB_OUTPUT
- name: Get hash
id: commit
run: echo "hash=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Use hash
run: echo "Commit hash: ${{ steps.commit.outputs.hash }}"Step outputs require an id on the step:
steps:
- name: This step has no id
run: echo "result=hello" >> $GITHUB_OUTPUT
# Cannot reference this output — no id to reference it by
- name: This step has an id
id: my-step # Required for output referencing
run: echo "result=hello" >> $GITHUB_OUTPUT
- name: Reference the output
run: echo "${{ steps.my-step.outputs.result }}"
# Output: hello ✓Multiple outputs from one step:
- name: Build info
id: build
run: |
echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT
echo "timestamp=$(date -u +%Y%m%dT%H%M%SZ)" >> $GITHUB_OUTPUT
echo "sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Use build info
run: |
echo "Version: ${{ steps.build.outputs.version }}"
echo "Timestamp: ${{ steps.build.outputs.timestamp }}"
echo "SHA: ${{ steps.build.outputs.sha }}"Fix 3: Fix Job-Level Outputs
To pass data between jobs (not just steps), you must declare job outputs explicitly and wire them to step outputs:
jobs:
build:
runs-on: ubuntu-latest
# Declare job outputs — maps job output names to step output expressions
outputs:
version: ${{ steps.get-version.outputs.version }}
artifact-name: ${{ steps.set-artifact.outputs.name }}
steps:
- uses: actions/checkout@v4
- name: Get version
id: get-version # id required — referenced in outputs above
run: echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT
- name: Set artifact name
id: set-artifact
run: echo "name=myapp-${{ github.sha }}" >> $GITHUB_OUTPUT
deploy:
runs-on: ubuntu-latest
needs: build # Must declare dependency to access outputs
steps:
- name: Deploy
run: |
echo "Deploying version: ${{ needs.build.outputs.version }}"
echo "Artifact: ${{ needs.build.outputs.artifact-name }}"Common mistake — forgetting needs:
# Wrong — deploy doesn't declare dependency on build
deploy:
runs-on: ubuntu-latest
steps:
- run: echo "${{ needs.build.outputs.version }}"
# Output: "" — needs.build is not available without needs: build# Correct
deploy:
runs-on: ubuntu-latest
needs: build # Declares dependency AND makes outputs available
steps:
- run: echo "${{ needs.build.outputs.version }}"
# Output: "1.2.3" ✓Fix 4: Use env: for Step-Scoped Variables
For variables used only within a single step or job, use the env: key — it’s cleaner than writing to $GITHUB_ENV:
# Job-level env — available to all steps in the job
jobs:
build:
runs-on: ubuntu-latest
env:
NODE_ENV: production
API_URL: https://api.example.com
steps:
- name: Build
run: echo "Building for $NODE_ENV at $API_URL"
# Step-level env — overrides job-level for this step only
- name: Test
env:
NODE_ENV: test # Overrides job-level NODE_ENV for this step
run: echo "Testing in $NODE_ENV" # Output: "Testing in test"Workflow-level env (available to all jobs):
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: docker build -t $REGISTRY/$IMAGE_NAME .Fix 5: Pass Secrets and Env Vars Securely
Secrets set in GitHub repository settings are not automatically available as env vars. You must explicitly map them:
steps:
- name: Deploy
env:
# Map GitHub secret to an environment variable for this step
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: aws s3 sync ./dist s3://my-bucketNever write secrets to $GITHUB_ENV — values written to $GITHUB_ENV appear in logs and are accessible to subsequent steps. GitHub masks known secret values in logs, but avoid unnecessary exposure:
# Avoid — passes secret through GITHUB_ENV unnecessarily
- run: echo "MY_SECRET=${{ secrets.MY_SECRET }}" >> $GITHUB_ENV
# Better — pass secret directly to the step that needs it via env:
- name: Use secret
env:
MY_SECRET: ${{ secrets.MY_SECRET }}
run: ./deploy.sh # Script reads $MY_SECRET from envFix 6: Debug Variable Values
When values are empty or unexpected, add debug steps to inspect the state:
steps:
- name: Debug environment
run: |
echo "All env vars:"
env | sort | grep -v SECRET | grep -v TOKEN # Filter sensitive values
echo "GITHUB_ENV contents:"
cat $GITHUB_ENV
echo "GITHUB_OUTPUT contents:"
cat $GITHUB_OUTPUT || echo "(empty)"
- name: Debug specific variable
run: |
echo "VERSION='$VERSION'"
echo "Empty check: ${VERSION:-(not set)}"Enable debug logging for the entire workflow:
In your repository, go to Settings → Secrets → Add a secret named ACTIONS_STEP_DEBUG with value true. This enables verbose logging for all steps.
Or use ACTIONS_RUNNER_DEBUG=true for runner-level debugging.
Print the GitHub context to understand what’s available:
- name: Dump contexts
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
STEPS_CONTEXT: ${{ toJson(steps) }}
NEEDS_CONTEXT: ${{ toJson(needs) }}
run: |
echo "$GITHUB_CONTEXT"
echo "$STEPS_CONTEXT"
echo "$NEEDS_CONTEXT"Still Not Working?
Check that composite actions declare their outputs. A composite action that writes to $GITHUB_OUTPUT internally exposes nothing to the caller unless the action’s action.yml declares the outputs at the top level:
# action.yml
name: 'Get Version'
outputs:
version:
description: 'The version string'
value: ${{ steps.read.outputs.version }}
runs:
using: 'composite'
steps:
- id: read
shell: bash
run: echo "version=1.2.3" >> $GITHUB_OUTPUTWithout the top-level outputs: block, ${{ steps.my-composite.outputs.version }} in the caller returns empty.
Check reusable workflow outputs separately from job outputs. A reusable workflow called via workflow_call exposes outputs at the workflow level, not the job level. The calling workflow references them as ${{ jobs.<job-id>.outputs.<name> }} where <job-id> is the name of the job that called the reusable workflow:
jobs:
call-reusable:
uses: ./.github/workflows/reusable.yml
use-output:
needs: call-reusable
runs-on: ubuntu-latest
steps:
- run: echo "${{ needs.call-reusable.outputs.version }}"Check if the variable name contains special characters. Variable names in $GITHUB_ENV must be alphanumeric with underscores. Hyphens and dots are not allowed.
Check for Windows line endings. If your runner is Windows, use >> with PowerShell syntax:
# Windows runner
- name: Set variable (Windows)
run: echo "VERSION=1.2.3" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
shell: pwsh
# Or use bash on Windows runner
- name: Set variable (bash on Windows)
run: echo "VERSION=1.2.3" >> $GITHUB_ENV
shell: bashVerify the step id matches the reference exactly:
# id defined as "get-version" (with hyphen)
- name: Get version
id: get-version
run: echo "version=1.2.3" >> $GITHUB_OUTPUT
# Must reference with the same id — hyphens in expressions need no escaping
- run: echo "${{ steps.get-version.outputs.version }}"
# ^^^^^^^^^^^^ Must match the id exactlyFor related GitHub Actions issues, see Fix: GitHub Actions Cache Not Working, Fix: GitHub Actions if Condition Not Working, Fix: GitHub Actions Secret Not Available, and Fix: GitHub Actions Reusable Workflow.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: GitHub Actions Artifacts Not Working — Upload Fails, Download Empty, or Artifact Not Found
How to fix GitHub Actions artifact issues — upload-artifact path patterns, download-artifact across jobs, retention days, artifact name conflicts, and the v3 to v4 migration.
Fix: GitHub Actions Secret Not Available — Environment Variable Empty in Workflow
How to fix GitHub Actions secrets that appear empty or undefined in workflows — secret scope, fork PR restrictions, environment protection rules, secret names, and OIDC alternatives.
Fix: GitHub Actions Matrix Strategy Not Working — Jobs Not Running or Failing
How to fix GitHub Actions matrix strategy issues — matrix expansion, include/exclude patterns, failing fast, matrix variable access, and dependent jobs with matrix outputs.
Fix: GitHub Actions Docker Build and Push Failing
How to fix GitHub Actions Docker build and push errors — registry authentication, image tagging, layer caching, multi-platform builds, and GHCR vs Docker Hub setup.