Contexts, Expressions, Functions, Conditionals, and Evaluation Semantics: Core Concepts and Mental Model
Chapters 01–03 established how an event selects a workflow and creates a run. Chapter 04 now opens the evaluation engine inside that run: GitHub builds typed context objects, evaluates expressions at specific lifecycle phases, and uses the results to decide job admission, step execution, configuration values, and dataflow. The goal is not to memorize punctuation; it is to be able to explain exactly which value existed, where it was available, what type/coercion applied, and why a condition evaluated the way it did.
Learning objectives
- Model expression evaluation as typed dataflow from event/configuration/prior-result state into job and step behavior.
-
Distinguish major contexts such as
github,inputs,vars,env,job,runner,steps, andneedsby lifecycle availability. - Explain literals, truthiness, loose equality, missing properties, and why string-valued outputs often require explicit conversion.
- Recognize the difference between GitHub expression evaluation and later shell/environment expansion on the runner.
- Inspect only bounded context fields needed for evidence instead of dumping sensitive or attacker-controlled objects.
1. The practical problem: “Why did this condition run?”
Trigger design tells you why a workflow run exists. Expression design tells you why the run took one path instead of another. A production incident often sounds like “the deploy job unexpectedly ran,” “the cleanup step never executed after failure,” or “the boolean input was false but the condition was true.” Those are not generic YAML problems. They are dataflow problems.
To diagnose them, preserve four facts: the expression source, the contexts available at that workflow key, the runtime value and type, and the status state that GitHub implicitly or explicitly adds. Without those facts, changing punctuation until the workflow turns green is guesswork.
2. Mental model: state becomes contexts; contexts become decisions
flowchart TD A[Event + workflow metadata] --> B[Early contexts: github inputs vars needs] B --> C[Expression evaluation] C --> D[Job admitted or skipped] D --> E[Runner assigned] E --> F[Runtime contexts: runner job steps env] F --> G[Step expressions + shell environment] G --> H[Outputs conclusions evidence]
The important boundary is between
GitHub-side evaluation and
runner-side execution. A job-level
if can be evaluated before any runner is assigned, so
it cannot depend on runner-only state such as
runner.os. A step-level condition runs later, when the
job and previous step records exist. The same expression syntax
therefore does not imply the same data is available everywhere.
3. Contexts are typed objects with scope, not global dictionaries
| Context | What it represents | When it becomes useful | Beginner pitfall |
|---|---|---|---|
github |
Run/event/workflow/repository metadata. | Available broadly, including many pre-run expressions. | Treating every field as trusted or dumping the whole object. |
inputs |
Typed manual/reusable-workflow inputs. | workflow_dispatch or reusable workflows. |
Replacing it with github.event.inputs and
losing Boolean typing.
|
vars |
Configuration variables. | Policy/configuration values available before runner execution in many keys. | Confusing configuration variables with secrets. |
env |
Workflow/job/step environment values defined in workflow configuration. | Step expressions and runtime shell environment. | Assuming it contains every default runner variable. |
needs |
Direct dependency job results/outputs. | Downstream jobs after dependency evaluation. | Assuming it contains every transitive ancestor automatically. |
runner |
Assigned runner state such as OS/arch/temp paths. | After a job is routed to a runner. |
Using it in a job-level if where it is
unavailable.
|
job |
Current job state. | During job/step execution. | Using final job status before the job has reached that state. |
steps |
Earlier/current named step outcomes, conclusions, and outputs. | After referenced steps have executed. | Referencing an output before its producer step or forgetting outputs are strings. |
The authoritative context-availability table in GitHub documentation is part of your production contract. When a context is not listed for a workflow key, changing quotation marks will not make it available.
4. Expressions have types, truthiness, and coercion
GitHub expressions support Boolean, null, number, and string
literals. In a conditional, documented falsy
values—false, numeric zero, empty string, and
null—become false; other values are truthy. This is why
the non-empty string 'false' is dangerous when someone
assumes it behaves like the Boolean false.
Equality is intentionally loose. If operand types differ, GitHub may
coerce them to numbers. For example, null becomes
0, Boolean true becomes 1, empty string
becomes 0, and a non-numeric string can become
NaN. Relational comparisons involving
NaN are false. A workflow that depends on accidental
coercion is difficult to review; normalize types explicitly instead.
| Source | Typical type | Safer interpretation |
|---|---|---|
inputs.enable_demo from Boolean manual input
|
Boolean | Use directly: if: inputs.enable_demo. |
github.event.inputs.enable_demo |
String representation | Prefer inputs when available. |
steps.producer.outputs.count |
String |
Use fromJSON(...) before numeric comparison.
|
| Missing context property | Empty string | Test existence deliberately; do not confuse missing with Boolean false. |
env.RETRY_LIMIT |
String |
Convert with fromJSON when a numeric/Boolean
workflow field needs a typed value.
|
5. Expression syntax and shell syntax are two different languages
${{ ... }} asks GitHub to evaluate an expression. A
shell variable such as $NAME or ${NAME} is
expanded later by the shell running on the assigned runner. Mixing
the two phases can create both correctness and security bugs.
jobs:
explain:
if: ${{ github.ref == 'refs/heads/main' }} # GitHub evaluates this first
runs-on: ubuntu-24.04
steps:
- name: Runtime expansion
env:
SAFE_REF: ${{ github.ref }} # GitHub copies data into env
run: |
printf 'ref=%s\n' "$SAFE_REF" # Bash expands quoted env value
In an if condition the outer
${{ }} delimiters are usually optional. Elsewhere, use
them where the workflow syntax expects an expression. Keep
expression literals and shell quoting rules mentally separate.
6. Functions transform data; they do not erase type questions
Useful built-ins include contains,
startsWith, endsWith, format,
join, toJSON, fromJSON,
hashFiles, and current case selection
syntax. Availability can depend on the workflow key; for example,
hashFiles operates on files in
GITHUB_WORKSPACE and is not a universal pre-run
function.
env:
MODE: ${{ case(inputs.verbose, 'verbose', 'normal') }}
SAFE_LIST: ${{ join(fromJSON('["lint","test"]'), ',') }}
Do not use toJSON as permission to dump entire
contexts. It is a serialization function, not a redaction boundary.
Serialize only synthetic or explicitly allow-listed values.
7. Every conditional also has a status dimension
For if expressions, GitHub applies a default status
check of success() unless your condition includes a
status-check function. That means a step after a failure may not run
even if its business expression is true.
| Function | Meaning | Typical use |
|---|---|---|
success() |
Previous relevant work has succeeded. | Normal happy-path steps. |
failure() |
A previous step/job in the relevant chain failed. | Failure diagnosis/evidence. |
cancelled() |
The workflow was cancelled. | Cancellation-specific handling. |
always() |
Evaluates true even after cancellation/failure. | Narrow step-level evidence tasks; not a universal cleanup hammer. |
!cancelled() |
Run unless cancellation occurred. | Often safer for evidence/cleanup that should follow success or failure but not cancellation. |
8. Read-only inspection: allow-list context fields
name: Chapter 04 context probe
on:
workflow_dispatch:
inputs:
enabled:
description: 'Enable the probe'
type: boolean
required: true
default: true
permissions: {}
jobs:
inspect:
if: ${{ inputs.enabled }}
runs-on: ubuntu-24.04
steps:
- name: Bounded context evidence
env:
EVENT: ${{ github.event_name }}
REF: ${{ github.ref }}
SHA: ${{ github.sha }}
WORKFLOW_SHA: ${{ github.workflow_sha }}
RUNNER_OS: ${{ runner.os }}
RUNNER_ARCH: ${{ runner.arch }}
run: |
printf 'event=%s ref=%s sha=%s workflow_sha=%s\n' "$EVENT" "$REF" "$SHA" "$WORKFLOW_SHA"
printf 'runner_os=%s runner_arch=%s run=%s attempt=%s\n' \
"$RUNNER_OS" "$RUNNER_ARCH" "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT"
This is enough to prove event/workflow/runner identity without
printing the whole github, secrets, or
event payload objects.
9. Production rule: make conditions reviewable as dataflow
A reviewer should be able to read a condition and answer: where did every value come from, what type does it have, at which lifecycle phase is it available, is any field attacker-controlled or sensitive, what status function is implicit/explicit, and what observable state changes if the condition is true? If those questions cannot be answered, the condition is too magical.
Knowledge check
Why can runner.os work in a step but not in a
job-level if?
The job-level condition is evaluated before runner assignment;
the runner context belongs to the later execution
phase.
A step output contains "5". Why should a numeric
comparison use fromJSON?
Step outputs are strings. Explicit conversion makes the numeric intent reviewable and avoids relying on loose coercion.
What does a nonexistent context property evaluate to?
Current GitHub documentation says a nonexistent property evaluates to an empty string.
Why is the string "false" risky in an
if?
It is a non-empty string and therefore truthy; it is not the
Boolean value false.
A failure-diagnostic step has
if: steps.test.conclusion == 'failure' but never
runs after the test fails. What is missing?
A status check such as failure(); otherwise the
default success() gate suppresses the step after
failure.
Official references and version notes
-
Evaluate expressions in workflows and actions
— current literals, truthiness, loose equality/coercion, built-in
functions,
fromJSON/toJSON, and status-check functions. -
Contexts reference
— current context objects, missing-property behavior, per-key
context availability, and
steps/runner/inputssemantics. -
Workflow syntax for GitHub Actions
— current
if,workflow_dispatchinput types, environment scopes, job/step syntax, and permission placement. - Script injections — why attacker-controlled context text must not be substituted directly into generated shell scripts.
- Secure use reference — current safe handling guidance for secrets, untrusted context values, permissions, and workflow code.
- Variables reference — distinction between default runner environment variables and expression contexts.
Version-sensitive expression/context behavior was rechecked
against current primary GitHub documentation on
2026-09-09. Executable labs use a disposable
repository, ubuntu-24.04, built-in shell steps only,
and explicit permissions: {}; no external action,
secret, package, deployment, self-hosted runner, or cloud account
is required. At verification time, expression literals include
booleans/null/numbers/strings; conditionals coerce documented
falsy values to false; equality is loose and can numerically
coerce mismatched types; nonexistent context properties evaluate
to an empty string; steps.*.outputs.* values are
strings; manual-workflow values in the inputs context
preserve Boolean typing while
github.event.inputs represents Boolean values as
strings; and a default success() status check applies
to if expressions unless a status-check function is
present. Current docs also caution against broad
always() use for critical tasks and recommend
!cancelled() when appropriate. GitHub Actions is
continuously delivered, so these semantics and
context-availability tables must be rechecked when regenerating
the lesson. The chapter intentionally uses no third-party action
so context/expression semantics remain visible without
action-runtime or supply-chain dependencies.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.