Skip to content
Latchkey LogoLatchkey home

Exit codes in CI: what every number means and how to fix it

Exit codes in CI are the only thing a failed step gets to say about itself, which is why so much of debugging a pipeline comes down to reading one number. This page is the table: what each code means, what usually produced it on a GitHub Actions runner, the fix, and the code a real latchkey-small runner reported for every row in it.

Three sources of a step exit code: the program, the shell at 126 and 127, and 128 plus the signal
The same field in the Actions log carries all three. Which family produced the number decides where you look next.
Recorded log of all 21 table exit codes produced by one script on a Latchkey latchkey-small runner
content/repro/ci-exit-codes-reference.sh on a Latchkey latchkey-small runner, 2026-09-20. The last two lines are the self-heal wrapper.

Three different things can choose the number you are looking at, and they do not agree with each other. The program can choose it, and the meaning is whatever that program decided: 28 from curl, 100 from apt-get, 5 from pytest. The shell can choose it, and then it is 126 or 127, about the command rather than about what the command does. Or a signal can choose it, and then it is 128 plus the signal number and the process never decided anything at all.

Reading the family first is most of the work. A 137 and a 139 sit two apart in the table and have nothing in common: one is a machine that ran out of memory, the other is a real memory bug in native code.

How a step decides what number to report

A process exits with a value between 0 and 255, and 0 means success. Everything else is convention, and the conventions overlap. Bash reserves two for itself: the manual states that "if a command is not found, the child process created to execute it returns a status of 127", and that "if a command is found but is not executable, the return status is 126". It also states the rule behind most of the high numbers in CI: "when a command terminates on a fatal signal whose number is N, Bash uses the value 128+N as the exit status".

That leaves 0 to 125 for the program, and programs use it differently. curl has almost a hundred codes; apt-get has one, and its manual says it "returns zero on normal operation, decimal 100 on error"; pytest defines six; most build tools define one, which is why exit code 1 tells you nothing. GitHub Actions adds none of its own: Process completed with exit code N is the runner repeating what the shell told it, so every number below means the same thing on your laptop.

The table

Meanings come from the bash manual, the libcurl error list, the apt-get manual and signal(7), all read on 2026-09-20 and linked at the foot of the page. The "typical cause" column is what we see on runners.

CodeWhat it meansTypical cause on a runnerWhat to do
0SuccessThe command did what it was askedNothing
1Generic failure, chosen by the programA failing test, a compile error, a lint violationRead the tool's own output. This is the signal CI exists to produce
2bash: misuse of a builtin. Many tools use it for a usage errorA bad flag, a missing argument, a syntax error in a sourced fileFix the invocation, not the environment
7curl CURLE_COULDNT_CONNECT: failed to connect to host or proxyNothing listening yet: a service container still startingWait for readiness before the first request
22curl CURLE_HTTP_RETURNED_ERROR, returned when -f is set and the status is 400 or aboveAn expired token, a wrong URL, an upstream 5xxUse --fail-with-body so the body survives the failure
28curl CURLE_OPERATION_TIMEDOUT: the time-out period was reachedA slow mirror, or a large download under a tight --max-timeSet --connect-timeout and --max-time deliberately and add --retry
35curl CURLE_SSL_CONNECT_ERROR: a problem in the TLS handshakeA proxy intercepting TLS, or a protocol or cipher mismatchRun once with -v and read the handshake lines
52curl CURLE_GOT_NOTHING: nothing was returned from the serverThe server read the request, then closed without answering: a crash or a restart mid-requestLook at the server, not the client
56curl CURLE_RECV_ERROR: failure receiving network dataThe connection was reset rather than closed, often by a proxy or a server that hung up unreadRetry, then find the device that resets it. Match this alongside 52, not instead of it
60curl CURLE_PEER_FAILED_VERIFICATION: the certificate was not acceptedA corporate CA that is not in the runner trust storeInstall the CA into the job. Never reach for -k
100apt-get error, its only failure codeUnable to locate package with no apt-get update first, or a held dpkg lockUpdate the index in the same step, and pin the package name
124timeout: the command outlived its budgetA step wrapped in timeout that ran longRaise the budget or make the work smaller. The command was not asked nicely to stop
125timeout itself failedA bad timeout invocationFix the invocation
126Found, but not executableA script committed without its executable bit, or a binary for the wrong architecturechmod +x, or check uname -m against the binary
127Command not foundA setup-* step that did not run, a tool installed into a PATH the next step does not inherit, or a missing shared libraryPrint which and PATH in the failing step before anything else
130Killed by SIGINT, signal 2 (128 + 2)The run was canceled, or a concurrency group superseded itIntentional. Do not retry it
134Killed by SIGABRT, signal 6 (128 + 6)An assertion inside a runtime, or glibc detecting heap corruptionRead the crash output. This is a real defect
137Killed by SIGKILL, signal 9 (128 + 9)The kernel out-of-memory killer, or timeout -s KILLSize the job or the machine
139Killed by SIGSEGV, signal 11 (128 + 11)Native code touching memory it does not own, or an addon built against a different ABIRebuild the addon against the runtime, or run under a sanitizer
141Killed by SIGPIPE, signal 13 (128 + 13)A pipeline whose reader exited early, such as | head -1Usually harmless. Handle it rather than hiding the code
143Killed by SIGTERM, signal 15 (128 + 15)A graceful stop: a canceled job, a container stopping, a node drainingNot your code. Find who sent the signal

The signals, and the 128 plus N rule

Subtract 128 and you have the signal number, which signal(7) names: 2 is SIGINT, "interrupt from keyboard"; 6 is SIGABRT; 9 is SIGKILL, "kill signal"; 11 is SIGSEGV, "invalid memory reference"; 13 is SIGPIPE; 15 is SIGTERM. In CI these arrive from four places, and telling them apart is the whole diagnosis.

The kernel sends SIGKILL when the machine is out of memory, which is the 137 that fills issue trackers. A cancellation sends SIGTERM first and SIGKILL after a grace period, so a canceled run can report either 143 or 137 depending on how quickly the process cooperated. A timeout wrapper returns 124 on expiry but 137 "if COMMAND (or timeout itself) is sent the KILL (9) signal (128+9)".

The codes a tool picked for itself

curl accounts for most of the tool-specific codes in a CI log, and four are worth knowing by sight: 28 is a timeout, the most common transient failure in any pipeline; 35 is a TLS handshake that died; and 52 and 56 are the pair that people get wrong.

The difference between them is not about your request: it is about how far the server got before it hung up. The measured run below points curl at two listeners that differ in one respect. The first waits for the request and closes without reading it, and closing a socket whose receive buffer still holds data makes the kernel answer with RST rather than FIN, which curl reports as an aborted receive: 56, "failure with receiving network data". The second reads the request and then closes, an orderly FIN with nothing behind it, which is exactly "nothing was returned from the server": 52.

Same client, same URL, one difference in server behavior, two codes. A retry condition written against 52 alone misses the reset case, which on a real network is the more common of the two.

apt-get is the opposite: exit 100 covers a missing package, an unmet dependency and a held dpkg lock equally, so the log tells you why and the code does not. On a runner it is usually an install with no apt-get update in the same step.

pytest exit codes 0 to 5

pytest is one of the few test runners that gives each outcome its own number. The one to watch is 5.

CodeExitCodepytest documentationWhat it is in CI
0OKAll tests were collected and passed successfullyGreen
1TESTS_FAILEDTests were collected and run but some of the tests failedA real failure. Read the assertions
2INTERRUPTEDTest execution was interrupted by the userA canceled job, or a fixture that aborted the session
3INTERNAL_ERRORInternal error happened while executing testsAlmost always a plugin, not your tests
4USAGE_ERRORpytest command line usage errorA flag CI passes that local runs do not
5NO_TESTS_COLLECTEDNo tests were collectedThe dangerous one: a green pipeline that tested nothing

Measured: one script, every code in the table

ci-exit-codes-reference.sh, under content/repro/, produces each case on a real machine and prints the code the shell reported, so the table above is measured rather than transcribed. It ran on a Latchkey latchkey-small runner on 2026-09-20 and exited 42. Each probe runs inside a subshell with its output discarded, because a shell announces a signalled child on its own stderr ("Killed", "Segmentation fault") and those lines would otherwise look like a failure worth diagnosing.

Three things are worth pointing at. Every one of the twenty-one codes in the table appears here, including the two usually asserted rather than shown: 60 from a listener holding a certificate nothing trusts, 22 from a real 404 under --fail. The 56 and 52 lines are the two listeners described above. And the script ends exit 42, which is what the harness recorded, because a job reports its last command status and nothing about the codes produced inside it.

The final two lines are the Latchkey self-heal wrapper: every nonzero exit gets a sidecar round trip, and nothing between BEGIN and END means a diagnosis that did nothing, which is correct for a deliberate exit 42.

latchkey run, recorded 2026-09-20 on latchkey-small
shell: success                             0
shell: generic failure                     1
shell: invalid option to a builtin         2
shell: command not found                 127
shell: found but not executable          126
timeout: command exceeded its budget     124
timeout: timeout itself was misinvoked   125
timeout: sent KILL after the budget      137
signal: interrupt (Ctrl-C, cancellation) 130
signal: terminate (graceful stop request) 143
signal: abort                            134
signal: invalid memory reference         139
signal: forced kill (what OOM looks like) 137
signal: broken pipe, under pipefail      141
curl: nothing listening on the port        7
curl: connect budget exceeded             28
curl: closed before reading the request   56
curl: read the request, then closed       52
curl: TLS negotiation died                35
curl: certificate nothing trusts          60
curl: HTTP 404 under --fail               22
apt-get: package cannot be located       100
pipeline: last stage wins by default       0
pipeline: same line under pipefail         1
pytest: passed/failed/bad flag/no tests  0 1 4 5
observed on Ubuntu 24.04.4 LTS, kernel 6.17.0-1019-aws, curl 8.5.0
[latchkey-bash-wrapper] BEGIN sidecar POST (boot_wait=30s max_time=320s url=http://localhost/diagnose socket=/run/latchkey-self-heal/sock)
[latchkey-bash-wrapper] END sidecar POST ok (attempts=1 http=200)

The code you see is not always the code that mattered

Two shell behaviors quietly rewrite the number before it reaches the log, and both showed up in the run above. Without set -o pipefail, a pipeline reports only its last stage, so false | true exits 0 and a failing test piped into a formatter passes. That is the default you are on: a run: step with no shell: key runs as bash -e {0}, which sets -e and not pipefail. Writing shell: bash explicitly is what gets you bash --noprofile --norc -eo pipefail {0}, and the docs note that the two run different commands. Until you opt in, the set -o pipefail line in the sample below is doing real work.

The second is set -e, which you do get by default. A multi-line run: block stops at the first failing command, so a cleanup line at the end never executes and the code you see belongs to whatever failed first. If a command is allowed to fail, capture it rather than appending || true, which discards the number entirely.

.github/workflows/ci.yml
- name: Keep the exit code you care about
  run: |
    set -o pipefail
    rc=0
    npm test | tee test.log || rc=$?
    echo "test exit: $rc"
    exit $rc

Key takeaways

  • Read the family before the number: the program chose it, the shell chose it, or a signal chose it.
  • Anything above 128 is a signal: subtract 128 and signal(7) names it.
  • 137 and 143 are about the machine. 134 and 139 are about your code.
  • A tool code means only what that tool says it means, and apt-get says everything with 100.
  • Without pipefail the pipeline reports its last stage, which is how a failing test exits 0.

Frequently asked questions

How to handle exit code 137 on Docker?
Treat it as a memory result, not a Docker result. 137 is 128 plus signal 9, so something sent SIGKILL: in a container that is almost always the out-of-memory killer hitting the cgroup limit, occasionally docker stop escalating after its grace period. Measure the peak before changing the limit.
Why does my job fail with exit code 127 when the command is installed?
Because the shell that ran the step did not see it. Each run: block is a new shell, so a tool installed into a PATH that was never exported is invisible, which is the shape of actions/runner-images#13370. A binary that is present but missing a shared library also reports 127, from the loader rather than from bash.
What does exit code 143 mean in GitHub Actions?
128 plus signal 15, so the process was sent SIGTERM and did not survive it. In Actions that is nearly always a stop request rather than a crash: a canceled run, a concurrency group replacing the job, or a container shutting down. subosito/flutter-action#368 is a typical report, and the answer is to find the sender rather than retry.
Should pytest exit code 5 fail my build?
Yes, and it already does unless something is hiding it. Exit 5 means no tests were collected, so a run that reports it proved nothing. The request to suppress it goes back to pytest-dev/pytest#2393; the honest fix is to make collection deterministic: pin testpaths, keep the naming convention, and fail loudly when a directory disappears.

Related guides

References

A Latchkey runner reads the exit code the way you just did, then repairs the transient half. Start free → 30-day trial · No credit card