Expressions, contexts, and conditions in GitHub Actions
The curly braces, where their values come from, and how to make a job or a step run only sometimes. Every context and function in a table, the usual conditions as recipes, and the rules that catch everybody once.
Most of a workflow is fixed text. This page is the rest: the ${{ }} GitHub works out at run time, the values it can reach, and the if that skips a job or a step.
✨ Expression
${{ }} is a little bit of code GitHub works out before the run. The answer goes where the braces were, and the key is read as if you had typed it.
- Reach for it when a value is not known until the run starts: the branch, who pushed, an input, a secret, what an earlier step found.
- Lives inside almost any value in the file: a run name, an
envvalue, awithinput, aruncommand, anif. The key gets the result, never the braces.
name: Build
run-name: ${{ github.ref_name }} by ${{ github.actor }}
on: push
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- run: echo "Building ${{ github.ref_name }} at ${{ github.sha }}"
What goes inside the braces, and what it reads as:
| Inside the braces | Written as | Reads as |
|---|---|---|
| A value from a context | github.ref_name |
Whatever the run knows, main say |
| Text | 'release' |
The text; single quotes only, and a ' inside is written '' |
| A number, a yes or no, nothing | 42, true, null |
As written |
| A function call | contains(github.ref_name, 'release') |
true or false |
| A comparison, or two things joined | github.ref_name == 'main' && github.actor != 'dependabot[bot]' |
One yes or no |
Gets you in trouble when
- A
${{never closes. An unbalanced brace is the single most common thing the editor complains about:An expression opens with ${{ but never closes with }}.GitHub refuses the file for it. - Double quotes inside.
${{ "main" }}is an error, not text; the braces take single quotes only, and${{ 'it''s' }}is how a quote gets in. - A value that is not there.
github.event.pull_request.titleon a push reads as nothing, no error says so, and the empty text goes wherever it was headed. - It lands in a
runline.echo ${{ github.event.pull_request.title }}pastes the title into a shell command, and a title written to hold$(...)runs whatever is inside; put it inenvand read$TITLE. - A
secrets.value lands in arunline for the same reason, and the editor warns: it sits in the command line, and in the log once the command fails.
In the editor any field in the code font takes one, braces and all; Run name has Compose beside it, which builds one from a catalogue and shows a sample.
🎒 Contexts
The bags of values you can read: github, inputs, env, secrets, needs, matrix, steps, vars, runner. Each is a mapping, and a dot reaches into it.
- Reach for it when you want a fact about this run rather than a fixed value: which branch, which event, which slice of a matrix, what the last job produced.
- Lives inside the braces as
context.name, orcontext['some-name']when the name carries a dash;github.eventis the whole webhook payload, so anything in it is a dot away.
on:
workflow_dispatch:
inputs:
target:
description: Where to deploy
type: choice
options: [staging, production]
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 10
environment: ${{ inputs.target }}
steps:
- run: echo "Deploying ${{ github.sha }} to ${{ inputs.target }} on ${{ runner.os }}"
Each bag, what is in it, where you may read it, and one value worth knowing:
| Context | What it holds | Readable | One value worth knowing |
|---|---|---|---|
github |
What GitHub knows about the run: the event, who started it, the branch, the commit, the repository, and the whole payload under github.event |
Everywhere | github.event_name, the event that started the run |
inputs |
What a manual run or a calling workflow passed in, each keeping the type it was declared with | Everywhere | inputs.dry_run, a ticked box read as a real yes or no |
env |
The variables env set above this key, at the workflow, the job, or the step |
A step's fields, and a few of the job's; never a job's if |
env.NODE_ENV, whatever the nearest env said |
secrets |
The repository's, the environment's, and the organisation's secrets, and the run's own token | env, with, run, and their like; never an if |
secrets.GITHUB_TOKEN, the token every run is given |
needs |
What the jobs this one waited for produced: their outputs, and how each ended | A job and its steps, once needs names the job |
needs.build.outputs.version, or needs.build.result |
matrix |
This run's slice of the matrix, one value per axis | A job's fields and its steps, not the job's own if |
matrix.os, the axis you named os |
steps |
What earlier steps in this job wrote as outputs, and how each ended | Later steps in the same job | steps.build.outputs.sha, from a step whose id is build |
vars |
The configuration variables set in repository, environment, or organisation settings | Everywhere | vars.DEPLOY_TARGET, whatever settings hold for it |
runner |
Where the job is running: its OS, its architecture, its temp and tool folders | A job's steps, and its outputs | runner.os, Linux, Windows, or macOS |
Gets you in trouble when
- The bag is not there where you stand. A job's
ifreadsgithub,needs,vars, andinputsonly;secretsreaches noifat all, andstepsonly a later step. GitHub's availability table, linked below, says which key sees which. github.event.inputswhere you meantinputs. The first is text through and through, so a ticked box reads'true'; the second keeps the declared type.needs.build.outputs.versionis empty. A step's output stays in its job until the job's ownoutputs:maps it out; only then does the next job see it.github.refon a pull request. It isrefs/pull/128/merge, not the branch;github.head_refis the branch the pull request comes from.- A secret in a log. GitHub masks the exact value and nothing else; a substring, a base64 form, or a URL with it inside goes through in plain sight, so a sensible workflow never prints one. A pull request from a fork brings no secrets at all, only a read-only token.
In the editor Compose offers the github values worth having, the inputs this workflow declares, and a vars name you type, each with a sample of what it reads as.
🧮 Functions
The handful of things you can do to those values. Twelve exist, GitHub wrote them all, and you cannot add one.
- Reach for it when a value is nearly right: a list to search, a text to test, a template to fill, a file set to fingerprint, a status to ask about.
- Lives inside the braces, or bare in an
if, writtenname(arguments); the names ignore case, sotojsonistoJSON.
on: pull_request
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- id: lock
run: echo "hash=${{ hashFiles('**/package-lock.json') }}" >> "$GITHUB_OUTPUT"
- if: contains(github.event.pull_request.labels.*.name, 'deploy')
run: echo "Labelled deploy; the lock file hashes to ${{ steps.lock.outputs.hash }}"
- if: failure()
run: echo "A step above failed"
Every function, what it does, and one example:
| Function | What it does | One example |
|---|---|---|
contains(search, item) |
Whether text holds a piece, or a list holds an item; case does not matter | contains(github.event.pull_request.labels.*.name, 'deploy') |
startsWith(text, piece) |
Whether text starts with a piece | startsWith(github.ref, 'refs/tags/') |
endsWith(text, piece) |
Whether text ends with a piece | endsWith(github.ref_name, '-rc') |
format(template, values) |
Fills the numbered slots in a template; {{ writes a literal brace |
format('{0} by {1}', github.ref_name, github.actor) |
join(list, separator) |
A list written as one text, with the separator between items | join(github.event.pull_request.labels.*.name, ', ') |
toJSON(value) |
A value written out as JSON, which is how you see what a context holds | toJSON(github.event) |
fromJSON(text) |
JSON read back into a value: a list, a number, a yes or no | fromJSON('["main", "release"]') |
hashFiles(patterns) |
One hash of every file the patterns match, made for a cache key | hashFiles('**/package-lock.json') |
success() |
Yes when nothing before this point failed | if: success() |
failure() |
Yes when something before this point failed | if: failure() |
cancelled() |
Yes when the run was cancelled | if: cancelled() |
always() |
Yes no matter what, a cancellation included | if: always() |
Between them go the operators, the same ones Compose drops in:
==and!=: the same, or different; text is compared ignoring case, so'Main'is'main'.<,<=,>,>=: less or more, with text turned into a number first, so'10' > '9'holds.&&: both; it reads as the second value when the first has one, and as the first otherwise.||: either; it reads as the first value that has one, which is how a fallback is written.!: the opposite of a yes or no; anifthat starts with it must be quoted, since YAML reads a bare!as a tag.( )groups,.and[ ]reach into a value, and.*reaches into every item of a list.
Gets you in trouble when
contains(github.event.pull_request.labels, 'deploy'). The labels are objects, so nothing matches; it islabels.*.nameyou search.fromJSONon something that is not JSON. The key fails as GitHub reads it, and the error names the JSON rather than your file;toJSONit first to see what you have.hashFilesbefore the checkout. It reads the runner's folder, which is empty untilactions/checkoutruns, so the hash is of nothing and the cache key never changes.github.actor == 'Octocat'. It matchesoctocat, since comparisons ignore case, which is handy right up to the day it is not.always()on a deploy. It runs after a cancellation too, so the thing someone pressed Cancel on ships anyway;!cancelled()runs after a failure and stops for a cancel.
In the editor Compose has a Functions group, each a whole working call to edit, and an Operators group that drops one where the caret sits.
🚦 Condition
if decides whether a job or a step runs at all. It is read before the job or the step starts, and a no is a skip, never a failure.
- Reach for it when a job or a step should sit some runs out: only on the default branch, only on a pull request, only after something failed, only once a label lands.
- Lives on a job or on a step, as
if. It is already an expression, so the braces are optional there;if: github.ref == 'refs/heads/main'is the whole thing.
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- run: npm test
- if: failure()
run: cat test.log
deploy:
needs: test
if: github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- run: echo "Only after the tests passed, only on a push to main"
The usual answers, ready to paste:
- Only on the default branch:
if: github.ref == 'refs/heads/main'on a push. A pull request'sgithub.refis the merge ref, so there it isgithub.base_ref == 'main'. - Only on a pull request:
if: github.event_name == 'pull_request'; add&& !github.event.pull_request.head.repo.forkto leave out forks. - Only when an earlier step failed:
if: failure()on the step that should run then;if: steps.test.outcome == 'failure'when it is one step in particular. - Only when a label was added:
if: github.event.action == 'labeled' && github.event.label.name == 'deploy', under apull_requestorissuestrigger whose activity types includelabeled. - Only by hand:
if: github.event_name == 'workflow_dispatch';if: inputs.dry_runfor a ticked box, since a manual run'sinputskeep their type. - Only when a comment says the word:
if: contains(github.event.comment.body, '/claude')underissue_comment; the editor writes this one for you. - Whatever happened before, unless someone cancelled:
if: ${{ !cancelled() }}, the one for a log upload or a cleanup.
The rules that catch everybody once:
- The braces are optional in an
if, and nowhere else:if: github.ref == 'refs/heads/main'andif: ${{ github.ref == 'refs/heads/main' }}are the same condition. - A yes is anything but
false,0,null, and empty text. The wordfalsearriving as text counts as yes, and every step output and everygithub.event.inputsvalue is text; compare to'true'instead.
Gets you in trouble when
- A step's
ifsees whether an earlier step failed only when it says so. Anifnaming no status function getssuccess() &&put in front, so by default a failed step stops the job, and every later step is skipped,ifor noif. - You need the cleanup anyway.
always(),failure(), andsuccess()exist for exactly that, andalways()will happily run your cleanup after a cancellation too;!cancelled()is the one that stops for a cancel. secretsin anif. GitHub keeps the context out of everyif, on a job and on a step alike; copy the secret intoenvon the job and testenv.TOKEN != ''on the step instead. On a pull request from a fork there are no secrets at all.- An
ifthat starts with!. YAML reads a bare!as a tag and refuses the file; writeif: "!cancelled()"orif: ${{ !cancelled() }}. - A job whose
needsfailed. It is skipped too, unless its ownifsaysalways()or!cancelled(); thenneeds.build.resultsays how the other one went.
In the editor it is Run only when on a job and on a step, braces optional; the Only when the text says so card writes a phrase guard as one for you.
📚 Straight from GitHub
- Expressions: the literals, the operators, and every function with an example, and the status check functions on their own.
- Contexts: every context and what it holds, and the context availability table, which says which key may read which.
jobs.<job_id>.ifand a step'sif: a condition on a job and on a step, with an example of the status check functions.- Using conditions to control job execution: GitHub's own walk through the
ifrecipes. - Script injection: why an expression in a
runline goes throughenvinstead.