Common Failure Patterns
Most workflow failures fall into a handful of recognizable shapes once you’ve seen them a few times. This chapter is a field guide — a table to come back to the next time a run goes red — plus a closer look at a few patterns using the broken deploy pipeline from the earlier debugging chapter as the worked example.
The Quick-reference Table
| Signature | What it usually means | Where to look first |
|---|---|---|
| Step shows a red X with a specific error message | The step itself failed — the error text is probably accurate | The step’s own log |
| A late step fails with a generic timeout/wait error, earlier steps all green | The real failure happened upstream; this step just detected it | The step(s) before it, plus any log the failing process produced |
| A step is greyed out / marked “skipped” | An if: condition evaluated to false — often because an earlier step failed and nothing told this step to run anyway | The if: condition on the skipped step, and the result of the step it depends on |
| Job fails instantly, before any of your steps run | Something in job-level setup failed — checkout, a missing secret referenced in env:, an invalid runs-on label | The job summary above the step list, not inside any step |
Permission denied / Resource not accessible by integration | The token or credentials the job is running with don’t have the scope the failing call needs | The exact permission named in the error, cross-referenced against the workflow’s permissions: block |
| One matrix leg fails, others stay green | The failure is specific to that leg’s OS/version combination, not the workflow logic itself | That leg’s own log, compared against a passing leg’s log for the same step |
| Workflow-level failure with every step green | Something outside the job’s step list failed — a job timeout expiring, a concurrency cancellation | The run summary, not any individual step |
Exit Code vs. Workflow-level Failure
A step exit code failure means a command inside a run: block returned nonzero — GitHub Actions is just reporting what your shell told it. A workflow-level failure can happen with zero failing exit codes: a job timeout expiring, or a concurrency group cancelling an in-progress run. When every step shows green but the run is still marked failed or cancelled, stop looking inside the steps — the cause is in how the run was managed, not in what any step did.
Silently-skipped Steps
This one wastes the most time, because a skipped step looks like nothing happened at all — no error, just a step sitting there greyed out. It means an if: condition evaluated to false, and the most common reason is a step earlier in the job failing without anything telling this step to run anyway.
Every step implicitly runs under if: success() by default — it only executes if everything before it in the job succeeded. A step like:
- name: Show app logs on failure
if: failure()
run: cat release/app.log || echo "no log found"only runs because it has if: failure() on it — remove that line and it’d be silently skipped on the exact runs where you need it most, since without it the implicit if: success() would suppress it the moment an earlier step failed. This is the reasoning behind if: always() and if: failure() from earlier in the course: if: always() runs regardless of what happened before it (useful for cleanup that should fire either way), if: failure() runs only if something upstream failed (useful for diagnostics or alerting). If a step you expected to run shows as skipped, the fix is almost always adding the right if: — not fixing a bug in the step itself, since it never got the chance to have one.
Permission-denied Errors
A workflow’s GITHUB_TOKEN gets a default set of permissions, and the permissions: block from earlier in the course lets you narrow (or, per-job, widen) exactly what it’s allowed to do. A common failure shape: a step tries to do something — push a tag, create a release, comment on a PR — and fails with something like:
Error: Resource not accessible by integrationor an explicit 403. This is a different failure family from a broken command — the step ran, authenticated fine, and got turned away because the token it’s using wasn’t granted that specific permission. The fix is checking the permissions: block at the top of the workflow (or job) and adding the scope the failing call actually needs, rather than assuming the step’s own logic is at fault.
Matrix Legs Failing Independently
Each leg of a matrix build is its own independent job instance, so when one leg fails, GitHub Actions reports that leg as failed while the others run to completion (or not, depending on fail-fast) — test (ubuntu-latest, 3.13) red next to test (windows-latest, 3.13) green, for the exact same commit. The signature: identical code, identical step, different result across legs. That almost always points to something environment-specific — a path separator, a line-ending difference, a package that behaves differently on that OS or interpreter version — rather than a bug in the logic itself. Compare the passing and failing leg’s logs for the same step side by side before assuming the problem is code-level.
Applying This To The Broken Deploy Pipeline
The deploy failure from the earlier debugging chapter was a “late step fails with a generic timeout, earlier steps all green” signature — every step up through “Start app” reported success, and the failure only surfaced at “Wait for app to become healthy.” That correctly flagged it as a case to look upstream rather than trust that the failing step was where the bug actually lived, which is exactly what led to adding the if: failure() log-dumping step and finding the real KeyError underneath.
Tip
Whenever a run fails, try classifying it against this table before diving into logs. Knowing which shape you’re looking at tells you where to look first.