Course outline · 0% complete

0/29 lessons0%

Course overview →

Fail fast: test, then build

lesson 7-3 · ~11 min · 21/29

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.

StepTypical costPosition
installseconds, cachedfirst
linta secondearly
teststens of secondsbefore the build
docker buildminuteslast

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
LineRan
install echoyes
test echoyes
falseyes, and aborted the subshell
build echono

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 commandRepresentsPipeline result
falsea failing test suiteaborts, exit 1
truea passing test suitecompletes, 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.

DetailWhy it matters
runners ship with Docker preinstalleddocker 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.

OrderTime to learn the code is broken
tests, then buildabout ten seconds
build, then teststwo 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.

OptionEffect
-eexit on the first failing command
-uerror on an undefined variable
-o pipefaila 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.