Chapter 04Lesson 04~145 minutes

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.

DiagnosticsInjectionImplicit successContext availabilitySafe logging

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

  1. Preserve the run ID/attempt and first failing/skipped job/step evidence.
  2. Confirm event/ref/SHA and exact workflow revision (github.workflow_sha / repository commit).
  3. Copy the exact expression text before editing it.
  4. Identify the workflow key: job if, step if, env, with, run, etc.
  5. Check the authoritative context-availability table for that key.
  6. Record the real source type: Boolean, number, string, missing/empty, step-output string, untrusted event text, or secret.
  7. Account for implicit success() or explicit status functions.
  8. Separate GitHub expression substitution from runner shell expansion.
  9. 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.os at 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 secrets or 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.
Next lesson

Checkpoint: predict every path

Use typed manual inputs and prior step results to produce a small path matrix, then expose and repair one deliberately wrong string-based condition.

Knowledge check

Why does adding more shell quotes around ${{ github.event.pull_request.title }} not fully fix injection?

Why can github.event.inputs.enabled == true fail even when the UI input is true?

Why does steps.test.conclusion == 'failure' alone not guarantee a post-failure step executes?

A job if uses runner.os. Should you switch runners?

Why is toJSON(github) not a safe generic debugging technique?

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/inputs semantics.
  • Workflow syntax for GitHub Actions — current if, workflow_dispatch input 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 and compatibility note

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.