3.3 Environment Variables & Workflow Commands

Key Takeaways

  • Environment variables follow a strict hierarchical precedence: step env overrides job env, which overrides workflow env, which overrides default runner environment variables.
  • Environment variables are made persistent across subsequent steps by appending key-value strings to the runner environment file via echo 'KEY=val' >> '$GITHUB_ENV'.
  • Multi-line environment variables must be written to $GITHUB_ENV using a unique delimiter string (EOF heredoc syntax) to prevent corruption or truncation.
  • Step output parameters are exported using echo 'name=val' >> '$GITHUB_OUTPUT' and referenced in later steps via ${{ steps.<step_id>.outputs.name }}.
  • Special workflow commands enable dynamic secret masking (::add-mask::), path modification ($GITHUB_PATH), and rich Markdown job summaries ($GITHUB_STEP_SUMMARY).
Last updated: August 2026

Environment Variables & Workflow Commands

Managing data flow and runner state during a workflow run requires a solid grasp of environment variable scoping, precedence hierarchies, and workflow commands. In GitHub Actions, runners communicate with the execution engine through special file-based interfaces ($GITHUB_ENV, $GITHUB_OUTPUT, $GITHUB_PATH, $GITHUB_STEP_SUMMARY) and stdout logging commands.


1. Environment Variable Precedence Hierarchy

Environment variables can be declared at multiple levels in a workflow, as well as provided natively by GitHub and the runner environment. When variables share the same name, GitHub Actions resolves them using a strict precedence hierarchy.

+-----------------------------------------------------------------------------+
|                   ENVIRONMENT VARIABLE PRECEDENCE HIERARCHY                 |
|                                                                             |
|   [HIGHEST PRECEDENCE]                                                      |
|                                                                             |
|   1. Step Level        `steps[*].env`                                       |
|            |                                                                |
|            v                                                                |
|   2. Job Level         `jobs.<job_id>.env`                                  |
|            |                                                                |
|            v                                                                |
|   3. Workflow Level    `env` (top-level)                                    |
|            |                                                                |
|            v                                                                |
|   4. Default Variables `GITHUB_*`, `RUNNER_*` (Set by execution engine)    |
|            |                                                                |
|            v                                                                |
|   5. Configuration     `vars.*` (Repository / Org Settings)                |
|                                                                             |
|   [LOWEST PRECEDENCE]                                                       |
+-----------------------------------------------------------------------------+

Precedence Comparison Table

Level / SourceYAML / Engine ScopeExample DeclarationScope of Visibility
Step LevelHighest priority. Overrides all higher scopes for that specific step.`steps:
  • env: APP_ENV: staging| Only the specific step where declared. | | **Job Level** | Overrides workflow-level variables for all steps in the job. |jobs: build: env: APP_ENV: test| All steps inside that specific job. | | **Workflow Level** | Global across all jobs in the workflow file. |env: APP_ENV: production| All jobs and all steps in the workflow. | | **Default Runner Vars** | Injected automatically by the runner environment. |GITHUB_SHA, GITHUB_REF, RUNNER_OS| Global across all steps in all jobs. | | **Configuration Vars** | Stored at repo/org level; referenced viavars.*. | ${{ vars.DEFAULT_REGION }}` | Explicitly injected via YAML. |
env:
  NODE_ENV: production # Workflow level

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      NODE_ENV: staging # Job level (overrides workflow level)
    steps:
      - name: Step 1 (Inherits Job Level)
        run: echo "NODE_ENV is $NODE_ENV" # Outputs: staging

      - name: Step 2 (Step Override)
        env:
          NODE_ENV: development # Step level (overrides job level)
        run: echo "NODE_ENV is $NODE_ENV" # Outputs: development

      - name: Step 3 (Returns to Job Level)
        run: echo "NODE_ENV is $NODE_ENV" # Outputs: staging

2. Modern File-Based Workflow Commands

In modern GitHub Actions runner environments, state changes intended to persist beyond the current step are communicated by appending data to temporary files whose paths are exposed as environment variables.

+-----------------------------------------------------------------------------+
|                      RUNNER ENVIRONMENT FILES OVERVIEW                      |
|                                                                             |
|   $GITHUB_ENV          ---> Sets environment variables for SUBSEQUENT steps |
|   $GITHUB_OUTPUT       ---> Sets step output parameters for steps & jobs    |
|   $GITHUB_PATH         ---> Prepends directories to the system PATH         |
|   $GITHUB_STEP_SUMMARY ---> Appends Markdown to the Workflow Summary UI     |
+-----------------------------------------------------------------------------+

1. Setting Environment Variables: $GITHUB_ENV

To make an environment variable available to subsequent steps in the same job, append the KEY=value pair to the file located at $GITHUB_ENV.

# Single-line assignment
echo "DEPLOY_TAG=v1.2.3" >> "$GITHUB_ENV"

[!NOTE] Variables written to $GITHUB_ENV are not visible within the currently executing step's shell environment; they become active starting with the next step in the job.

Multi-Line Environment Variables (Heredoc Syntax):

When setting multi-line strings (e.g., certificates, multiline release notes, JSON strings), you must use a delimiter string:

echo 'RELEASE_NOTES<<EOF' >> "$GITHUB_ENV"
echo "## Changelog" >> "$GITHUB_ENV"
echo "- Fixed authentication bug" >> "$GITHUB_ENV"
echo "- Added ARM64 support" >> "$GITHUB_ENV"
echo 'EOF' >> "$GITHUB_ENV"

2. Setting Step Outputs: $GITHUB_OUTPUT

Step outputs provide structured parameters accessible by later steps via ${{ steps.<step_id>.outputs.<key> }} or exported to downstream jobs:

# Single-line output
echo "image_digest=sha256:7f83b1657" >> "$GITHUB_OUTPUT"

# Multi-line output
echo 'config_json<<EOF' >> "$GITHUB_OUTPUT"
echo '{"env": "prod", "replicas": 5}' >> "$GITHUB_OUTPUT"
echo 'EOF' >> "$GITHUB_OUTPUT"

3. Adding to System PATH: $GITHUB_PATH

To prepend a directory to the system PATH for all subsequent steps in the current job:

echo "/opt/custom-toolchain/bin" >> "$GITHUB_PATH"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"

4. Custom Markdown Job Summaries: $GITHUB_STEP_SUMMARY

Workflows can render rich Markdown reports on the GitHub Actions run summary page by appending content to $GITHUB_STEP_SUMMARY (up to 1,024 KB per step):

echo "### 🧪 Test Execution Report" >> "$GITHUB_STEP_SUMMARY"
echo "| Suite | Passed | Failed |" >> "$GITHUB_STEP_SUMMARY"
echo "| :--- | :--- | :--- |" >> "$GITHUB_STEP_SUMMARY"
echo "| Unit | 142 | 0 |" >> "$GITHUB_STEP_SUMMARY"
echo "| Integration | 38 | 0 |" >> "$GITHUB_STEP_SUMMARY"

3. Workflow Log Commands & Secret Masking

Workflow commands can also be printed directly to stdout using specific formatted strings that the runner interceptor parses.

Dynamic Secret Masking (::add-mask::)

When a script generates or retrieves a sensitive token dynamically at runtime, printing ::add-mask:: instructs the runner to redact all future instances of that string from the logs, replacing them with ***.

DYNAMIC_TOKEN=$(curl -s https://auth.internal.corp/generate-token)
echo "::add-mask::$DYNAMIC_TOKEN"
echo "TOKEN=$DYNAMIC_TOKEN" >> "$GITHUB_ENV"

Log Annotations: Notices, Warnings, and Errors

Commands can create inline annotations on GitHub Pull Request diffs and workflow logs:

# Format: ::warning file={name},line={line},col={col}::{message}
echo "::warning file=src/app.js,line=42,col=5::Deprecated API usage detected"
echo "::error file=src/db.js,line=100::Database connection timeout"
echo "::notice::Build completed in 45s"

Log Grouping (::group:: and ::endgroup::)

Collapsible sections in the runner web console keep log output clean and organized:

echo "::group::Initializing Dependencies"
npm ci
echo "::endgroup::"

4. Deprecated Workflow Commands & Security Remediation

In older versions of GitHub Actions, commands were executed via stdout syntax such as ::set-output and ::set-env. These were deprecated and disabled by default due to critical security injection vulnerabilities.

+-----------------------------------------------------------------------------+
|                     DEPRECATED VS MODERN COMMAND MIGRATION                  |
|                                                                             |
|   [DEPRECATED / INSECURE]                 [MODERN / SECURE]                 |
|                                                                             |
|   echo "::set-output name=x::val"  --->   echo "x=val" >> "$GITHUB_OUTPUT"  |
|   echo "::set-env name=x::val"     --->   echo "x=val" >> "$GITHUB_ENV"     |
|   echo "::add-path::/custom/bin"   --->   echo "/bin" >> "$GITHUB_PATH"     |
+-----------------------------------------------------------------------------+

[!WARNING] Security Vulnerability of ::set-env and ::set-output: If an untrusted input (such as an issue title, commit message, or PR comment) contained ::set-env name=NODE_OPTIONS::--inspect, the stdout parser would blindly execute the command, allowing arbitrary environment variable injection and remote code execution (RCE). The file-based system isolates commands to dedicated file handles that untrusted stdout output cannot trigger.


5. Comprehensive YAML Workflow Example

name: Enterprise Workflow Commands Demo

on:
  push:
    branches: [main]

jobs:
  process-artifacts:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Configure Environment and Paths
        id: setup
        run: |
          echo "::group::Setup Toolchain and Masks"
          # 1. Mask sensitive token
          API_SECRET="super-secret-token-xyz-99"
          echo "::add-mask::$API_SECRET"
          
          # 2. Add local bin to PATH
          mkdir -p $HOME/.custom_bin
          echo "$HOME/.custom_bin" >> "$GITHUB_PATH"
          
          # 3. Set persistent environment variable
          echo "APP_STAGE=canary" >> "$GITHUB_ENV"
          
          # 4. Set step output
          echo "calculated_id=CANARY-9842" >> "$GITHUB_OUTPUT"
          echo "::endgroup::"

      - name: Validate Persistence & Generate Summary
        run: |
          # Verify APP_STAGE is available from $GITHUB_ENV
          echo "Current Stage is: $APP_STAGE"
          
          # Verify step output from setup step
          echo "Calculated ID: ${{ steps.setup.outputs.calculated_id }}"
          
          # Write markdown summary to GitHub UI
          echo "## 🚀 Deployment Status" >> "$GITHUB_STEP_SUMMARY"
          echo "- Stage: **$APP_STAGE**" >> "$GITHUB_STEP_SUMMARY"
          echo "- Deployment ID: `${{ steps.setup.outputs.calculated_id }}`" >> "$GITHUB_STEP_SUMMARY"
Test Your Knowledge

A workflow defines env: { TARGET: 'alpha' } at the top level, env: { TARGET: 'beta' } at the job level, and env: { TARGET: 'gamma' } within a step. When that specific step runs echo $TARGET, what value is printed?

A
B
C
D
Test Your Knowledge

Which syntax correctly writes a multi-line environment variable named CERT_DATA into the runner environment file so that subsequent steps can access it?

A
B
C
D
Test Your Knowledge

What is the primary effect of executing echo "::add-mask::my-api-secret" during a workflow step?

A
B
C
D