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

  1. Decide what needs to cross a boundary: same step (use a local shell variable), same job across steps (GITHUB_ENV or step outputs), or across jobs (job-level outputs: read via needs).
  2. Give the producing step an explicit id: — it’s the handle later expressions use, as in steps.read_version.outputs.version.
  3. Inside that step, append key=value to "$GITHUB_OUTPUT" (or "$GITHUB_ENV") instead of echoing a workflow command string.
  4. 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> }}.
  5. In the consuming job, add needs: <job-id> so it waits for the producer and gains access to needs.<job-id>.outputs.<name>.
  6. Guard risky steps with if: expressions using success(), failure(), cancelled(), or always() so cleanup and diagnostics run at the right time regardless of upstream failures.
  7. 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 vars for non-secret configuration and reserve secrets for 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 through env: first.
  • Set the minimal permissions: block a workflow needs (often contents: read, sometimes plus pull-requests: write or similar) rather than relying on the default token scope, which is broader than most jobs require.
  • Use pinned action versions (a tag like @v4 for 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_SUMMARY for human-readable results (test counts, coverage, deployment links) instead of scrolling raw logs — it renders as Markdown in the run summary.

Practice Exercises

  1. Write a workflow with three steps where a workflow-level env value, a job-level value, and a step-level value all share the same variable name; predict and then verify which value each step sees.
  2. Add a step that reads a repository vars value and a step that reads a secrets value in the same job; observe how the secret is masked in the log but the variable is not.
  3. Create two jobs where the second uses needs to depend on the first and reads one of its declared outputs; break it on purpose by forgetting the job-level outputs: map and observe the empty result.
  4. 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.
  5. Add an if: failure() diagnostic step and an if: 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.