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:
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.
The ${{ }} braces
Section titled “The ${{ }} braces”Required in most places, optional in if::
if: github.event_name == 'push' # conventionalif: ${{ github.event_name == 'push' }} # equivalentrun: echo "${{ github.sha }}" # required hereif: is always an expression, so the braces are redundant. Everywhere else — run, env, with,
name — a value is a literal unless braced.
The contexts
Section titled “The contexts”Eleven contexts, each available in different places.
github
Section titled “github”The largest and most used. Information about the event, repository and run.
| Property | Contains |
|---|---|
github.event_name | push, pull_request, schedule… |
github.event | The full webhook payload |
github.sha | The commit being built |
github.ref | Full ref — refs/heads/main, refs/tags/v1.0 |
github.ref_name | Short name — main, v1.0 |
github.ref_type | branch or tag |
github.head_ref | Source branch, on pull requests only |
github.base_ref | Target branch, on pull requests only |
github.repository | owner/repo |
github.repository_owner | owner |
github.actor | Who triggered the run |
github.workflow | The workflow’s name |
github.run_id | This run’s ID |
github.run_number | Incrementing count for this workflow |
github.run_attempt | Which attempt, after reruns |
github.workspace | Checkout path |
github.token | The 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, vars and secrets
Section titled “env, vars and secrets”${{ env.LOG_LEVEL }} # environment variables in scope${{ vars.AWS_REGION }} # configuration variables${{ secrets.API_TOKEN }} # secretsCovered 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-errorThe 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.
runner
Section titled “runner”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 cacherunner.os is how one workflow behaves correctly across a cross-platform matrix.
strategy and matrix
Section titled “strategy and matrix”${{ matrix.python-version }} # this job's matrix value${{ strategy.job-index }} # position in the matrix${{ strategy.fail-fast }}inputs
Section titled “inputs”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 and jobs
Section titled “job and jobs”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.
The expression language
Section titled “The expression language”Operators
Section titled “Operators”${{ 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 }}Functions
Section titled “Functions”| Function | Does |
|---|---|
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.
Status functions
Section titled “Status functions”if: success() # everything so far succeeded — the defaultif: failure() # something failedif: cancelled() # the run was cancelledif: always() # regardless, including cancellationalways() is blunt. !cancelled() is usually the better choice for cleanup and notification steps —
it runs on success and failure while respecting an explicit cancellation.
Untrusted context data
Section titled “Untrusted context data”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 value | Controlled by |
|---|---|
github.event.pull_request.title | Whoever opened it |
github.event.pull_request.body | Same |
github.event.pull_request.head.ref | The branch name they chose |
github.event.issue.title / .body | Whoever opened it |
github.event.comment.body | Any commenter |
github.event.review.body | Any reviewer |
github.event.head_commit.message | Whoever committed |
github.actor | Any account name |
Where contexts are available
Section titled “Where contexts are available”Not every context exists everywhere, and the restrictions are not arbitrary — they follow from when each value becomes known.
| Location | Available |
|---|---|
Workflow env | github, vars, inputs |
concurrency | github, inputs, vars |
Job if | github, needs, vars, inputs |
Job env | github, needs, strategy, matrix, vars, inputs, secrets |
Step if | Everything the job has, plus steps, runner, job |
Step with / env | Same 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.
Practical patterns
Section titled “Practical patterns”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 == falseAct 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.
Contexts by event type
Section titled “Contexts by event type”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 messagegithub.event.head_commit.author.namegithub.event.commits # array of pushed commitsgithub.event.before # ref's previous SHAgithub.event.created / .deleted # branch or tag lifecyclepull_request
github.event.action # opened, synchronize, closed…github.event.pull_request.numbergithub.event.pull_request.titlegithub.event.pull_request.draftgithub.event.pull_request.merged # meaningful on `closed`github.event.pull_request.head.sha # the contributor's commitgithub.event.pull_request.base.ref # the target branchgithub.event.pull_request.labels # array — use an object filterrelease
github.event.release.tag_namegithub.event.release.prereleasegithub.event.release.assetsworkflow_run
github.event.workflow_run.conclusion # must be checked — `completed` includes failuregithub.event.workflow_run.head_branchgithub.event.workflow_run.idThe 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.shWithout it, a workflow “on merge” also runs when someone closes a pull request without merging — which, for a deployment, is a genuinely bad outcome.
Expression evaluation order
Section titled “Expression evaluation order”Understanding when each part is resolved explains several otherwise-mysterious behaviours.
- The workflow file is parsed as YAML. Structure is fixed at this point; expressions are still text.
- Workflow-level expressions are evaluated —
env,concurrency,run-name. Onlygithub,varsandinputsare available. - Jobs are selected. Job-level
ifis evaluated, and matrices are expanded. - The job starts. Job-level
envis evaluated;needsis now populated. - Each step is evaluated immediately before it runs — its
if,env,withand any${{ }}insiderun.stepscontains 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.
Debugging expressions
Section titled “Debugging expressions”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:
gh run rerun RUN_ID --debugThat also enables ACTIONS_RUNNER_DEBUG and ACTIONS_STEP_DEBUG, producing considerably more verbose
logs from the runner and from actions that honour them.
Common mistakes
Section titled “Common mistakes”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.
Object filters in depth
Section titled “Object filters in depth”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 1Adding 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.
Expressions in unexpected places
Section titled “Expressions in unexpected places”${{ }} 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.
Exercise
Section titled “Exercise”- Print
toJSON(github)throughenv:and read what the context actually contains. - Do the same for
toJSON(github.event)on a push and on a pull request; compare the shapes. - Write a step conditional on
github.ref_type == 'tag'and verify it with a tag push. - Use
contains(github.event.pull_request.labels.*.name, 'deploy')to gate a step by label. - Interpolate a pull request title directly into
echo, then rewrite throughenv:, using a title containing a quote and a backtick. Compare the logs. - Reference
secrets.SOMETHINGin a workflow-levelenv: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.
A quick reference
Section titled “A quick reference”The expressions worth committing to memory, because they cover most real conditions:
# Branch and refgithub.ref == 'refs/heads/main'github.ref_name == 'main'github.ref_type == 'tag'startsWith(github.ref, 'refs/tags/v')
# Eventgithub.event_name == 'push'github.event.action == 'opened'github.event.pull_request.merged == true
# Fork detectiongithub.event.pull_request.head.repo.full_name == github.repository
# Draft stategithub.event.pull_request.draft == false
# Labelscontains(github.event.pull_request.labels.*.name, 'deploy')
# Job and step resultsneeds.build.result == 'success'contains(needs.*.result, 'failure')steps.check.outcome == 'failure'
# Statussuccess() / failure() / cancelled() / always() / !cancelled()
# Runnerrunner.os == 'Linux'runner.debug == '1'
# Defaults and ternaryinputs.version || github.ref_namegithub.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.
What you learned
Section titled “What you learned”- Expressions are substituted into the workflow before the shell runs; they are not shell variables.
- The
githubcontext carries the event payload, whose shape differs per event type. needsandstepsexpose results and outputs;outcomeandconclusiondiffer undercontinue-on-error.- Object filters (
labels.*.name) map over arrays and are the key to working with payloads. toJSONprinted throughenv: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.
Type coercion in comparisons
Section titled “Type coercion in comparisons”Expressions coerce types before comparing, and the rules produce results that look wrong until you know them.
| Comparison | Result | Why |
|---|---|---|
'1' == 1 | true | The string is coerced to a number |
'true' == true | false | A non-empty string coerces to NaN, not to a boolean |
'' == 0 | true | Empty string coerces to 0 |
'abc' == 0 | false | NaN equals nothing, including itself |
null == 0 | true | null 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 falseif: inputs.dry_run == 'true' # correctComparisons 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) }}Writing to the job summary
Section titled “Writing to the job summary”$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.
Annotations from workflow commands
Section titled “Annotations from workflow commands”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.
Where to look things up
Section titled “Where to look things up”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.
One last caution
Section titled “One last caution”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.
Related lessons
Section titled “Related lessons”Check your understanding
3 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.