Chapter 04Lesson 02~150 minutes

Contexts, Expressions, Functions, Conditionals, and Evaluation Semantics: Guided Hands-On Workflow

This guided workflow turns the mental model into observable evidence. A disposable manual workflow exposes typed inputs, records selected context fields, creates string outputs, converts them deliberately, branches at both job and step scope, and then induces a controlled failure so success(), failure(), and !cancelled() can be compared from the same run history.

Hands-onTyped inputsfromJSON / toJSONStep outputsStatus functions

Learning objectives

  • Create typed workflow_dispatch inputs and prove the difference between inputs and github.event.inputs.
  • Inspect github, runner, job, and steps context fields without whole-context dumps.
  • Produce string outputs and normalize them with fromJSON before Boolean/numeric decisions.
  • Use toJSON only on synthetic bounded data rather than secrets or whole contexts.
  • Observe status functions and step conclusions after an intentional failure while preserving run evidence.

1. Disposable lab and exact assumptions

Item Chapter 04 lab assumption
Repository Learner-owned disposable repository; workflow on default branch so manual dispatch is available.
Runner ubuntu-24.04 GitHub-hosted runner.
Permissions permissions: {}; no GitHub API mutation.
Actions None; built-in shell steps only.
Secrets None. All values are synthetic.
Side effects Actions run history and logs only.

Record the repository, workflow path, workflow commit SHA, and expected input values before dispatch. The lab is intentionally small so every expression can be explained.

2. Build the workflow progressively

name: Chapter 04 expression lab

on:
  workflow_dispatch:
    inputs:
      enabled:
        description: 'Run the main job'
        type: boolean
        required: true
        default: true
      force_failure:
        description: 'Fail one step intentionally'
        type: boolean
        required: true
        default: false
      mode:
        description: 'Synthetic operating mode'
        type: choice
        required: true
        options: [quick, full]
      threshold:
        description: 'Numeric threshold'
        type: number
        required: true
        default: 3

permissions: {}

defaults:
  run:
    shell: bash

jobs:
  inspect:
    if: ${{ inputs.enabled }}
    runs-on: ubuntu-24.04
    steps:
      - name: Record typed and string views
        env:
          TYPED_ENABLED: ${{ inputs.enabled }}
          EVENT_ENABLED: ${{ github.event.inputs.enabled }}
          MODE: ${{ inputs.mode }}
          THRESHOLD: ${{ inputs.threshold }}
          EVENT_NAME: ${{ github.event_name }}
          REF: ${{ github.ref }}
          SHA: ${{ github.sha }}
          RUNNER_OS: ${{ runner.os }}
        run: |
          printf 'typed_enabled=%s event_enabled=%s\n' "$TYPED_ENABLED" "$EVENT_ENABLED"
          printf 'mode=%s threshold=%s event=%s\n' "$MODE" "$THRESHOLD" "$EVENT_NAME"
          printf 'ref=%s sha=%s runner_os=%s\n' "$REF" "$SHA" "$RUNNER_OS"

      - name: Produce string outputs
        id: producer
        run: |
          echo 'count=4' >> "$GITHUB_OUTPUT"
          echo 'flag=true' >> "$GITHUB_OUTPUT"
          echo 'payload={"source":"chapter04","retries":2,"safe":true}' >> "$GITHUB_OUTPUT"

      - name: Use explicit conversion
        if: ${{ fromJSON(steps.producer.outputs.count) >= inputs.threshold }}
        env:
          COUNT_TEXT: ${{ steps.producer.outputs.count }}
          FLAG_TYPED: ${{ fromJSON(steps.producer.outputs.flag) }}
          PAYLOAD_JSON: ${{ toJSON(fromJSON(steps.producer.outputs.payload)) }}
        run: |
          printf 'count_text=%s flag_typed=%s\n' "$COUNT_TEXT" "$FLAG_TYPED"
          printf 'synthetic_payload=%s\n' "$PAYLOAD_JSON"

      - name: Deliberate failure
        id: deliberate
        if: ${{ inputs.force_failure }}
        run: |
          echo 'intentional failure for status-function evidence'
          exit 7

      - name: Failure-specific evidence
        if: ${{ failure() && steps.deliberate.conclusion == 'failure' }}
        run: echo 'failure path observed deliberately'

      - name: Happy-path evidence
        if: ${{ success() }}
        run: echo 'all previous effective steps succeeded'

      - name: Preserve final bounded evidence unless cancelled
        if: ${{ !cancelled() }}
        env:
          PRODUCER_OUTCOME: ${{ steps.producer.outcome }}
          PRODUCER_CONCLUSION: ${{ steps.producer.conclusion }}
          DELIBERATE_OUTCOME: ${{ steps.deliberate.outcome }}
          DELIBERATE_CONCLUSION: ${{ steps.deliberate.conclusion }}
        run: |
          printf 'producer=%s/%s deliberate=%s/%s\n' \
            "$PRODUCER_OUTCOME" "$PRODUCER_CONCLUSION" \
            "$DELIBERATE_OUTCOME" "$DELIBERATE_CONCLUSION"

The workflow has no checkout step because no repository file is needed. That keeps the exercise centered on dataflow and avoids introducing an action dependency merely for convention.

3. Run A — successful path

Dispatch with enabled=true, force_failure=false, mode=quick, threshold=3. Before pressing Run, predict:

  • The job is admitted because inputs.enabled is Boolean true.
  • The conversion step runs because numeric 4 is greater than or equal to numeric 3.
  • The deliberate-failure step is skipped.
  • The failure-specific step is skipped.
  • The success() step runs.
  • The final !cancelled() evidence step runs.

Record the run ID/attempt, exact workflow SHA, input values, runner OS, producer output strings, step conclusions, and final job conclusion.

4. Prove typed input behavior

The workflow prints both views of the same manual Boolean. inputs.enabled preserves Boolean typing inside expressions. github.event.inputs.enabled is the event-payload representation and its Boolean value is represented as a string. The shell log may render both as the text true, so the log text alone does not prove the expression type; the different conditional behavior in the next lessons is stronger evidence.

Rule: when a typed inputs value exists, use it as the contract. Do not downgrade it to a string and reconstruct meaning later unless an integration requires that representation.

5. Outputs are strings: normalize at the decision boundary

GITHUB_OUTPUT is a data-transfer mechanism. The steps.producer.outputs.count expression is a string even though its text is 4. The lab converts it with fromJSON before comparing it to the typed numeric manual input.

The same principle applies to Boolean text. A producer may write flag=true; the downstream expression should use fromJSON(steps.producer.outputs.flag) when it requires Boolean semantics. Do not rely on non-empty-string truthiness.

6. Safe fromJSON/toJSON: serialize synthetic bounded data

The producer emits a hard-coded JSON object. fromJSON converts that string into an expression object and toJSON serializes the bounded object for readable evidence. This demonstrates the functions without touching secrets or the whole github context.

Do not generalize this into “print contexts with toJSON.” The full github context can contain sensitive values such as the token, while event fields can contain attacker-controlled text. Serialization is not sanitization.

7. Run B — intentional failure and status functions

Dispatch the same workflow with force_failure=true. Predict the divergence before execution:

  • The producer and conversion steps run normally.
  • The deliberate step exits 7 and records a failure.
  • A later step whose condition is only a business expression would inherit the default success() status gate and be suppressed.
  • The explicit failure() && steps.deliberate.conclusion == 'failure' step runs.
  • The explicit success() step does not run.
  • The !cancelled() evidence step still runs because the run failed but was not cancelled.
  • The job remains failed; observing the failure does not rewrite it into success.

8. steps context: outcome, conclusion, and temporal availability

A named step appears in the steps context after it has executed or been skipped. Its outcome and conclusion let later steps reason about what happened. If continue-on-error is used, the distinction can matter because conclusion reflects post-policy handling. This chapter does not hide failures with continue-on-error; it keeps the causal failure visible.

Referencing a step output before its producer is another lifecycle error. The YAML parser cannot make future runtime data exist earlier.

9. Mini challenge — locate the owning layer

Predict the result of each change before trying it:

  1. Set threshold=6. Which step is skipped and why?
  2. Set enabled=false. Does a runner get assigned to inspect?
  3. Replace fromJSON(steps.producer.outputs.count) with the raw output. Is the condition still reviewable even if loose coercion makes it appear to work?
  4. Move if: runner.os == 'Linux' to the job level. Which phase makes that invalid?

Write your answer in terms of context availability/type/evaluation phase, not “GitHub is weird.”

Next lesson

Design patterns and trade-offs

Now choose deliberately between expression-time and shell-time logic, job and step conditions, typed inputs and strings, and early versus runtime data.

Knowledge check

Run A has force_failure=false. Why is the deliberate step skipped without failing the job?

Why does the conversion step use fromJSON(steps.producer.outputs.count)?

After the deliberate failure, why does the failure-evidence step run?

Why does !cancelled() still run after a normal failure?

Does printing true from both inputs.enabled and github.event.inputs.enabled prove they have the same type?

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 lab uses only synthetic outputs and bounded context fields. No whole-context dump, secret, repository checkout, or external action is necessary.

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.