Contexts, Expressions, and Variables
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:
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.
steps:
- env:
TITLE: ${{ github.event.pull_request.title }}
run: echo "PR title is $TITLE"
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.
- Putting
${{ github.event.pull_request.title }}straight into arun:line — a title like$(rm -rf .)executes; route it throughenv:and reference"$TITLE"instead. - Storing an API key in
varsinstead ofsecrets—varsis plaintext and prints in logs unmasked. - Expecting
secretsto be available to a workflow triggered by a fork'spull_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.
- Assign any untrusted context value to an
env:variable, then reference the variable inrun:— never interpolate it straight into the shell. - Put all sensitive values in
secretsand reservevarsfor 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.
$(var), secret variablesJenkins env and credentials bindingKnowledge 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?
secretsonly —envandvarsstay plaintextvarsonly, since it is managed in repo Settings- All three are encrypted and masked in run logs
envandsecrets, but not thevarsstore
A contributor opens a PR from a fork. Are your secrets available to that pull_request run?
- No — secrets are withheld from fork
pull_requestruns 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 booleanfalse if:ignores the${{ }}wrapper entirely and never evaluates inside it- String comparison is case-insensitive, so
'false'is read as equal totrue if:accepts onlygithub.*references, so an unknown one defaults to running
You got correct