Expressions, Contexts & Conditionals
Expressions, Contexts & Conditionals
By the time a workflow file is saved, you have already written YAML — but GitHub Actions evaluates a second language embedded inside that YAML: the expression language. Understanding it is the difference between copy-pasting snippets and authoring workflows that behave exactly the way you intend under every condition. This lesson covers that language completely: its syntax, the built-in contexts it reads from, and the if: key that gates job and step execution.
Expression Syntax
Expressions are delimited by ${{ }}. They may appear in any value position in a workflow file — job-level fields like runs-on, step fields like run, env, with, and the if: key. A bare if: value is itself an implicit expression (the delimiters are optional there).
The language supports:
- Literals — boolean (
true/false), null, numbers, and single-quoted strings:'main'. Double quotes are not valid string delimiters. - Operators —
==,!=,<,<=,>,>=,&&,||,!. Comparison is case-insensitive for strings. - Property access — dot notation (
github.event_name) or bracket notation for dynamic keys (fromJson(inputs.matrix)[0]). - Functions — a small stdlib:
contains(),startsWith(),endsWith(),format(),join(),toJson(),fromJson(),hashFiles(),always(),success(),failure(),cancelled().
false; any non-empty string equals true. This is intentional and useful — if: inputs.flag is truthy whenever the input was provided — but it catches engineers who expect strict equality.
Contexts
A context is a named object that Actions populates before your workflow runs. You access context properties with expressions. The seven contexts you will use most often are:
github— metadata about the triggering event:github.event_name,github.ref,github.sha,github.actor,github.repository, and the full event payload atgithub.event.env— environment variables set at the workflow, job, or step level. Within a step'srunyou can also read them as shell variables ($VAR), but${{ env.VAR }}is available anywhere, includingif:conditions.secrets— values stored in the repository or organization secret store. Accessing them in expressions (${{ secrets.TOKEN }}) is the only safe way to pass them to steps. Actions redacts secret values from logs automatically.vars— non-sensitive configuration variables (repository or org level). Use these for things likeAWS_REGIONorNODE_VERSIONthat are environment-specific but not secret.jobs— output values from other jobs in the same workflow. Available only in jobs that declareneeds:.steps— output values and outcome (success/failure/skipped/cancelled) of previous steps within the same job.runner— runtime info:runner.os,runner.arch,runner.temp,runner.tool_cache.
vars for everything that varies between environments but is not sensitive — base Docker registries, cluster names, feature flags, notification channels. This keeps secrets small and auditable, and you can change non-secret configuration without rotating credentials.
The if: Key — Conditional Execution
Every job and every step accepts an if: key. When the expression evaluates to falsy, GitHub skips that job or step entirely — it appears as "skipped" in the UI. The default implicit condition is success(), meaning jobs and steps only run when all previous steps in the same job (or all needed jobs) succeeded.
The four status-check functions are critical to understand:
success()— true when every prior step/job finished without error (default).failure()— true when at least one prior step/job failed. Use this for notification or cleanup steps.always()— true regardless of prior outcome, even if the workflow was cancelled. Use for teardown steps that must run no matter what.cancelled()— true only when the workflow run was explicitly cancelled.
if: failure() on a step that also needs an output from a previous step. If the previous step failed, its output is empty or undefined — and your failure handler may itself error. Always scope failure handlers to the minimal data they actually need.
Practical Patterns
The following workflow demonstrates all three concepts together — expressions, multiple contexts, and conditional steps — in a realistic production scenario: a deployment that only runs on main, notifies Slack on failure, and always uploads logs as artifacts.
Notice the interplay: vars.* holds environment-specific but non-sensitive config; secrets.* holds the IAM role ARN and webhook URL; github.* drives the conditional logic; and steps.* outputs thread the image URI between jobs.
Checking Step Outcomes
Each step has an outcome property: success, failure, cancelled, or skipped. You can branch on it explicitly when you need fine-grained control:
The continue-on-error: true flag lets a step fail without immediately killing the job — you capture the outcome and decide what to do next. This is useful for flaky test suites where you want to upload results before halting.
Expression Functions in Practice
Two functions deserve special attention at scale. hashFiles('**/package-lock.json') returns a deterministic hash of the matched files — ideal as a cache key that invalidates only when dependencies actually change. fromJson() and toJson() let you pass structured data between steps and jobs, bridging the string-only boundary of GITHUB_OUTPUT.
contains(github.event.pull_request.labels.*.name, 'skip-ci') to let engineers label a PR and bypass expensive jobs. The *.name wildcard flattens an array of label objects to their names — a clean idiom you will see in production workflows at scale.