Knowledgebase

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 env value, a with input, a run command, an if. 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.title on a push reads as nothing, no error says so, and the empty text goes wherever it was headed.
  • It lands in a run line. 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 in env and read $TITLE.
  • A secrets. value lands in a run line 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, or context['some-name'] when the name carries a dash; github.event is 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 if reads github, needs, vars, and inputs only; secrets reaches no if at all, and steps only a later step. GitHub's availability table, linked below, says which key sees which.
  • github.event.inputs where you meant inputs. The first is text through and through, so a ticked box reads 'true'; the second keeps the declared type.
  • needs.build.outputs.version is empty. A step's output stays in its job until the job's own outputs: maps it out; only then does the next job see it.
  • github.ref on a pull request. It is refs/pull/128/merge, not the branch; github.head_ref is 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, written name(arguments); the names ignore case, so tojson is toJSON.
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; an if that 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 is labels.*.name you search.
  • fromJSON on something that is not JSON. The key fails as GitHub reads it, and the error names the JSON rather than your file; toJSON it first to see what you have.
  • hashFiles before the checkout. It reads the runner's folder, which is empty until actions/checkout runs, so the hash is of nothing and the cache key never changes.
  • github.actor == 'Octocat'. It matches octocat, 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's github.ref is the merge ref, so there it is github.base_ref == 'main'.
  • Only on a pull request: if: github.event_name == 'pull_request'; add && !github.event.pull_request.head.repo.fork to 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 a pull_request or issues trigger whose activity types include labeled.
  • Only by hand: if: github.event_name == 'workflow_dispatch'; if: inputs.dry_run for a ticked box, since a manual run's inputs keep their type.
  • Only when a comment says the word: if: contains(github.event.comment.body, '/claude') under issue_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' and if: ${{ github.ref == 'refs/heads/main' }} are the same condition.
  • A yes is anything but false, 0, null, and empty text. The word false arriving as text counts as yes, and every step output and every github.event.inputs value is text; compare to 'true' instead.

Gets you in trouble when

  • A step's if sees whether an earlier step failed only when it says so. An if naming no status function gets success() && put in front, so by default a failed step stops the job, and every later step is skipped, if or no if.
  • You need the cleanup anyway. always(), failure(), and success() exist for exactly that, and always() will happily run your cleanup after a cancellation too; !cancelled() is the one that stops for a cancel.
  • secrets in an if. GitHub keeps the context out of every if, on a job and on a step alike; copy the secret into env on the job and test env.TOKEN != '' on the step instead. On a pull request from a fork there are no secrets at all.
  • An if that starts with !. YAML reads a bare ! as a tag and refuses the file; write if: "!cancelled()" or if: ${{ !cancelled() }}.
  • A job whose needs failed. It is skipped too, unless its own if says always() or !cancelled(); then needs.build.result says 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