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.
Learning objectives
- Choose between GitHub expression evaluation and shell/runtime logic according to trust, timing, portability, and observability.
-
Choose job-level versus step-level
ifbased 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.
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.
Knowledge check
Why is a job-level if often better for a manual
feature flag than skipping every step?
The decision can be made before runner allocation, reducing queue/cost and making the job-admission evidence explicit.
Why is
github.event.inputs.deploy == 'true' usually
inferior to inputs.deploy?
The typed inputs context already carries the
Boolean contract; converting it into string logic adds
ambiguity.
What is the safe boundary for attacker-controlled PR title text used by Bash?
Pass the expression value through an environment variable or program argument, then quote it at runtime; do not inject it directly into the generated shell script.
A job-level condition uses runner.os. Is quoting
the expression a fix?
No. The context is unavailable at that lifecycle phase; move the decision to a step after runner assignment or redesign the earlier condition.
Why should production conditions avoid relying on loose equality?
Implicit numeric coercion, empty-string behavior,
NaN, and case-insensitive comparison can make
intent hard to review and surprising under changed inputs.
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 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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.