Chapter 14Lesson 04~170 minutes

Workflow Triggers, Filters, Expressions, Contexts, Variables, Inputs, and Outputs: Diagnostics, Failure Modes, Security, and Performance

When a workflow does not appear, a step is skipped, or a value is empty, the temptation is to add logging and broaden access. That can make the incident worse. You will diagnose these failures by separating event eligibility, expression evaluation, context availability, secret policy, and output dependencies before changing the workflow.

DiagnosticsScript injectionCoercionOutput failures

Learning objectives

  • Diagnose a missing run by proving event, ref, branch/path filter, workflow location/default-branch state, and changed-file scope before editing YAML.
  • Diagnose expression failures caused by string/number/Boolean coercion and absent context properties.
  • Explain secret availability failures without printing or weakening secret controls.
  • Identify script injection when untrusted event text is rendered directly into shell source and replace it with data-safe handling.
  • Repair broken step/job output contracts from explicit producer and needs evidence.

Safety boundary: All runnable examples are read-only or operate on a disposable public repository. No real secrets are created or displayed. Privileged pull_request_target, self-hosted runner registration, broad token grants, force pushes, and repository deletion are excluded from the mandatory path.

1. Diagnostic sequence: locate the missing contract layer

Use one consistent order: preserve evidence → identify repository/account/event/ref/workflow/run/job scope → inspect filters/contexts/permissions/logs/API → choose least-destructive correction → verify independently.

Symptom First evidence Do not do first
No run exists gh workflow list, event/ref, workflow default-branch presence, trigger filters Do not add broad events such as push to every branch.
Run exists but job skipped Run event + job/step conclusions + if: inputs/contexts Do not rerun until you know which expression evaluated false.
Value empty Producer step output + job output map + needs declaration Do not replace outputs with global mutable variables.
403 / missing secret Event trust boundary + permissions + secret availability policy Do not print token/secret or grant write-all.
Shell behaves strangely Rendered data sources + quoting + untrusted context flow Do not echo entire contexts to “see everything.”

2. Failure mode: filters combine differently than expected

Suppose a developer expects this workflow to run for either a release branch or a service path:

on:
  push:
    branches: ['release/**']
    paths: ['service/**']

That expectation is wrong. Current GitHub workflow syntax requires both filters. Preserve the push branch and changed paths, then compare each to the patterns. For a missing run there may be no job log; the absence itself is evidence that scheduler eligibility failed.

REPO="OWNER/DISPOSABLE"
SHA="EXPECTED_PUSH_SHA"

gh run list -R "$REPO" --event push --commit "$SHA" --limit 10   --json databaseId,event,headBranch,headSha,status,conclusion,url

gh api -H "X-GitHub-Api-Version: 2026-03-10"   "repos/$REPO/commits/$SHA" --jq '{sha,files:[.files[].filename]}'

Correction depends on intent. If either condition should trigger, split them into separate events/workflows or redesign the predicate. Do not pretend YAML offers an OR between branch and path filters when it does not.

3. Failure mode: expression coercion makes a comparison surprising

GitHub uses loose equality and numeric coercion for different types. An empty string can coerce to zero. Step outputs are strings. Consider this fragile condition:

- id: count
  run: echo "value=" >> "$GITHUB_OUTPUT"

# Fragile: empty string can participate in coercion unexpectedly.
- if: ${{ steps.count.outputs.value == 0 }}
  run: echo "looks like numeric zero"

Safer design validates the value before conversion. If it should be JSON number text, assert non-empty/format and then use fromJSON(). Treat failed parsing as invalid input rather than falling through to a default that could authorize work.

4. Failure mode: a secret is unavailable in this event/context

An unset secret expression resolves to an empty string, and secrets are withheld in important untrusted-fork scenarios. GitHub also does not allow the secrets context directly in every workflow key such as an if: expression. Do not diagnose by printing the secret.

Broken policy assumption: “The secret exists in repository settings, therefore every event can use it.” That is false. Availability depends on event trust, fork origin, environment approval, repository/organization policy, and whether the secret is declared/forwarded into a reusable workflow.

# Safer presence gate inside a job: map the secret to env,
# then compare the environment value without printing it.
env:
  DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
steps:
  - name: Explain unavailable credential without exposing it
    if: ${{ env.DEPLOY_TOKEN == '' }}
    run: echo "deployment credential is unavailable in this run context"

Do not weaken the trust boundary: If a fork PR does not receive production secrets, that is a security property. Do not switch to a privileged event merely to make the secret appear. Redesign the workflow so untrusted code validates without privileged credentials.

5. Security failure: untrusted event text becomes shell source

GitHub explicitly warns that attacker-controlled context values can contain shell metacharacters. A pull-request title, branch name, issue title/body, or other event field may be crafted to change the script generated by an inline expression.

Unsafe example — do not run:

# DO NOT USE
- run: |
    title="${{ github.event.pull_request.title }}"
    echo "$title"

The expression is substituted before the shell parses the script. A malicious title can therefore alter shell syntax. The safer pattern passes data via an environment variable:

- env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: |
    printf '%s\n' "$PR_TITLE"

This keeps the event text as an environment value read by the shell. You must still quote it and must not later feed it to eval, build command strings from it, or use it as an unchecked executable/file path.

6. Intentionally broken example: output declared at the wrong name/scope

The producer job correctly exposes selected_mode, but the consumer asks for mode. A nonexistent property resolves to an empty string, so an assertion catches the data-contract error.

jobs:
  prepare:
    runs-on: ubuntu-latest
    outputs:
      selected_mode: ${{ steps.derive.outputs.mode }}
    steps:
      - id: derive
        run: echo "mode=smoke" >> "$GITHUB_OUTPUT"

  consume:
    needs: prepare
    runs-on: ubuntu-latest
    steps:
      - env:
          MODE: ${{ needs.prepare.outputs.mode }}   # WRONG name
        run: |
          test -n "$MODE"
          echo "$MODE"

Expected failure: the consumer step exits non-zero because MODE is empty. Preserve the failed run and fix only the reference to needs.prepare.outputs.selected_mode. Do not add another variable store or copy the producer logic into the consumer.

7. Output consumed before its producer: missing needs is a graph problem

Job outputs are not global variables. A consumer must be downstream of the producer. If consume does not declare needs: prepare, the needs.prepare object is not a valid dependency contract for that job. The repair is graph structure, not sleep/retry logic.

This distinction becomes critical in Chapter 15 when matrices and larger job graphs create multiple producers. Keep output ownership and dependency edges explicit.

8. Reliability and performance only where they are causal

Path filtering can save runner work, but GitHub’s changed-file calculation has documented limits for very large pushes/diffs. A push with more than 1,000 commits or a diff-generation timeout can cause the workflow to run, while very large file sets can affect whether a matching file appears in the evaluated set. If a critical safety control depends on path filtering, test repository-scale edge cases rather than assuming filters are a perfect security boundary.

Expression complexity is usually not a performance bottleneck; human predictability is. Prefer readable intermediate outputs and named conditions over dense expressions that no reviewer can reason about.

9. Verification after correction

  • The intended event/ref creates exactly the expected run.
  • A non-matching branch/path case demonstrably creates no run.
  • Skipped jobs/steps have an explainable condition and correct event/input evidence.
  • Every consumed output has a producer step ID, job output mapping, and needs edge.
  • No full context or secret value is logged.
  • Untrusted event values reach shell code only as quoted data/arguments, not generated shell source.

10. Lesson summary

Missing runs are scheduler problems until proven otherwise; skipped jobs are expression problems until proven otherwise; empty outputs are data-contract problems until proven otherwise; secret failures are trust/authorization problems until proven otherwise. Keeping those layers separate prevents “fixes” that broaden privileges or hide the original cause.

Knowledge check

A workflow has no run at all for a push. What should you inspect before job logs?

Why can steps.x.outputs.count == 0 be surprising when count is empty?

A fork PR cannot see a deployment secret. Should you switch to pull_request_target and execute the fork code?

What is the minimal repair for needs.prepare.outputs.mode when the declared job output is selected_mode?

Why is an environment variable safer than direct expression interpolation for a PR title in an inline script?

Next lesson

Next: Checkpoint Lab — Workflow Triggers, Filters, Expressions, Contexts, Variables, Inputs, and Outputs

Further reading — current official GitHub sources

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
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.