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).
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 / Source | YAML / Engine Scope | Example Declaration | Scope of Visibility |
|---|---|---|---|
| Step Level | Highest 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_ENVare 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-envand::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"
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?
Which syntax correctly writes a multi-line environment variable named CERT_DATA into the runner environment file so that subsequent steps can access it?
What is the primary effect of executing echo "::add-mask::my-api-secret" during a workflow step?