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 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.
| Code | What it means | Typical cause on a runner | What to do |
|---|---|---|---|
0 | Success | The command did what it was asked | Nothing |
1 | Generic failure, chosen by the program | A failing test, a compile error, a lint violation | Read the tool's own output. This is the signal CI exists to produce |
2 | bash: misuse of a builtin. Many tools use it for a usage error | A bad flag, a missing argument, a syntax error in a sourced file | Fix the invocation, not the environment |
7 | curl CURLE_COULDNT_CONNECT: failed to connect to host or proxy | Nothing listening yet: a service container still starting | Wait for readiness before the first request |
22 | curl CURLE_HTTP_RETURNED_ERROR, returned when -f is set and the status is 400 or above | An expired token, a wrong URL, an upstream 5xx | Use --fail-with-body so the body survives the failure |
28 | curl CURLE_OPERATION_TIMEDOUT: the time-out period was reached | A slow mirror, or a large download under a tight --max-time | Set --connect-timeout and --max-time deliberately and add --retry |
35 | curl CURLE_SSL_CONNECT_ERROR: a problem in the TLS handshake | A proxy intercepting TLS, or a protocol or cipher mismatch | Run once with -v and read the handshake lines |
52 | curl CURLE_GOT_NOTHING: nothing was returned from the server | The server read the request, then closed without answering: a crash or a restart mid-request | Look at the server, not the client |
56 | curl CURLE_RECV_ERROR: failure receiving network data | The connection was reset rather than closed, often by a proxy or a server that hung up unread | Retry, then find the device that resets it. Match this alongside 52, not instead of it |
60 | curl CURLE_PEER_FAILED_VERIFICATION: the certificate was not accepted | A corporate CA that is not in the runner trust store | Install the CA into the job. Never reach for -k |
100 | apt-get error, its only failure code | Unable to locate package with no apt-get update first, or a held dpkg lock | Update the index in the same step, and pin the package name |
124 | timeout: the command outlived its budget | A step wrapped in timeout that ran long | Raise the budget or make the work smaller. The command was not asked nicely to stop |
125 | timeout itself failed | A bad timeout invocation | Fix the invocation |
126 | Found, but not executable | A script committed without its executable bit, or a binary for the wrong architecture | chmod +x, or check uname -m against the binary |
127 | Command not found | A setup-* step that did not run, a tool installed into a PATH the next step does not inherit, or a missing shared library | Print which and PATH in the failing step before anything else |
130 | Killed by SIGINT, signal 2 (128 + 2) | The run was canceled, or a concurrency group superseded it | Intentional. Do not retry it |
134 | Killed by SIGABRT, signal 6 (128 + 6) | An assertion inside a runtime, or glibc detecting heap corruption | Read the crash output. This is a real defect |
137 | Killed by SIGKILL, signal 9 (128 + 9) | The kernel out-of-memory killer, or timeout -s KILL | Size the job or the machine |
139 | Killed by SIGSEGV, signal 11 (128 + 11) | Native code touching memory it does not own, or an addon built against a different ABI | Rebuild the addon against the runtime, or run under a sanitizer |
141 | Killed by SIGPIPE, signal 13 (128 + 13) | A pipeline whose reader exited early, such as | head -1 | Usually harmless. Handle it rather than hiding the code |
143 | Killed by SIGTERM, signal 15 (128 + 15) | A graceful stop: a canceled job, a container stopping, a node draining | Not 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.
| Code | ExitCode | pytest documentation | What it is in CI |
|---|---|---|---|
0 | OK | All tests were collected and passed successfully | Green |
1 | TESTS_FAILED | Tests were collected and run but some of the tests failed | A real failure. Read the assertions |
2 | INTERRUPTED | Test execution was interrupted by the user | A canceled job, or a fixture that aborted the session |
3 | INTERNAL_ERROR | Internal error happened while executing tests | Almost always a plugin, not your tests |
4 | USAGE_ERROR | pytest command line usage error | A flag CI passes that local runs do not |
5 | NO_TESTS_COLLECTED | No tests were collected | The 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.
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.
- 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 $rcKey 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?
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?
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?
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?
testpaths, keep the naming convention, and fail loudly when a directory disappears.Related guides
References
- Bash Reference Manual: Exit Status (read 2026-09-20)
- libcurl error codes, including 7, 22, 28, 35, 52, 56 and 60 (read 2026-09-20)
- signal(7): the standard signals and their numbers (read 2026-09-20)
- pytest reference: exit codes 0 to 5 (read 2026-09-20)
- coreutils timeout(1): 124 on expiry, 137 on KILL (read 2026-09-20)
- GitHub Actions documentation