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.
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
needsevidence.
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
needsedge. - 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?
Workflow discovery/default-branch state, the event/ref, and branch/path/activity filters. There are no job logs if scheduler eligibility never created a run.
Why can steps.x.outputs.count == 0 be surprising
when count is empty?
Outputs are strings and GitHub loose equality can coerce an empty string to numeric zero. Validate/convert explicitly.
A fork PR cannot see a deployment secret. Should you switch to
pull_request_target and execute the fork
code?
No. That can create a privileged untrusted-code execution vulnerability. Preserve the secret boundary and redesign validation/deployment separation.
What is the minimal repair for
needs.prepare.outputs.mode when the declared job
output is selected_mode?
Change the consumer reference to
needs.prepare.outputs.selected_mode. Do not add
broader shared state.
Why is an environment variable safer than direct expression interpolation for a PR title in an inline script?
GitHub does not splice the untrusted text into the shell source. The shell reads it as data from the environment, which is safer when properly quoted and not evaluated as code.
Further reading — current official GitHub sources
- GitHub Docs — Workflow syntax
- GitHub Docs — Events that trigger workflows
- GitHub Docs — Expressions
- GitHub Docs — Contexts reference
- GitHub Docs — Variables
- GitHub Docs — Pass job outputs
- GitHub Docs — Script injections
- GitHub Docs — Secure use reference
- GitHub CLI — gh workflow run
- GitHub CLI — gh run list
- GitHub Docs — Context availability
- GitHub Docs — Using secrets
- GitHub Docs — Script injections
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.