Module 1: From Your Machine To The Pipeline

5. The stages of a pipeline

Description

By the end of this lesson you'll know what a pipeline is and be able to name its stages: checkout → install → test → report. So far we've talked about CI as a box —"it runs your suite on a clean machine"—; this lesson opens the box and shows you the machinery inside. And the good news, which you'll verify, is that inside there's no magic: each stage of the pipeline is a command you already know —clone the repo, install dependencies, run pytest— lined up, one after another, run by a machine instead of by you. A pipeline is nothing more than your manual testing flow, written so a machine can repeat it the same every time.

You'll understand three ideas that make a pipeline work. First, that the stages go in order and depend on each other: you can't test what you didn't install, you can't install what you didn't clone. Second, that a pipeline fails fast (fail fast): if a stage falls, the following ones aren't even attempted, because it wouldn't make sense. And third, the most important of all: what the signal is with which the pipeline decides whether the whole thing passed or failed —the exit code of the test process—, a number that was already there on your machine and that you never looked at, but on which the whole CI verdict depends. We're going to measure that number for real.

Connection to the module: this lesson is the anatomy. Lesson 3 defined CI, lesson 4 explained why it's worth it; this one shows the concrete pieces that make it. What follows leans on it: lesson 6 explains how the verdict of these stages protects the main branch; lesson 7 places CD as additional stages that come after these. And an important boundary: here you'll see the stages as concept and as commands; the YAML file that declares them in GitHub Actions —with steps:, uses:, run:— is the topic of module 2. Here you understand what each stage does; there you learn to write it.

The assembly line

Think of it this way. In a car factory, a car isn't assembled at a single station where one person does everything. It's assembled on an assembly line: a row of stations, each with a task, through which the car advances in order. The first station puts the chassis. The second, which receives the chassis from the first, mounts the engine. The third, which receives that, puts the wheels. The fourth inspects and approves. Each station depends on the previous one having finished well: you can't mount the engine if the chassis didn't arrive, you can't put wheels if there's no car.

And there's a golden rule on the line: if a station detects a serious defect —the chassis comes in crooked—, it stops the line. It makes no sense to mount a thousand-dollar engine on a crooked chassis that's going to the trash. Better to stop at station 1, throw out the bad chassis, and not waste the engine. Stopping early saves all the work of the following stations.

A CI pipeline is that assembly line, but what advances down the row isn't a car: it's your code change. Each station is a stage, with a task, and each depends on the previous one. And the same golden rule governs: if a stage falls, the line stops —the following ones aren't even attempted—, because it makes no sense to continue. The word "pipeline" comes precisely from this image: something enters at one end, passes through a series of stations in order, and comes out the other with a verdict —approved or rejected—.

A pipeline is an ordered sequence of automatic stages, where each depends on the previous one and a failure stops the line. For a test pipeline, the stages are: bring the code, install the environment, run the suite, and report the verdict.

The four stages, mapped to commands you already know

Here's the demystifying part: each stage of a test pipeline is a command you already use —or would use— when you test Reservo by hand. The pipeline just lines them up so a machine can run them. Let's look at them one by one.

Stage 1 — Checkout: bring the code

What it does: CI's clean machine starts empty; the first thing is to bring your code to that machine. It's the equivalent of you, on a new machine, cloning the repository.

The command you already know: git clone (or, inside a repo, bringing the exact branch and commit of the change that triggered the pipeline). In Reservo, it's the step of having the reservo/ folder with its models and functions, and the test_*.py next to it, present on the machine.

Why it's stage 1: without code, there's nothing to install or test. Everything else depends on this.

Stage 2 — Install: build the environment

What it does: on the clean machine with the code already present, it installs what the project needs to run: the declared Python version and the dependencies. This is the stage that materializes the "clean and reproducible environment" of lesson 3: it's built from scratch, from what the project declares, not from what "was already installed".

The command you already know: prepare the Python version and then pip install -r requirements.txt (or the dependency manager you use). Reservo is pure stdlib, so its dependencies are minimal —basically pytest—, but the stage exists anyway: it installs pytest on the clean machine.

Why it depends on stage 1: to install the dependencies the project declares, you first need the file that declares them (requirements.txt), which came with the code in stage 1.

Stage 3 — Test: run the suite

What it does: with the code present and the environment built, it runs your suite. This is the heart of the pipeline, the reason for everything else. And here's the key we've been repeating since lesson 3: CI runs exactly the same command you run on your machine.

The command you already know: python3 -m pytest. The same, exactly. In Reservo, this runs test_pricing.py, test_scheduling.py, and test_refunds.py and produces the output you've already seen —12 passed—, only on the runner's clean machine.

Why it depends on stage 2: you can't run pytest if pytest isn't installed, and you can't test import reservo if the code isn't there. Stages 1 and 2 exist so this one can happen.

Stage 4 — Report: deliver the verdict

What it does: it takes the result of stage 3 and turns it into a visible verdict: a green or red check on the commit or the PR, with the full log available for inspection, and sometimes artifacts (a coverage report, for example —that's module 6—). This is the stage that closes lesson 4's feedback loop: it's the one that warns you.

The command you already know: here you don't type a new command; the "report" is born from something that was already in stage 3 and that you never looked at: pytest's exit code. That's what the following section is about, because it's the central mechanism of all of CI.

The central mechanism: the exit code

Here we arrive at the most important and worst-understood piece of CI. How does the machine know whether your suite passed or failed? It doesn't "read" the 12 passed as text nor understand discounts. The machine knows a single thing, the only one it needs: the exit code that the pytest process hands it when it finishes.

Every command-line program, when it finishes, returns to the system an integer called an exit code. The universal convention —not just pytest's, all of Unix's— is simple: 0 means "all good"; any number other than 0 means "something failed". pytest respects this convention to the letter: if all tests pass, it exits with 0; if at least one fails, it exits with 1. That number is the signal CI reads to decide green or red. Everything else —the pretty log, the dots, the summary— is for humans; for the machine, the whole verdict fits in a number.

This number was always there, on your machine, every time you ran pytest —only your shell didn't show it to you—. Let's measure it for real, because seeing it with your own eyes demystifies the whole of CI.

What to expect (green suite). We run Reservo's full suite and, right after, we ask for the exit code with echo $? (a special shell variable that stores the exit code of the last command):

$ python3 -m pytest test_pricing.py test_scheduling.py test_refunds.py
============================== 12 passed in 0.02s ==============================
$ echo $?
0

Zero. The twelve tests passed, pytest exited with 0, and that 0 is what CI interprets as "green, let the change through". Now let's break something —we run lesson 2's fragile test in a clean environment, where it fails—:

$ env -u RESERVO_PRO_DISCOUNT python3 -m pytest test_env_pricing.py
============================== 1 failed in 0.03s ==============================
$ echo $?
1

One. The test failed, pytest exited with 1, and that 1 is what CI interprets as "red, block the change". This is, literally, all CI needs to know. It doesn't understand Reservo, or prices, or why it failed: it read a 1 and painted the check red. In lesson 3 you saw it from the other side, in the CI log: the line Error: Process completed with exit code 1 is the runner reacting to this very number.

Stop at what this means, because it's liberating: CI isn't smart, and it doesn't need to be. All the pipeline's machinery boils down to "run these commands in order; if any exits with a non-zero code, stop the line and report red". The same assembly-line rule —stop the line at a defect— is, in code, "if the exit code isn't zero, stop". Understanding this takes away your fear of CI: it's a pipe that runs your commands and looks at a number.

Fail fast: why a fallen stage stops the line

Go back to the assembly line's golden rule: if the chassis comes in crooked, don't mount the engine on it. In the pipeline it's the same, and it's called fail fast. If an early stage falls, the following ones don't run, because it wouldn't make sense:

  • If stage 1 (checkout) fails —the code couldn't be brought—, there's nothing to install or test. The line stops there.
  • If stage 2 (install) fails —a dependency couldn't be installed, for example a broken requirements.txt—, stage 3 isn't even attempted: you can't run a suite in an environment that couldn't be built. The pipeline reports red at the install stage, and the log tells you the failure was there, not in your tests.
  • If stage 3 (test) fails —a red test—, the report stage receives the exit code 1 and paints the check red.

This has a very useful practical consequence for diagnosing: the pipeline tells you which stage it fell at, and that's already half the diagnosis. A red at stage 2 (install) is an environment or dependencies problem —nothing to do with your logic—; a red at stage 3 (test) is a test that failed. Knowing how to distinguish them saves you from looking for the bug in the wrong place. (When the red at stage 3 appears only in CI and not in your local, you're facing lesson 2's "it works on my machine", and reproducing it is module 3.)

Common mistakes

Thinking CI "understands" your tests. What happens: someone imagines CI intelligently analyzes the code and decides if it's fine, and so treats it like an oracle or gives it reverential respect. Why it happens: the result —a green or red verdict— looks like an intelligent judgment. How to spot it: if you think CI does anything more than run commands and look at exit codes, you attribute to it intelligence it doesn't have. How to fix it: internalize that CI runs pytest (the same as you) and reads a number: 0 or non-zero. All its "intelligence" is that convention. This doesn't make it less useful —it makes it predictable, which is better—: you know exactly what it looks at, so you know exactly how to give it green (make your commands exit with 0).

Confusing an install failure with a test failure. What happens: CI is red, someone assumes a test failed, and starts reviewing the logic of their tests… when in reality stage 2 (install) fell and stage 3 never ran. They lose an hour looking for a bug that doesn't exist. Why it happens: "CI is red" is read as "a test failed", without looking at which stage. How to spot it: if you didn't check which stage the line stopped at before starting to debug, you're going in blind. How to fix it: read the log top to bottom and locate the first stage that fell. A red at install is a dependencies or environment problem; a red at test is a test. Fail fast gives you that clue —use it—.

Ignoring the exit code when running locally. What happens: someone chains commands in a local script —run tests and then deploy— without checking the tests' exit code, so the deploy happens even if the tests failed. The script "ran fine" to their eyes because it didn't blow up, but it deployed broken code. Why it happens: in the terminal, the exit code is invisible if you don't look at it with echo $?; it's easy to forget it exists. How to spot it: if your scripts chain steps without verifying the previous one exited with 0, you have this bomb set. How to fix it: respect the exit code the way CI does. In a script, cmd_a && cmd_b only runs cmd_b if cmd_a exited with 0 —that's the fail fast rule by hand—. CI does this for you between stages; locally, do it yourself.

Exercises

Exercise 1 — Map the manual flow to the stages. Here's what you do by hand when you test Reservo on a new machine, out of order. Associate it with the four pipeline stages (checkout / install / test / report) and put it in the correct order. (a) You run python3 -m pytest and see if it says passed or failed. (b) You run pip install -r requirements.txt. (c) You clone the Reservo repository. (d) You read the summary and decide if the code is ready to merge.

See solution

The order and the mapping:

  1. (c) You clone the repository → stage 1, checkout. Bring the code to the machine. Without this, there's nothing to do.
  2. (b) pip install -r requirements.txt → stage 2, install. Build the environment with the declared dependencies (for Reservo, essentially pytest).
  3. (a) python3 -m pytest → stage 3, test. Run the suite. The heart of the pipeline; the same command CI runs.
  4. (d) You read the summary and decide → stage 4, report. Turn the result into a verdict. In CI, this stage is automatic and born from (a)'s exit code.

The lesson: a pipeline doesn't invent new steps; it takes the ones you already do by hand and lines them up so a machine can repeat them the same every time. Recognizing that "CI is my manual flow automated" takes away all the mystery.

Exercise 2 — Predict the exit code. For each situation, say with which exit code pytest finishes (0 or non-zero) and, therefore, which check CI paints (green or red). (a) Reservo's 12 tests pass. (b) 11 pass and 1 fails. (c) The 12 "pass" but one was badly written and actually tested nothing (an assertion that's always true). (d) The install stage failed and pytest never even ran.

See solution
  • (a) Code 0 → green. All passed; pytest exits with 0. It's the case we measured: 12 passed, echo $?0.
  • (b) Code 1 → red. At least one test failed; pytest exits with 1 (regardless of how many passed). We measured it: 1 failed, echo $?1.
  • (c) Code 0 → green. Watch out for this one! pytest only looks at whether the tests pass, not whether they're good. A test that always goes green (a weak assertion) passes, so pytest exits with 0 and CI paints green —even though it's testing nothing—. CI inherits the quality of your suite; it doesn't judge it. (This is a topic for fundamentals and module 6: coverage helps detect these gaps.)
  • (d) Non-zero code → red, but at another stage. If install failed, the line stops there (fail fast) and pytest never runs. The pipeline reports red, but the log shows the failure at stage 2, not in your tests. Distinguishing it is key so you don't debug in the wrong place.

The lesson: CI decides by the exit code, and that number only says "passed / didn't pass", not "is good / is bad". A CI green is worth exactly what your suite is worth.

Exercise 3 — Fail fast as diagnosis. A teammate's CI is red. Without seeing their code, you ask them a single question that tells you whether the problem is environment or logic. (a) What's the question? (b) If they answer "it fell at the install stage", where should you NOT look for the bug? (c) If they answer "it fell at the test stage, with a red test, but on my machine it passes", what phenomenon of the guide is it and to which module do you send it?

See solution
  • (a) The question is: "which stage did the line stop at?" (or "which is the first stage that appears red in the log?"). Thanks to fail fast, the stage where it fell already tells you what type of problem it is.
  • (b) If it fell at install, do NOT look in the logic of your tests or of Reservo. The problem is environment or dependencies: a broken requirements.txt, a version that couldn't be installed, a missing package. The suite never ran, so the bug isn't in it. Looking there would be a waste of time.
  • (c) It's "it works on my machine" (lesson 2): a test that passes in your environment and fails in CI's clean one. It's almost certainly an environment leak —variable, version, dependency, path, clock—. You send it to module 3, which is dedicated to reproducing locally a failure that only appears in CI.

The lesson: fail fast isn't just efficiency (not wasting extra stages); it's diagnosis. Knowing which stage the line fell at halves the space where to look for the problem, before reading a single line of code.

Summary and next step

In this lesson you opened the pipeline from the inside and saw there's no magic: it's an ordered sequence of stagescheckout → install → test → report— where each depends on the previous one and a failure stops the line (fail fast). Each stage is a command you already know —git clone, pip install, python3 -m pytest— lined up so a machine can repeat it the same every time. A pipeline is your manual testing flow, automated.

The most important thing you take away is the central mechanism: CI decides green or red by reading the exit code of the test process. You measured it for real: Reservo's green suite exits with 0 (echo $?0), and a red test exits with 1 (echo $?1). CI doesn't understand prices or tests; it reads that number and paints the check. All its machinery boils down to "run the commands in order; if one exits with a non-zero code, stop and report red".

Before moving on you should be able to: name the four stages in order and the command of each; explain what the exit code is and what 0 and 1 mean; explain fail fast and why the stage where the line falls is a diagnostic clue.

What's next is understanding what all this machinery protects. In lesson 6 you'll see that the verdict of these stages —that green or red born from an exit code— has a concrete job on a team: keeping the main branch always green, so no one merges broken code and whoever clones main always receives something that works.

Resources