GitHub Actions Fundamentals
Topic 52

Contexts, Expressions, and Variables

Actions

The ${{ }} expression syntax is how a workflow reads its own state: the github context for event data, env/vars/secrets for values, and needs/steps for upstream outputs. With if: you branch on those expressions to run a step or job only when a condition holds.

Two distinctions cause most of the trouble here, and both are about trust. vars are plaintext configuration while secrets are encrypted and masked — putting a key in the wrong one leaks it. And interpolating untrusted event data straight into a shell line is a command-injection hole, not a convenience. This page covers the syntax and both hazards.

The Expression Syntax

Inside ${{ }} you have operators (==, !=, &&, ||, !) and a set of built-in functions: contains(), startsWith(), format(), fromJSON(), and hashFiles() among them. These let you compute values and build conditions from context data rather than hard-coding them.

The github Context

The github context carries the event that triggered the run. github.event_name is the trigger (push, pull_request, …), github.ref is the branch or tag ref, github.sha is the commit, and github.actor is who triggered it. github.event.* exposes the raw webhook payload — and that raw payload is exactly the untrusted data you must handle carefully below.

Conditionals with if

Gate a step or job with if:. A common pattern is to run a deploy only on the main branch: if: github.ref == 'refs/heads/main'. The if: key evaluates as an expression even without the ${{ }} wrapper, though it tolerates the wrapper too. Be deliberate about truthiness — a non-empty string is truthy, so if: ${{ 'false' }} runs.

Deploy only on a push to main
deploy:
  if: github.ref == 'refs/heads/main' && github.event_name == 'push'
  runs-on: ubuntu-latest
  steps:
    - run: make deploy

env vs vars vs secrets

Three places values come from, with different visibility. env is defined in the workflow file and good for build-time constants. vars (${{ vars.X }}) is non-sensitive configuration managed in repo or org Settings without editing YAML — region names, image tags. secrets (${{ secrets.X }}) is encrypted at rest and masked in logs, and is the only one of the three that should ever hold a token or key.

The Injection Hazard

Interpolating github.event.* — a PR title, a branch name, an issue body — directly into a run: shell line lets an attacker run commands on your runner. A PR titled $(rm -rf .) executes when the title is spliced into the script. The fix is to pass the untrusted value through env: and reference the quoted environment variable, so the shell treats it as data, not code.

Safe: untrusted title goes through env, not the shell line
steps:
  - env:
      TITLE: ${{ github.event.pull_request.title }}
    run: echo "PR title is $TITLE"
env vs vars vs secrets

env is defined in the workflow file and read as ${{ env.X }} — use it for build-time constants. vars (${{ vars.X }}) is non-sensitive config managed in repo/org Settings, so you change it without editing YAML, but it is plaintext and shows in logs.

secrets (${{ secrets.X }}) is encrypted at rest and masked in logs, and is the only one of the three you should put tokens or keys in. Masking only catches exact secret values, and fork-triggered pull_request runs do not receive secrets at all.

Common Mistakes
  • Putting ${{ github.event.pull_request.title }} straight into a run: line — a title like $(rm -rf .) executes; route it through env: and reference "$TITLE" instead.
  • Storing an API key in vars instead of secretsvars is plaintext and prints in logs unmasked.
  • Expecting secrets to be available to a workflow triggered by a fork's pull_request — they are withheld by design.
  • Assuming masking hides any trace of a secret, when it only catches the exact value; an echoed base64 or partial of a secret is not masked.
  • Writing if: ${{ false }} and expecting it to skip, when the string 'false' is truthy — misreading the truthiness rules.
Best Practices
  • Assign any untrusted context value to an env: variable, then reference the variable in run: — never interpolate it straight into the shell.
  • Put all sensitive values in secrets and reserve vars for non-sensitive config such as region or image name.
  • Gate deploy steps with an explicit if: github.ref == 'refs/heads/main' && github.event_name == 'push'.
  • Use fromJSON() to turn a matrix or output string into structured data you can index.
  • Use hashFiles('**/lockfile') to build deterministic, content-based cache keys.
Comparable toolsGitLab CI/CD CI/CD variables, predefined vars, masked/protectedCircleCI contexts and env varsAzure Pipelines variables, $(var), secret variablesJenkins env and credentials binding

Knowledge Check

Why is putting ${{ github.event.pull_request.title }} directly into a run: line a vulnerability?

  • It is untrusted input spliced into the shell, so a title like $(rm -rf .) executes
  • A title with embedded newlines breaks the workflow's YAML parsing at load time
  • It leaks the PR author's commit email into the run's public logs
  • It always evaluates to an empty string, so the step is silently skipped

Which of env, vars, and secrets is encrypted and masked in logs?

  • secrets only — env and vars stay plaintext
  • vars only, since it is managed in repo Settings
  • All three are encrypted and masked in run logs
  • env and secrets, but not the vars store

A contributor opens a PR from a fork. Are your secrets available to that pull_request run?

  • No — secrets are withheld from fork pull_request runs by design
  • Yes, all repository secrets are available to the run as usual
  • Only the secrets whose names are prefixed with PUBLIC_ are passed in
  • Yes, but the run receives them in a read-only, non-editable form

Why does if: ${{ 'false' }} still run the step?

  • It is the non-empty string 'false', which is truthy, not the boolean false
  • if: ignores the ${{ }} wrapper entirely and never evaluates inside it
  • String comparison is case-insensitive, so 'false' is read as equal to true
  • if: accepts only github.* references, so an unknown one defaults to running

You got correct