Skip to content

Fix: GitHub Actions Environment Variables Not Available Between Steps

FixDevs · (Updated: )

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 steps

Or 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 deprecated

Or 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 correctly

Why 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 export instead of GITHUB_ENVexport sets a variable only for the current shell process. To pass variables to subsequent steps, write to the $GITHUB_ENV file.
  • Using deprecated ::set-output — the ::set-output workflow command was deprecated in 2022 and disabled in 2023. Use $GITHUB_OUTPUT instead.
  • Step output not referenced correctly — outputs must be referenced with ${{ steps.<step-id>.outputs.<name> }} and the step must have an id.
  • Job output not declared in outputs block — job-level outputs must be explicitly declared in the job’s outputs block to be available to dependent jobs.
  • Multi-line values not escaped — multi-line env var values written to GITHUB_ENV require 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_ENV file (Jul 2020) — GitHub Security Lab published an advisory demonstrating that ::set-env could be exploited to inject environment variables from logged output. The fix replaced ::set-env with writing to the $GITHUB_ENV file. ::set-env was disabled on November 16, 2020.
  • GITHUB_PATH file (Jul 2020) — same change applied to ::add-path, which was replaced by writing to $GITHUB_PATH.
  • Reusable workflows (Nov 2021) — GitHub introduced workflow_call reusable workflows. These have their own outputs: declaration and pass data between jobs differently from composite actions. A needs.<job>.outputs.<name> reference works across reusable workflow boundaries but only if the called workflow exposes the output explicitly.
  • ::set-output deprecation (Oct 2022) — GitHub announced deprecation of ::set-output and ::save-state in favor of $GITHUB_OUTPUT and $GITHUB_STATE. The deprecation notice landed in runner v2.297.0.
  • ::set-output disabled (May/Jun 2023) — the older syntax was disabled on runners after a transition period. Workflows still using ::set-output started 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 the outputs: 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=value works within the same step’s shell but is completely invisible to subsequent steps. Always use >> $GITHUB_ENV for 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-bucket

Never 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 env

Fix 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_OUTPUT

Without 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: bash

Verify 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 exactly

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

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles