Reach for this skill when a CI job is failing and you need to find out WHY before touching anything:
-
Read the log top-to-bottom and grab the REAL exit code before theorizing. The first red line is rarely the cause — scroll up to the first error, and check the process exit code, which names the failure class:
| Exit code | Means | Likely cause |
|---|
1 / 2 | generic failure / misuse | real test/build error — read the actual assertion |
124 | command timed out (timeout wrapper) | step exceeded its time budget |
137 | 128+9 = SIGKILL | OOM-killed (almost always memory limit) or job cancelled |
139 | 128+11 = SIGSEGV | native segfault (bad binary/arch mismatch) |
143 | 128+15 = SIGTERM | timeout/cancel signalled gracefully |
125 | docker run failed | image/entrypoint problem, not your code |
In GitHub Actions add --rerun-failed-jobs only AFTER you know why. Download the raw log (gh run view <id> --log-failed, GitLab "Complete Raw") — the web UI truncates and folds groups.
-
Reproduce locally in the SAME image, not your laptop. "Green on my machine" proves nothing if your machine isn't the runner. Run the actual job in its actual container:
| CI | Local reproduce |
|---|
| GitHub Actions | act -j <job> --container-architecture linux/amd64 (use the runner image: -P ubuntu-latest=catthehacker/ubuntu:act-latest) |
| GitLab CI | gitlab-runner exec docker <job> or glab ci run; pull the exact image: |
| CircleCI | circleci local execute --job <job> |
| any | docker run --rm -it <image>@<digest> then run the steps by hand |
Pin the image by digest (@sha256:…), not a moving tag — ubuntu-latest/node:20 drift between your pull and the runner's. If it reproduces in the container but not on your host, the delta IS the bug (next step).
-
Diff the environment — env-drift is the #1 silent cause. Inside the reproduced container vs your host, compare:
printenv | sort > /tmp/ci.env
<tool> --version
sha256sum package-lock.json poetry.lock go.sum
uname -m && cat /etc/os-release
locale && echo $TZ
Classic drifts: tool tag floated (actions/setup-node@v4 with no exact version), npm ci vs npm install (lockfile ignored), $PATH ordering picks a different binary, TZ/LANG unset on the runner breaks date/sort tests, CI sets CI=true which flips test behavior. Fix = pin (digest + lockfile + exact tool version) → pin-toolchain-versions.
-
Classify the failure — match symptom to cause, then run ONE experiment to confirm. Don't guess and re-run; prove it:
| Class | Tell-tale | Confirm by |
|---|
| Flaky test | passes on re-run, no code change, intermittent | re-run the SAME commit 10×; randomize order → debug-flaky-tests |
| Env/version drift | green local, red CI; broke with no relevant diff | the env diff in step 3 |
| Poisoned/stale cache | broke after a dep bump or cache-key collision; "works in clean checkout" | run with cache disabled (step 5) |
| Resource / OOM | exit 137, "Killed", slow then dead | raise mem / lower parallelism; watch RSS |
| Missing/empty secret | only on fork PRs, only on protected branches, *** blank | echo "len=${#SECRET}" (never the value) |
| Timeout / deadlock | exit 124/143, "cancelled after Nm" | run with timeout + thread dump on hang |
| Test-ordering | fails only in CI's shard/order, passes in isolation | run that ONE test alone; then full suite |
| Network/flaky registry | ETIMEDOUT/ECONNRESET/429 to npm/pypi/ghcr | retry; check it's not a hard dep on a live service |
-
Run clean-vs-cached to convict the cache. A poisoned or stale cache makes "works in a fresh checkout, fails in CI" — because CI restored a bad layer. Force a clean run and compare:
- GitHub Actions: bump the cache
key (e.g. -v2), or gh cache delete <key>, or set actions/cache to a key that won't hit. Re-run with ACTIONS_STEP_DEBUG=true.
- GitLab:
CACHE_DISABLE=true / clear via "Clear runner caches"; or change cache:key.
- Docker layer cache:
docker build --no-cache --pull; suspect a stale base layer if a RUN apt-get/pip install silently uses old pins.
- Package managers:
npm ci (not install), pip install --no-cache-dir, go clean -cache.
If clean is green and cached is red → the cache is the cause: the key is too coarse (not keyed on lockfile hash) or restores across incompatible refs → caching-strategy to re-scope the key. If BOTH fail, the cache is innocent — move on.
-
Isolate ONE matrix leg. A matrix-only failure (only windows-latest, only py3.12, only arm64) is a portability bug, not a flake. Temporarily pin the matrix to the failing leg (include: just that combo) so you iterate on one red job, not 12. Common per-leg causes: path separators / line endings (CRLF) on Windows, glibc vs musl (alpine) for native deps, arch-specific wheels/binaries on arm64, a stdlib behavior that changed in the new language minor. Fix the portability issue, then restore the full matrix.
-
Crank up debug logging and set -x. The default log hides what ran. Turn on tracing:
| CI | Debug switch |
|---|
| GitHub Actions | secrets/vars ACTIONS_STEP_DEBUG=true and ACTIONS_RUNNER_DEBUG=true |
| GitLab CI | variables: CI_DEBUG_TRACE: "true" (⚠ leaks env — protected branch only) |
| shell steps | add set -euxo pipefail to see every command + fail-fast on the real line |
| any | echo printenv|sort, df -h, free -m, nproc, tool --version as a debug step |
set -x + pipefail alone fixes a whole class of "silent failure" where an early command in a pipe failed but the exit code was masked by the last one.
-
Drop into an interactive runner when logs aren't enough. For "I can't reproduce it locally and the log is opaque," open a live shell ON the runner:
- GitHub Actions:
mxschmitt/action-tmate@v3 step (gates on failure) → SSH into the live runner mid-job; or tmate in a manual workflow_dispatch.
- GitLab: interactive web terminal /
gitlab-runner --debug; CircleCI: "Rerun job with SSH".
- Inside: re-run the failing command by hand, inspect
/tmp, check mounted caches, cat the generated config, ps/top for the OOM, dmesg | tail for the kill. Tear it down — don't leave a runner pinned.
-
Convict resource/OOM with real numbers. Exit 137 + "Killed" = the kernel OOM-killer. Don't just continue-on-error — measure: GitHub-hosted runners are ~7 GB / 2 cores; self-hosted/container jobs have a --memory cgroup limit. Add /usr/bin/time -v <cmd> (Max RSS), or while true; do free -m; sleep 5; done & to watch growth. Fixes: lower test parallelism (-j2, --maxWorkers=2, pytest -n2), raise the container/runner memory limit, split the job, or fix the actual leak. Node OOM specifically → NODE_OPTIONS=--max-old-space-size=4096.
-
Fix the ROOT CAUSE, then re-run to confirm — never ship a blind re-run as the fix. A green re-run on a flaky/poisoned/under-provisioned job is a false negative that WILL recur. The closing move per class: pin the digest+lockfile+tool version (drift), re-scope or invalidate the cache key (cache), raise the limit or cut parallelism (OOM), randomize-then-pin test order / fix the shared state (ordering/flake → debug-flaky-tests), add a retry-with-backoff ONLY for genuinely external network calls (and nothing else). Then re-run the SAME commit ≥3× to prove it's deterministically green. Quarantining a flaky test (skip + tracking issue) is acceptable as a stopgap to unblock the pipeline — but it's a TODO, not the fix.
Done = the failure was reproduced in the runner's actual image, classified into one named cause confirmed by a targeted experiment (env diff / clean-vs-cached / isolated test / RSS), and fixed at the root (pin, cache-key, limit, order) — proven by the same commit going green ≥3× with no blind re-run, no masking, and no leaked secrets.