Order steps to fail fast
A CI job stops at the first failing step, so the cheapest and most likely to fail checks belong first. The standard order is install, then lint, then test, then docker build.
There is no point spending two minutes building an image from unit 3 for code whose tests already failed in ten seconds.
| Step | Typical cost | Position |
|---|---|---|
| install | seconds, cached | first |
| lint | a second | early |
| tests | tens of seconds | before the build |
docker build | minutes | last |
Shell scripts get the same behavior from set -e, which appears inside CI steps constantly. It makes the script exit at the first failing command instead of blundering on.
set -e pip install -r requirements.txt pytest docker build -t myapp .
With set -e, a non-zero exit from pytest means docker build never runs. Without it, bash shrugs and keeps going, which is how an image whose tests failed gets shipped. The next block simulates exactly this.
A pipeline that aborts at the first failure
A subshell whose set -e aborts at the first failing command, with false standing in for a failing test suite.
( set -e echo "install: ok" echo "test: ok" false echo "build: ok" ) echo "pipeline exit code: $?"
Output
install: ok
test: ok
pipeline exit code: 1| Line | Ran |
|---|---|
| install echo | yes |
| test echo | yes |
false | yes, and aborted the subshell |
| build echo | no |
The build step never prints, which is the whole behavior CI relies on. The parentheses create a subshell so set -e applies only inside it, and the subshell's own exit code becomes the 1 reported afterwards.
The same pipeline with the test fixed
Swapping the failing command for true lets every step run, and the pipeline exits 0.
( set -e echo "install: ok" echo "test: ok" true echo "build: ok" ) echo "pipeline exit code: $?"
Output
install: ok
test: ok
build: ok
pipeline exit code: 0| Stand-in command | Represents | Pipeline result |
|---|---|---|
false | a failing test suite | aborts, exit 1 |
true | a passing test suite | completes, exit 0 |
With no failures, set -e never triggers and the subshell exits with the code of its last command. This is the green run that a CI dashboard shows as a checkmark.
The same idea as a real workflow
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install -r requirements.txt
- run: pytest
- run: docker build -t myapp:${{ github.sha }} .Two things here are new.
| Detail | Why it matters |
|---|---|
| runners ship with Docker preinstalled | docker build from lesson 3-1 works with no setup |
${{ github.sha }} | an Actions expression holding the commit hash being tested |
Tagging the image with the exact commit, instead of latest as warned about in lesson 6-1, means every image traces back to the code that produced it. That traceability is what makes a rollback in lesson 8-3 possible at all.
What is missing is docker push, because the runner has no registry credentials yet. Secrets are the first stop of unit 8.
Why tests come before the build
Teams run pytest before docker build because steps stop the job at the first failure, so putting cheap fast checks first makes a broken commit fail in seconds instead of after a long build.
The job dies at the first non-zero exit code, so ordering steps from cheap to expensive gives the quickest feedback and wastes the least runner time.
| Order | Time to learn the code is broken |
|---|---|
| tests, then build | about ten seconds |
| build, then tests | two minutes or more |
The build is pointless anyway if the tests already prove the code is broken, since nobody will deploy that image. On a busy repository the wasted runner minutes are also a direct bill.
The line that aborts on failure
The line is set -e, a shell option switched on with the set builtin.
Without it, bash keeps executing after a failure, which in a deploy script can mean shipping an image whose tests just failed. With it, the first non-zero exit code aborts the script, matching how CI steps behave.
| Option | Effect |
|---|---|
-e | exit on the first failing command |
-u | error on an undefined variable |
-o pipefail | a pipeline fails if any stage fails |
Most real CI shells run with all three by default. The pipefail option matters more than it looks, because without it a command such as pytest | tee log.txt reports the exit code of tee, which almost always succeeds.