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) }}"
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_nameover slicinggithub.refby hand. - Use
github.sha(notlatest) 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
${{ }}insideif:— 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
idwhen 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: - 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.*fromworkflow_dispatchbefore using them; achoiceinput type constrains values far better than a free-textstring. - 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.shaprints 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;$VARis evaluated by the shell on the runner. They happen at different times. - Referencing a step without an
id.steps.<id>.outputsonly exists for steps that declareid:. - Expecting payload fields on the wrong event.
github.event.pull_requestis empty on apushorworkflow_dispatchrun. - Using
envcontext too early.env.FOOis not available in theenv:block that defines it, nor inruns-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. githubholds 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 checkssuccess(),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 withfromJSON, and script-injection hardening.