Find the cause of a CI failure and name it, with the evidence that proves it.
Explain the divergence before changing anything.
Recent runs
gh run list --limit 10 2>&1 | head -20
Arguments: $ARGUMENTS
Diagnose the run named in the arguments if given. Otherwise diagnose the most
recent failing run above. If there are no arguments and no run has failed, ask
the user which run to diagnose and stop.
Principles
- The log is the evidence. Read the failing step before forming a theory. Wrong
diagnoses come from guessing the error out of the workflow file
- "It passes locally" is the finding, not an objection to it. When one command
passes on one machine and fails on another, their environments differ. Locate
the difference, don't patch the symptom
- A cancelled run is not a failure. Cancellation comes from a concurrency group,
a sibling matrix leg, a timeout, or a person. Each has a different fix
- Reproduce before fixing. CI round trips are slow, so a fix you never saw fail
is an expensive guess
- Loosening the check is not the fix unless you can say what the check was wrong
to assert. Raising a timeout, retrying a flaky test, pinning to the one node
version that passes, committing generated output to satisfy a
--check: each
hides the cause
Workflow
- Resolve the target to a run ID. From a run URL, take the trailing number.
From a PR number, use
gh pr checks <n>. From a repo name alone, use
gh run list --status failure --limit 5
- Get the run's shape with
gh run view <id>: the conclusion (failure,
cancelled, timed out), which jobs failed, and whether one matrix leg failed
or all of them
- Isolate the failing step with
gh run view <id> --log-failed. When that is
empty, as it is for cancellations and timeouts, read the tail of
gh run view --job <job-id> --log. Quote the first real error, not the
summary line that follows it
- Read the workflow file that defines the job at the commit the run used, not
at HEAD:
gh api repos/{owner}/{repo}/contents/<path>?ref=<sha>. A
pull_request run uses the merge commit, which you have not checked out
- Ask whether the failure is deterministic. Compare against earlier runs of the
same workflow (
gh run list --workflow <file>): does it fail every run, on
one matrix leg, on this branch alone, or intermittently? Each answer points
at a different part of the checklist
- Walk the checklist for the run's conclusion: "Divergence checklist" when a
step failed, "Cancelled and hanging runs" when none did. Stop at the first
item that explains the error, and confirm it against the log or the workflow
file
- Reproduce locally under CI's conditions: the same node version, install mode,
command, flags, and a clean tree. See "Reproducing locally"
- Report as in "Reporting". Fix only if the arguments or a follow-up ask for
it, and only once the reproduction fails the way CI does
Divergence checklist
For "passes locally, fails in CI", in the order worth checking:
- Toolchain version. The leg's node, pnpm, or python differs from yours. Compare
node --version against the leg's matrix.node and the engines field. When
one leg fails and its siblings pass, assume this until proven otherwise
- Install mode. CI installs from a frozen lockfile (
npm ci,
pnpm install --frozen-lockfile); you installed mutably. A lockfile stale
against package.json fails only in CI, and so does a dependency you
installed locally and never committed
- Dirty local tree. Untracked or uncommitted files make the command pass for you
and fail on a clean checkout. Run
jj st, and check whether the failing input
is gitignored or generated
- Generated files checked, not written. Locally
pnpm format rewrites files and
exits 0; CI runs --check and exits nonzero. Lockfiles, snapshots, generated
types, and API reports behave the same way. When the format job fails but
pnpm format is a no-op, CI is checking a file your formatter skips, or
checking it under a different config
- Runner OS. Case-sensitive filesystem, path separators, line endings, missing
system binaries, a different shell. An import that resolves on macOS and fails
on
ubuntu-latest is a filename casing bug
- Cache. A restored
actions/cache entry holds stale build output or a stale
dependency tree. Test by re-running with the cache disabled or the key bumped
- Environment and secrets. A secret is empty on a fork's
pull_request,
CI=true changes tool behavior (watch mode off, colors off, prompts fail), or
NODE_ENV differs
- Resource limits. The runner has less memory and fewer cores. Out-of-memory
kills show as exit code 137, or as a bare "Process completed with exit code 1"
after a heap message
- Parallelism and ordering. A different worker count and test order expose
shared state and races that a serial local run hides
- Clock, timezone, and locale. The runner is UTC with a C locale, so date
formatting and sort order differ
- Network. A rate limit on an unauthenticated request, a registry outage, or an
egress restriction
- Permissions. The
GITHUB_TOKEN lacks a scope the job needs, so the failure
appears as a 403 rather than a build error
These aren't exhaustive. Reason from first principles when none fits cleanly.
Cancelled and hanging runs
When no step failed, the cause is in how the run was scheduled or bounded:
- Concurrency cancellation. A
concurrency group with
cancel-in-progress: true cancels the older run when a new push lands on the
same ref. The symptom is a cancellation seconds after a push. Check whether a
newer run of the same workflow exists for that ref
- Matrix fail-fast.
strategy.fail-fast defaults to true, so one leg's real
failure cancels its siblings. Diagnose the leg that failed, not the cancelled
ones
- Timeout.
timeout-minutes on the job or step, or the 6 hour default. A hang
comes from a process waiting on stdin, a watch mode that never exits, a server
started without a background flag, or a test awaiting a resource CI lacks.
Compare the step's duration against a passing run's
- Runner starvation. The job never started: no runner matched
runs-on, or the
queue timed out. gh run view <id> shows it queued rather than running
- Manual or API cancellation. Someone stopped it, or a scheduled workflow was
disabled after 60 days without repository activity
These aren't exhaustive. Reason from first principles when none fits cleanly.
Reproducing locally
Match CI's conditions one at a time, cheapest first:
- Run the workflow file's exact command with its flags, not the friendly alias
you normally use
- Switch to the failing leg's toolchain version
- Reinstall from the lockfile in frozen mode
- Run against a clean checkout of the run's commit in a scratch directory, so
untracked files cannot affect the result
- Set
CI=true and clear the environment variables CI does not set
If it still passes, the cause is in the runner, not the code. Get more evidence
from CI: re-run with debug logging (gh run rerun <id> --debug), or add a
temporary step printing the tool versions, the resolved config, and the git
status.
Reporting
Open with the cause in one sentence, marked confirmed or suspected. Then:
- The failing step, and the first real error line, quoted
- The divergence: what differs between your machine and the runner, with the
line of log or workflow file that shows it
- Why it passed locally, stated outright
- The fix: the file and the change
- What you could not check, and what a debug run would settle
1---2name: diagnose-ci3description: |4 Diagnose a failing, cancelled, or hanging CI run, explaining why it fails in5 CI when the same command passes locally.6argument-hint: '<run URL, PR number, or what is failing>'7allowed-tools:8 - Bash(gh run *)9 - Bash(gh pr *)10 - Bash(gh workflow *)11 - Bash(gh api *)12 - Bash(gh repo view *)13 - Bash(rg *)14---1516Find the cause of a CI failure and name it, with the evidence that proves it.17Explain the divergence before changing anything.1819# Recent runs2021```!22gh run list --limit 10 2>&1 | head -2023```2425Arguments: $ARGUMENTS2627Diagnose the run named in the arguments if given. Otherwise diagnose the most28recent failing run above. If there are no arguments and no run has failed, ask29the user which run to diagnose and stop.3031# Principles3233- The log is the evidence. Read the failing step before forming a theory. Wrong34 diagnoses come from guessing the error out of the workflow file35- "It passes locally" is the finding, not an objection to it. When one command36 passes on one machine and fails on another, their environments differ. Locate37 the difference, don't patch the symptom38- A cancelled run is not a failure. Cancellation comes from a concurrency group,39 a sibling matrix leg, a timeout, or a person. Each has a different fix40- Reproduce before fixing. CI round trips are slow, so a fix you never saw fail41 is an expensive guess42- Loosening the check is not the fix unless you can say what the check was wrong43 to assert. Raising a timeout, retrying a flaky test, pinning to the one node44 version that passes, committing generated output to satisfy a `--check`: each45 hides the cause4647# Workflow48491. Resolve the target to a run ID. From a run URL, take the trailing number.50 From a PR number, use `gh pr checks <n>`. From a repo name alone, use51 `gh run list --status failure --limit 5`522. Get the run's shape with `gh run view <id>`: the conclusion (failure,53 cancelled, timed out), which jobs failed, and whether one matrix leg failed54 or all of them553. Isolate the failing step with `gh run view <id> --log-failed`. When that is56 empty, as it is for cancellations and timeouts, read the tail of57 `gh run view --job <job-id> --log`. Quote the first real error, not the58 summary line that follows it594. Read the workflow file that defines the job at the commit the run used, not60 at HEAD: `gh api repos/{owner}/{repo}/contents/<path>?ref=<sha>`. A61 `pull_request` run uses the merge commit, which you have not checked out625. Ask whether the failure is deterministic. Compare against earlier runs of the63 same workflow (`gh run list --workflow <file>`): does it fail every run, on64 one matrix leg, on this branch alone, or intermittently? Each answer points65 at a different part of the checklist666. Walk the checklist for the run's conclusion: "Divergence checklist" when a67 step failed, "Cancelled and hanging runs" when none did. Stop at the first68 item that explains the error, and confirm it against the log or the workflow69 file707. Reproduce locally under CI's conditions: the same node version, install mode,71 command, flags, and a clean tree. See "Reproducing locally"728. Report as in "Reporting". Fix only if the arguments or a follow-up ask for73 it, and only once the reproduction fails the way CI does7475# Divergence checklist7677For "passes locally, fails in CI", in the order worth checking:7879- Toolchain version. The leg's node, pnpm, or python differs from yours. Compare80 `node --version` against the leg's `matrix.node` and the `engines` field. When81 one leg fails and its siblings pass, assume this until proven otherwise82- Install mode. CI installs from a frozen lockfile (`npm ci`,83 `pnpm install --frozen-lockfile`); you installed mutably. A lockfile stale84 against `package.json` fails only in CI, and so does a dependency you85 installed locally and never committed86- Dirty local tree. Untracked or uncommitted files make the command pass for you87 and fail on a clean checkout. Run `jj st`, and check whether the failing input88 is gitignored or generated89- Generated files checked, not written. Locally `pnpm format` rewrites files and90 exits 0; CI runs `--check` and exits nonzero. Lockfiles, snapshots, generated91 types, and API reports behave the same way. When the format job fails but92 `pnpm format` is a no-op, CI is checking a file your formatter skips, or93 checking it under a different config94- Runner OS. Case-sensitive filesystem, path separators, line endings, missing95 system binaries, a different shell. An import that resolves on macOS and fails96 on `ubuntu-latest` is a filename casing bug97- Cache. A restored `actions/cache` entry holds stale build output or a stale98 dependency tree. Test by re-running with the cache disabled or the key bumped99- Environment and secrets. A secret is empty on a fork's `pull_request`,100 `CI=true` changes tool behavior (watch mode off, colors off, prompts fail), or101 `NODE_ENV` differs102- Resource limits. The runner has less memory and fewer cores. Out-of-memory103 kills show as exit code 137, or as a bare "Process completed with exit code 1"104 after a heap message105- Parallelism and ordering. A different worker count and test order expose106 shared state and races that a serial local run hides107- Clock, timezone, and locale. The runner is UTC with a C locale, so date108 formatting and sort order differ109- Network. A rate limit on an unauthenticated request, a registry outage, or an110 egress restriction111- Permissions. The `GITHUB_TOKEN` lacks a scope the job needs, so the failure112 appears as a 403 rather than a build error113114These aren't exhaustive. Reason from first principles when none fits cleanly.115116# Cancelled and hanging runs117118When no step failed, the cause is in how the run was scheduled or bounded:119120- Concurrency cancellation. A `concurrency` group with121 `cancel-in-progress: true` cancels the older run when a new push lands on the122 same ref. The symptom is a cancellation seconds after a push. Check whether a123 newer run of the same workflow exists for that ref124- Matrix fail-fast. `strategy.fail-fast` defaults to true, so one leg's real125 failure cancels its siblings. Diagnose the leg that failed, not the cancelled126 ones127- Timeout. `timeout-minutes` on the job or step, or the 6 hour default. A hang128 comes from a process waiting on stdin, a watch mode that never exits, a server129 started without a background flag, or a test awaiting a resource CI lacks.130 Compare the step's duration against a passing run's131- Runner starvation. The job never started: no runner matched `runs-on`, or the132 queue timed out. `gh run view <id>` shows it queued rather than running133- Manual or API cancellation. Someone stopped it, or a scheduled workflow was134 disabled after 60 days without repository activity135136These aren't exhaustive. Reason from first principles when none fits cleanly.137138# Reproducing locally139140Match CI's conditions one at a time, cheapest first:1411421. Run the workflow file's exact command with its flags, not the friendly alias143 you normally use1442. Switch to the failing leg's toolchain version1453. Reinstall from the lockfile in frozen mode1464. Run against a clean checkout of the run's commit in a scratch directory, so147 untracked files cannot affect the result1485. Set `CI=true` and clear the environment variables CI does not set149150If it still passes, the cause is in the runner, not the code. Get more evidence151from CI: re-run with debug logging (`gh run rerun <id> --debug`), or add a152temporary step printing the tool versions, the resolved config, and the git153status.154155# Reporting156157Open with the cause in one sentence, marked confirmed or suspected. Then:158159- The failing step, and the first real error line, quoted160- The divergence: what differs between your machine and the runner, with the161 line of log or workflow file that shows it162- Why it passed locally, stated outright163- The fix: the file and the change164- What you could not check, and what a debug run would settle