Contexts, Expressions, Variables, and Environment Files
Every GitHub Actions workflow run is really a bundle of data — who triggered it, what branch it targeted, which secrets and variables it can see, what earlier steps produced. Contexts are the read-only objects that expose this data, and expressions (written as ${{ }}) are the syntax you use to read and combine it. Environment files are the other half of the picture: the mechanism a running step uses to write data back out, so later steps and jobs can read it. Understanding these three pieces together is what lets you write workflows that branch, share state, and stay safe from injection instead of copy-pasted boilerplate.
Overview / How it works
A workflow run assembles several contexts before and during execution. The most common ones are github (event payload, ref, actor, repository), env (environment variables visible to the current scope), vars (repository, organization, or environment-level configuration variables), secrets (encrypted values), job, steps, runner, strategy, matrix, needs, and inputs. Each context is just a nested object; you reach into it with dot notation inside an expression, for example github.event.pull_request.number or steps.build.outputs.artifact_path.
Expressions are evaluated by the Actions runner before a step’s shell command runs, so ${{ }} blocks are substituted with plain text first — this is exactly why interpolating untrusted context values directly into a run: block is dangerous, covered under Common Mistakes below. Environment files close the loop in the other direction. Inside a running step, GitHub exposes special file paths through environment variables: GITHUB_ENV (append KEY=value to define an env var for all later steps in the job), GITHUB_OUTPUT (append key=value to define a step output, which other jobs can read through the needs context), GITHUB_PATH (prepend a directory to PATH for later steps), and GITHUB_STEP_SUMMARY (append Markdown that renders in the run’s summary page). These files replaced the older ::set-env:: and ::set-output:: workflow commands, which are deprecated and disabled by default on most runners.
Syntax or workflow structure
An expression is any text inside ${{ ... }}. It can appear in if:, env:, run:, with:, and most other keys. Supported operators include ==, !=, &&, ||, and !, plus built-in functions such as contains(), startsWith(), endsWith(), format(), join(), toJSON(), and fromJSON(). Four special status-check functions — success(), failure(), cancelled(), and always() — read the outcome of previous steps and are almost always used inside if: conditions.
| Context | Typical use | Example |
|---|---|---|
github |
Event, ref, actor, repository metadata | github.sha |
env |
Variables set with env: or GITHUB_ENV |
env.NODE_ENV |
vars |
Non-secret configuration values (repo/org/environment) | vars.DEPLOY_REGION |
secrets |
Encrypted values, never printed | secrets.DEPLOY_TOKEN |
steps |
Outputs and outcome of prior steps in the same job | steps.build.outputs.version |
needs |
Outputs and result of jobs this job depends on | needs.detect.outputs.version |
matrix |
Current leg of a matrix strategy | matrix.node |
runner |
Details about the executing runner | runner.os |
A key distinction: vars holds non-secret configuration (region names, feature flags, default versions) that is fine to print in logs, while secrets holds encrypted values that Actions automatically masks in log output. Reach for vars first; reach for secrets only for values that must stay confidential.
Examples
Example 1 — scoped env vars and status functions. Environment variables can be set at the workflow, job, or step level; the narrowest scope wins for that step, and outer scopes still apply to steps that don’t override them.
name: Context and Expression Basics
on:
push:
branches: [main]
permissions:
contents: read
env:
NODE_ENV: production # workflow-level env, visible to every job
jobs:
build:
runs-on: ubuntu-latest
env:
BUILD_STAGE: compile # job-level env, overrides workflow-level for this job
steps:
- name: Show context values
env:
STEP_LABEL: context-demo # step-level env, narrowest scope
run: |
echo "Event: ${{ github.event_name }}"
echo "Ref: ${{ github.ref }}"
echo "Actor: ${{ github.actor }}"
echo "Runner OS: ${{ runner.os }}"
echo "NODE_ENV=$NODE_ENV, BUILD_STAGE=$BUILD_STAGE, STEP_LABEL=$STEP_LABEL"
- name: Only run on main
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: echo "Deploying from main"
- name: Only run after a failure
if: failure()
run: echo "A previous step failed - collecting diagnostics"
- name: Always run cleanup
if: always()
run: echo "Cleanup step ran"
Expected behavior: on a push to main, every step runs except the failure-diagnostics step, since nothing failed. The log looks like this:
Event: push
Ref: refs/heads/main
Actor: octocat
Runner OS: Linux
NODE_ENV=production, BUILD_STAGE=compile, STEP_LABEL=context-demo
Deploying from main
Cleanup step ran
Example 2 — passing data between steps and jobs. A step writes to GITHUB_OUTPUT to expose a value at the job level, and the job declares that value under outputs: so a downstream job can read it through the needs context. A separate step writes to GITHUB_ENV to make a value available to later steps in the same job only.
name: Pass Data Between Jobs and Steps
on:
workflow_dispatch:
permissions:
contents: read
jobs:
detect:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.read_version.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Read package version
id: read_version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Persist a value for later steps in this job
run: echo "BUILD_ID=$(date +%s)" >> "$GITHUB_ENV"
- name: Use the persisted value
run: echo "This build id is $BUILD_ID"
test-matrix:
needs: detect
runs-on: ubuntu-latest
strategy:
matrix:
node: [18, 20]
steps:
- uses: actions/checkout@v4
- name: Show inherited version and matrix context
run: |
echo "Testing package version ${{ needs.detect.outputs.version }}"
echo "Node matrix leg: ${{ matrix.node }}"
Expected behavior: the detect job resolves the package version once and exposes it as a job output. The test-matrix job waits for detect to finish, then runs twice in parallel — once per Node.js version — each leg reading the same version string through needs.detect.outputs.version.
detect job:
This build id is 1735939200
test-matrix job (node 18):
Testing package version 2.4.1
Node matrix leg: 18
test-matrix job (node 20):
Testing package version 2.4.1
Node matrix leg: 20
Example 3 — multiline values and job summaries. A single key=value line breaks if the value itself contains newlines, such as a commit message. Use a heredoc-style delimiter instead, and use GITHUB_STEP_SUMMARY to publish Markdown that shows up on the run’s summary page rather than buried in logs.
- name: Capture the last commit message as an output
run: |
{
echo "changelog<<EOF"
git log -1 --pretty=%B
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Publish a job summary
run: |
echo "## Test Results" >> "$GITHUB_STEP_SUMMARY"
echo "- Passed: 42" >> "$GITHUB_STEP_SUMMARY"
echo "- Failed: 0" >> "$GITHUB_STEP_SUMMARY"
Expected behavior: changelog becomes a step output containing the full, multiline commit message, safely delimited so embedded blank lines don’t corrupt the file. The summary step adds a formatted “Test Results” section to the run’s Summary tab in the GitHub UI, visible without opening any logs.
Step by step
- Decide what needs to cross a boundary: same step (use a local shell variable), same job across steps (
GITHUB_ENVor stepoutputs), or across jobs (job-leveloutputs:read vianeeds). - Give the producing step an explicit
id:— it’s the handle later expressions use, as insteps.read_version.outputs.version. - Inside that step, append
key=valueto"$GITHUB_OUTPUT"(or"$GITHUB_ENV") instead of echoing a workflow command string. - If the job needs to expose that value to other jobs, add a top-level
outputs:map on the job and set it to${{ steps.<id>.outputs.<name> }}. - In the consuming job, add
needs: <job-id>so it waits for the producer and gains access toneeds.<job-id>.outputs.<name>. - Guard risky steps with
if:expressions usingsuccess(),failure(),cancelled(), oralways()so cleanup and diagnostics run at the right time regardless of upstream failures. - Grant the workflow only the
permissions:it actually needs — reading contexts doesn’t require any token scope, but writing outputs consumed by a deploy step often does.
Common Mistakes
Mistake 1 — interpolating untrusted context directly into a shell command. Any expression that resolves to attacker-controlled text (an issue title, a PR branch name, a commit message) becomes literal shell text if you drop it straight into run:. A malicious title containing shell metacharacters and a piped command would execute as part of the step.
# VULNERABLE: untrusted context value is expanded directly into the shell command
- name: Greet the issue author (do not do this)
run: echo "Thanks for opening: ${{ github.event.issue.title }}"
# SAFE: the value flows through an environment variable instead of the command line
- name: Greet the issue author (corrected)
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
run: echo "Thanks for opening: $ISSUE_TITLE"
The corrected version still uses the same data, but the expression only ever populates an environment variable via the runner’s controlled substitution — the shell never re-parses attacker text as command syntax.
Mistake 2 — using the deprecated set-output/set-env workflow commands. Older tutorials show echo "::set-output name=x::value". GitHub disabled this command by default after a related security issue and it silently fails or errors on current runners.
# Deprecated: set-output was removed from the toolkit and no longer works reliably
- run: echo "::set-output name=result::success"
# Correct: write to the GITHUB_OUTPUT environment file
- run: echo "result=success" >> "$GITHUB_OUTPUT"
Always target the environment files (GITHUB_OUTPUT, GITHUB_ENV, GITHUB_PATH, GITHUB_STEP_SUMMARY) instead of workflow commands for anything beyond simple log annotations like ::warning:: and ::error::, which are still supported.
Best Practices
- Prefer
varsfor non-secret configuration and reservesecretsfor values that truly need masking and encryption; mixing them up either leaks nothing useful into logs or, worse, exposes real credentials. - Never build a
run:command by directly concatenating an expression that comes from pull request titles, branch names, commit messages, or issue bodies — always route it throughenv:first. - Set the minimal
permissions:block a workflow needs (oftencontents: read, sometimes pluspull-requests: writeor similar) rather than relying on the default token scope, which is broader than most jobs require. - Use pinned action versions (a tag like
@v4for well-maintained actions, or a full commit SHA for anything security-sensitive) — a tag can be moved by the action’s maintainer, while a SHA is immutable and auditable. - Quote environment file paths as
"$GITHUB_OUTPUT"and use the heredoc delimiter pattern for any value that might contain newlines, quotes, or is user-influenced. - Treat forked pull requests as untrusted: their head branch and any checked-out code can be attacker-controlled, so avoid combining
pull_request_target, secrets, and running that code without an explicit review gate. - Use
GITHUB_STEP_SUMMARYfor human-readable results (test counts, coverage, deployment links) instead of scrolling raw logs — it renders as Markdown in the run summary.
Practice Exercises
- Write a workflow with three steps where a workflow-level
envvalue, a job-level value, and a step-level value all share the same variable name; predict and then verify which value each step sees. - Add a step that reads a repository
varsvalue and a step that reads asecretsvalue in the same job; observe how the secret is masked in the log but the variable is not. - Create two jobs where the second uses
needsto depend on the first and reads one of its declaredoutputs; break it on purpose by forgetting the job-leveloutputs:map and observe the empty result. - Take the vulnerable example from Common Mistakes and rewrite it so an issue title containing shell metacharacters can no longer alter the command that runs.
- Add an
if: failure()diagnostic step and anif: always()cleanup step to an existing job, then intentionally break an earlier step to confirm both fire in the right order.
Summary
Contexts give a workflow structured, read-only access to everything about the run — the triggering event, configuration, secrets, and the outputs of other steps and jobs. Expressions (${{ }}) are how you read and combine that data in conditions and inputs. Environment files (GITHUB_ENV, GITHUB_OUTPUT, GITHUB_PATH, GITHUB_STEP_SUMMARY) are how a running step writes new data back for later steps and jobs to consume, replacing the deprecated workflow-command syntax. Used together — with untrusted values always routed through env: rather than interpolated directly, and permissions scoped tightly — they let you build workflows that branch correctly, share state cleanly across jobs, and avoid the injection and masking pitfalls that catch teams moving from a first simple workflow to production pipelines.
