From the git course, with five teammates pushing to the same repository all day and no automation, a broken push is discovered whenever some human happens to run the tests next, possibly days later.
Git only stores and transports code, it runs nothing, which is the same point lesson 1-1 made about environments.
Until something automatically runs the tests on every push, broken code sits on main silently. That something is CI.
Continuous integration
CI, short for continuous integration, means that every time anyone pushes code, a server automatically gets a fresh copy and runs the project's checks, typically install, lint, tests, and since unit 3 a docker build.
If any check fails, the push is marked with a red ✗ on GitHub, the author is notified, and a pull request can be blocked from merging.
The machinery underneath is already familiar from the terminal course: exit codes. Every command finishes with a number, 0 for success and anything else for failure, readable as $?.
| Test outcome | Exit code | CI verdict |
|---|---|---|
| all tests green | 0 | pass |
| any test red | 1 | fail |
| runner crashed | non-zero | fail |
Test runners follow this convention exactly, so the CI server merely runs the listed commands in order and watches the exit codes. There is no magic understanding of the code, only 0 or not-0.
Exit codes first-hand
Three commands with predictable exit codes, printed through $?, which holds the exit code of the previous command.
true echo "true exited with: $?" false echo "false exited with: $?" grep -q "hello" <<< "hello world" echo "grep exited with: $?"
Output
true exited with: 0 false exited with: 1 grep exited with: 0
| Command | Exit code | Reason |
|---|---|---|
true | 0 | it exists only to succeed |
false | 1 | it exists only to fail |
grep -q "hello" | 0 | the pattern was found |
<<< is a here-string, feeding a literal string to a command as its standard input. Reading $? is a one-shot operation, since the next command overwrites it, which is why each echo follows immediately.
A CI server is essentially one if statement
A shell function standing in for a test suite, with an if deciding the verdict the way a CI server does.
run_tests() { grep -q "TODO" <<< "no unfinished work here" } if run_tests; then echo "tests passed" else echo "tests failed" fi
Output
tests failed
| Element | Behavior |
|---|---|
if some_command; then | takes the then-branch when the command exits 0 |
grep -q "TODO" | finds no match here, so it exits 1 |
| function exit code | the exit code of its last command |
No brackets appear anywhere, because if in bash tests a command rather than an expression. The familiar [ ... ] form is itself just a command, which is why both spellings work.
How a step is judged
A CI server decides a test step passed by checking the exit code of the command, where 0 means pass and anything else means fail.
CI is exit codes all the way down. Any command that exits 0 is a green step, and anything else is red and stops the run.
| Step command | Exit code | Result |
|---|---|---|
pytest with all tests green | 0 | green |
pytest with one failure | 1 | red, job stops |
a shell one-liner ending in false | 1 | red |
That is why literally any command can go in a pipeline, from a test runner to a shell one-liner. The CI system never inspects the code, it only inspects the number the process returned.
The number that means success
The exit code must be 0.
The Unix convention is 0 for success and any non-zero value for failure, and CI systems are built entirely on that convention. Success is the one specific value, while failure is everything else, and echo $? after a passing command prints it.
| Exit code | Meaning |
|---|---|
| 0 | success |
| 1 | generic failure |
| 2 and up | tool-specific failures |
A step that exits 0 is green and anything else is red and stops the job. This is also why a test runner that forgets to propagate a failing exit code silently breaks a pipeline, since the tests report failures on screen while CI still reads a 0 and moves on.