Chapter 04Lesson 03~125 minutes

Contexts, Expressions, Functions, Conditionals, and Evaluation Semantics: Configuration, Design Patterns, and Trade-Offs

Expression syntax becomes maintainable only when teams choose the right evaluation boundary. This lesson compares GitHub-side expressions with runner-side shell logic, job-level admission with step-level branching, typed inputs with string reconstruction, and early configuration decisions with runtime environment state. The objective is not fewer conditions; it is conditions whose ownership and evidence are obvious.

Design patternsJob vs step ifTyped boundariesCoercionTrust

Learning objectives

  • Choose between GitHub expression evaluation and shell/runtime logic according to trust, timing, portability, and observability.
  • Choose job-level versus step-level if based on whether runner allocation and runtime contexts are required.
  • Prefer typed inputs over string assumptions and normalize string outputs/environment values at explicit boundaries.
  • Explain loose coercion, missing values, and case-insensitive string comparisons without relying on accidental behavior.
  • Use a decision table to design reviewable conditional automation with a clear failure/rollback path.

1. The design question: who should evaluate this decision?

A condition can be evaluated by GitHub before runner allocation, by the Actions runner while preparing a step, or by the shell/program inside the step. Each location has different context availability and trust implications. Put the decision at the earliest layer that has all required trusted data—but not earlier.

Decision Good boundary Reason
Should this job exist for manual input? Job-level expression using typed inputs. Avoids allocating a runner for a known false condition.
Is the assigned runner Linux? Step-level expression using runner.os. Runner context exists only after assignment.
Does generated test output contain a domain-specific condition? Program/script, then emit a small output. Complex parsing belongs in code, not a giant workflow expression.
Does an attacker-controlled PR title match a pattern? Pass to a safe action/program argument or quoted environment variable. Do not substitute untrusted text into generated shell source.

2. Expression interpolation versus shell interpolation

GitHub expressions are evaluated before a run script is handed to the shell. If attacker-controlled text is embedded directly into that script, the text can change the script itself.

# WRONG for untrusted PR titles
- name: Unsafe title check
  run: |
    title="${{ github.event.pull_request.title }}"
    printf '%s\n' "$title"

The shell never receives an abstract “value”; it receives a generated script after GitHub substitutes the expression. A malicious title containing shell syntax can therefore alter the script.

# SAFER boundary
- name: Safe title check
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: |
    printf '%s\n' "$PR_TITLE"

The value is data in the environment and is quoted when the shell expands it. Even better, a dedicated action/program can receive untrusted text as an argument and avoid shell parsing entirely.

3. Job-level versus step-level if

Dimension Job-level condition Step-level condition
Evaluation timing Before job is sent to a runner. Inside an admitted/running job.
Cost/queue False condition avoids runner allocation. Runner is already allocated.
Available data Broad metadata such as github, inputs, direct needs, vars; not runner/steps state. Can use runner, job, steps, env, and other step-time contexts allowed by the reference table.
Use case Coarse admission: “should this job run?” Fine runtime branching: “given what happened here, should this step run?”

A common anti-pattern is admitting an expensive job and then skipping every step because a decision that could have been made before runner allocation was placed too late.

4. Typed inputs versus string assumptions

For a manually triggered workflow, define the contract in workflow_dispatch.inputs. The inputs context preserves supported types such as Boolean and number. Prefer this:

if: ${{ inputs.deploy_preview && inputs.replicas >= 2 }}

over reconstructing a Boolean contract from event strings:

# Fragile and needlessly string-oriented
if: ${{ github.event.inputs.deploy_preview == 'true' }}

The second version can be necessary when interoperating with a string-only source, but it should be a deliberate adapter, not the default mental model.

5. Loose equality is a compatibility feature, not a design strategy

GitHub ignores case when comparing strings and performs loose equality. Type mismatches can trigger numeric coercion. That can produce surprising matches: an empty string and numeric zero can participate in the same numeric comparison path. Non-numeric strings can become NaN, making relational comparisons false.

Production rule: normalize data once at the boundary. Convert a numeric string with fromJSON, keep typed manual inputs typed, and avoid comparisons whose correctness depends on implicit conversion.

6. Missing data and null-like meaning are not the same thing

A nonexistent context property evaluates to an empty string. That is convenient for optional payload fields but dangerous if an empty string is also a valid business value. Make “missing” a documented state when it matters.

- name: Explain optional field
  env:
    LABEL: ${{ github.event.pull_request.head.label }}
    OPTIONAL_FIELD: ${{ github.event.client_payload.optional_field }}
  run: |
    if [[ -z "$OPTIONAL_FIELD" ]]; then
      echo 'optional field is absent or empty; business contract must distinguish if needed'
    fi

Do not silently interpret every empty string as false, zero, or “not applicable.” Decide what the integration contract means.

7. Early expression evaluation versus runtime environment expansion

Default environment variables such as GITHUB_REF exist on the runner. They are not entries in the workflow-defined env context. For a job-level condition, use the corresponding github.ref context. Inside a shell step, use the runner environment variable normally.

jobs:
  main-only:
    if: ${{ github.ref == 'refs/heads/main' }}
    runs-on: ubuntu-24.04
    steps:
      - run: printf 'runtime GITHUB_REF=%s\n' "$GITHUB_REF"

This is not duplicate data by accident; it reflects two lifecycle surfaces.

8. Selection logic: prefer explicit current functions over clever chains

Current expression reference includes case(), which can make multi-branch selection clearer than deeply nested Boolean chains. If you use the older condition && valueA || valueB idiom, remember that a falsy valueA can cause the fallback branch to win unexpectedly.

env:
  TARGET_TIER: ${{ case(
    github.ref == 'refs/heads/main', 'production',
    startsWith(github.ref, 'refs/heads/release/'), 'staging',
    'development'
  ) }}

Because GitHub is continuously delivered, treat newer expression functions as version-sensitive and re-check current platform support before depending on them in long-lived templates.

9. Decision table for maintainable conditions

Scenario Recommended pattern Evidence Trade-off
Manual feature flag Typed Boolean inputs at job-level if. Dispatch input + skipped/admitted job. Manual-only contract; caller must supply/accept default.
Runtime OS branch Step-level if: runner.os == 'Linux'. Runner metadata + step conclusion. Runner already allocated.
Numeric output threshold fromJSON(steps.x.outputs.count) >= inputs.threshold. Raw string output + typed threshold + condition result. Producer output must be valid JSON number text.
Untrusted title/text Environment/argument boundary; quote in shell. Bounded input echo/hash + program result. Requires careful program/shell handling.
Failure evidence failure() && steps.test.conclusion == 'failure'. Original failure + diagnostic step. Diagnostic must not mutate/erase original cause.

10. Review checklist before merging a condition

  • Every referenced context is available at this exact workflow key.
  • Every value's type is known or explicitly converted.
  • No condition depends on accidental loose coercion.
  • Untrusted event text never becomes generated shell source.
  • Sensitive contexts are not logged for convenience.
  • Status behavior is explicit after failures.
  • Job-level decisions are used when they can avoid unnecessary runner work.
  • Runtime-only state is not referenced before it exists.
  • Failure/skip evidence remains distinguishable from a run that never existed.
Next lesson

Diagnostics and production failure modes

Break each boundary intentionally: injection, Boolean/string confusion, implicit success, unavailable contexts, context dumping, and quoting errors—then repair the causal layer.

Knowledge check

Why is a job-level if often better for a manual feature flag than skipping every step?

Why is github.event.inputs.deploy == 'true' usually inferior to inputs.deploy?

What is the safe boundary for attacker-controlled PR title text used by Bash?

A job-level condition uses runner.os. Is quoting the expression a fix?

Why should production conditions avoid relying on loose equality?

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 lesson mentions current case() expression support as version-sensitive functionality; re-check the current expression reference before adopting it in shared templates.

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.