Contexts, Expressions, Functions, Conditionals, and Evaluation Semantics: Diagnostics, Failure Modes, and Production Practices
Expression failures are often invisible until a condition selects the wrong path or a diagnostic step is skipped. This lesson engineers six common failures deliberately and diagnoses them from the correct layer: trust boundary, type/coercion, status state, context availability, disclosure boundary, and expression-versus-shell quoting. The original run and first-failure evidence remain intact throughout.
Learning objectives
- Diagnose direct untrusted-context interpolation as a script-generation security failure rather than a quoting typo.
- Diagnose Boolean/string and loose-coercion mistakes from the actual input types.
-
Recognize implicit
success()as the reason failure-handling conditions can be suppressed. - Identify context-availability failures without moving unrelated runner/action settings.
- Repair logging/quoting mistakes while preserving the original run ID, attempt, expression source, and failing evidence.
1. Evidence-first diagnostic sequence
- Preserve the run ID/attempt and first failing/skipped job/step evidence.
-
Confirm event/ref/SHA and exact workflow revision (
github.workflow_sha/ repository commit). - Copy the exact expression text before editing it.
-
Identify the workflow key: job
if, stepif,env,with,run, etc. - Check the authoritative context-availability table for that key.
- Record the real source type: Boolean, number, string, missing/empty, step-output string, untrusted event text, or secret.
-
Account for implicit
success()or explicit status functions. - Separate GitHub expression substitution from runner shell expansion.
- Apply the smallest correction, rerun the smallest equivalent scope, and compare evidence.
Do not “fix” expression failures by granting wider tokens, changing runners, deleting run history, or replacing the event. Those changes alter unrelated layers and destroy causal clarity.
2. Failure A — untrusted context becomes shell source
# Intentionally unsafe teaching example — do not keep
- name: Check PR title
run: |
title="${{ github.event.pull_request.title }}"
if [[ "$title" == docs:* ]]; then
echo 'docs PR'
fi
A malicious title can contain shell syntax. GitHub substitutes the expression before Bash parses the temporary script, so the attacker can alter the script structure. This is not solved by adding more nested quotes around the expression.
- name: Check PR title safely
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
if [[ "$PR_TITLE" == docs:* ]]; then
echo 'docs PR'
fi
For more complex parsing, pass the title to a dedicated program/action argument. Treat branch names, PR titles/bodies, issue text, labels, emails, and other user-controlled event fields as untrusted data.
3. Failure B — Boolean input reconstructed as a string
on:
workflow_dispatch:
inputs:
enabled:
type: boolean
required: true
default: false
jobs:
broken:
if: ${{ github.event.inputs.enabled == true }}
runs-on: ubuntu-24.04
steps:
- run: echo 'expected to run when enabled=true'
The event-input representation is a string while
true is a Boolean. Loose comparison invokes numeric
coercion; the string 'true' is not a legal JSON number
and becomes NaN, so the equality does not represent the
intended Boolean contract.
jobs:
fixed:
if: ${{ inputs.enabled }}
runs-on: ubuntu-24.04
steps:
- run: echo 'typed Boolean contract'
An even worse variant is
if: github.event.inputs.enabled: both string
'true' and string 'false' are non-empty
and therefore truthy.
4. Failure C — the diagnostic condition forgets implicit
success()
steps:
- name: Deliberate failure
id: test
run: exit 2
- name: Broken failure diagnosis
if: ${{ steps.test.conclusion == 'failure' }}
run: echo 'this may be suppressed by the default status gate'
Because the condition contains no status-check function, GitHub
applies the default success() check. After a failing
previous step, the effective condition is not simply “conclusion
equals failure.”
- name: Correct failure diagnosis
if: ${{ failure() && steps.test.conclusion == 'failure' }}
run: echo 'preserve and explain the original failure'
Do not change the failing step to
continue-on-error: true just to make the diagnostic
execute. That changes failure policy rather than fixing the
condition.
5. Failure D — context referenced before it exists
jobs:
broken:
if: ${{ runner.os == 'Linux' }}
runs-on: ubuntu-24.04
steps:
- run: echo 'Linux-only behavior'
The job condition is evaluated before runner assignment;
runner is not an allowed job-level
if context. The correct design depends on intent:
-
If the runner is statically
ubuntu-24.04, the condition is redundant—remove it. -
If a later matrix/runner choice makes OS dynamic, use the allowed
pre-run configuration data or evaluate
runner.osat step scope after assignment.
6. Failure E — context dumping creates a disclosure boundary problem
# Do not use as a generic debugging pattern
- env:
EVERYTHING: ${{ toJSON(github) }}
run: printf '%s\n' "$EVERYTHING"
The github context can include sensitive values such as
github.token, and event subfields can contain
attacker-controlled content. Masking is not a reason to
intentionally log it. Build an allow-list:
- env:
EVENT: ${{ github.event_name }}
REF: ${{ github.ref }}
SHA: ${{ github.sha }}
WORKFLOW_SHA: ${{ github.workflow_sha }}
run: printf 'event=%s ref=%s sha=%s workflow_sha=%s\n' "$EVENT" "$REF" "$SHA" "$WORKFLOW_SHA"
7. Failure F — expression quoting and shell quoting are confused
Inside ${{ }}, string literals use GitHub expression
rules. Inside Bash, quoting follows Bash rules. The following
patterns solve different problems:
| Layer | Example | Purpose |
|---|---|---|
| Expression literal | github.ref == 'refs/heads/main' |
GitHub compares two expression strings. |
| Expression-to-env | REF: ${{ github.ref }} |
GitHub transfers data as an environment value. |
| Bash runtime | printf '%s\n' "$REF" |
Bash safely expands the environment variable as data. |
Adding shell escaping characters inside a GitHub expression can make the expression invalid; adding expression quotes around a shell variable does nothing because the shell runs later.
8. Controlled broken workflow
In a disposable repository, commit a workflow containing only the Boolean/string failure and implicit-success failure. Do not include the script-injection example in a privileged/untrusted workflow; it is shown above for analysis and should remain inert course text.
name: Chapter 04 diagnostic lab
on:
workflow_dispatch:
inputs:
enabled:
type: boolean
required: true
default: true
permissions: {}
jobs:
wrong-type:
if: ${{ github.event.inputs.enabled == true }}
runs-on: ubuntu-24.04
steps:
- run: echo 'unexpectedly skipped due to string/Boolean mismatch'
status-demo:
runs-on: ubuntu-24.04
steps:
- id: test
run: exit 2
- name: Broken diagnostic
if: ${{ steps.test.conclusion == 'failure' }}
run: echo 'does not override implicit success'
Capture the first run. Then create a second workflow revision
replacing the job condition with inputs.enabled and the
step condition with
failure() && steps.test.conclusion == 'failure'.
Compare run IDs, workflow SHAs, skipped/executed states, and final
job conclusions.
9. Production practices
- Treat context-availability tables as API contracts.
- Keep typed values typed; convert strings once at well-defined boundaries.
- Assume event/user text is untrusted until safely passed as data.
-
Never print
secretsor whole broad contexts to diagnose a condition. - Use status functions deliberately after failures and cancellation.
- Preserve the original failing run before rerunning; a rerun is new evidence, not a replacement.
- Do not broaden token permissions, switch to privileged triggers, or use trusted self-hosted runners to “fix” expression logic.
Knowledge check
Why does adding more shell quotes around
${{ github.event.pull_request.title }} not fully
fix injection?
The expression is substituted before the shell parses the generated script. The safe boundary is to pass untrusted data via an environment variable or program argument.
Why can github.event.inputs.enabled == true fail
even when the UI input is true?
The event-input Boolean is represented as a string while
true is Boolean; loose numeric coercion does not
preserve the intended Boolean contract.
Why does steps.test.conclusion == 'failure' alone
not guarantee a post-failure step executes?
Without a status-check function, GitHub implicitly applies
success(), suppressing the step after failure.
A job if uses runner.os. Should you
switch runners?
No. The problem is context availability before runner assignment; redesign the expression at the correct lifecycle phase.
Why is toJSON(github) not a safe generic debugging
technique?
Serialization is not redaction. The broad context can include sensitive token data and attacker-controlled fields; log only allow-listed evidence.
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 security anti-patterns are inert examples. The
executable diagnostic lab uses only typed manual input and an
intentional local shell failure; no untrusted fork code or
credential is introduced.
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.