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

> Exit codes in CI from 0 to 143: what each number means on a GitHub Actions runner, what causes it, and the fix, with the whole table measured on one.

Source: https://latchkey.dev/learn/ci-explained/ci-exit-codes-reference  
Updated: 2026-09-20

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](/learn/failures/exit-code-137-in-github-actions) |
| `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 |

> Codes above 125 are the shell talking, not your program: the bash manual says outright that "the shell may use values above 125 specially". A program that genuinely wants to exit 137 on its own is possible and is also a program nobody should write.

## 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 |

> Exit 5 is a failure, so a plain `pytest` step does turn the job red. It goes unnoticed when the step is wrapped in something that swallows it, or when a matrix leg legitimately has no tests and someone adds `|| true` to quiet it. Deleting the test directory in a refactor then costs nothing until the release.

## 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
```

## FAQ

### 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.

## References

- [Bash Reference Manual: Exit Status (read 2026-09-20)](https://www.gnu.org/software/bash/manual/html_node/Exit-Status.html)
- [libcurl error codes, including 7, 22, 28, 35, 52, 56 and 60 (read 2026-09-20)](https://curl.se/libcurl/c/libcurl-errors.html)
- [signal(7): the standard signals and their numbers (read 2026-09-20)](https://man7.org/linux/man-pages/man7/signal.7.html)
- [pytest reference: exit codes 0 to 5 (read 2026-09-20)](https://docs.pytest.org/en/stable/reference/exit-codes.html)
- [coreutils timeout(1): 124 on expiry, 137 on KILL (read 2026-09-20)](https://man7.org/linux/man-pages/man1/timeout.1.html)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
