Module 8: Project A Ci Pipeline For Reservo

1. Module introduction: the complete pipeline

Description

For seven modules you learned one piece at a time. Run the suite on every push. Close the gap between your machine and the runner. Test on several versions. Cache and parallelize. Put up a coverage gate. Manage the flaky. Each piece came with its demo, its mini-project, and its clear boundary, isolated from the others so that you understood it in depth. It was good that it was so: you learn a tool better when it does not compete with five others for your attention. But a real pipeline is not one piece; it is all of them at once, woven into a single file that runs on every change and decides whether your code goes in or not.

This module is where you bring them together. You are going to assemble Reservo's complete CI pipeline —the one that runs the suite, runs it on three Python versions, speeds it up with cache and parallelism, and stops it dead if the coverage falls below a threshold— and you are going to deliver two concrete things: the complete tests.yml, with all the layers commented, and the local parity setup, a command that runs in your terminal exactly what the CI runs on the runner. That second deliverable is the one that makes "it works on my machine" stop being an excuse and become a verifiable promise.

By the end of this lesson you will have the whole map in your head: which stage of the pipeline solves which problem, which module each one came from, and how they stack on top of each other without getting in each other's way. You are going to see the complete pipeline drawn as a pipe of stages, the table that connects each stage with the pain it removes, and the first real run of Reservo's suite —the one the pipeline will execute in each cell—. You do not learn a new tool here: you learn to compose the seven you already have into something that really protects.

Connection with the module: this lesson is the blueprint of the capstone. Here you see the whole pipeline at a glance and understand how the layers fit; lessons 2 through 7 assemble it piece by piece, each one taking a layer and showing it in its place within the whole —lesson 2 the base workflow, lesson 3 the pinned dependencies, lesson 4 the matrix, lesson 5 cache and parallelism, lesson 6 the coverage gate, lesson 7 the flaky policy—. Lesson 8 is the formal project: you write the complete tests.yml, set up the local parity, and are evaluated by the method of your decisions. And that same lesson 8 closes the guide, with the review of the eight modules and the path toward the sibling guides.

A note about the honesty of the guide, which in this module matters more than ever because we are going to show a lot of pipeline. The CI workflow really runs on a GitHub runner, which we do not have here. So the YAML you write and read as content —I show you how it looks and how its log would read—, while the runs of pytest, coverage, and xdist are real, done locally with Python 3.14.0 and pytest 9.1.1, because your machine plays the part of one cell of the pipeline. Every number I cite —13 passed, 1 skipped, 88% coverage, 4.07s dropping to 1.19s with parallelism— I measured by executing, I did not invent it. When I show a CI log with three jobs, that is the honest format of how it would look, not a screenshot of a phantom runner.

The orchestra that rehearsed each instrument separately

Think of an orchestra preparing a symphony. For months, each section rehearsed on its own: the violins in one room, the brass in another, the percussion in its own. Each group polished its part until mastering it. If you listened to the violins alone, you would say "perfect"; to the brass alone, "impeccable." Each section, in isolation, sounds good.

But a symphony is not each section separately; it is all playing together, at the same time, coordinated. The first full rehearsal is the moment of truth: do the brass come in when they should, without covering the violins? Does the percussion mark the tempo the rest follow? Does the whole thing sound like a single piece, or like six groups competing? Mastering your instrument is necessary, but not enough. What makes the orchestra is the composition: the parts fitted into a whole that works.

Your CI pipeline is that symphony. Each module of the guide was the rehearsal of one section: you learned the base workflow, the matrix, the cache, the coverage gate, each until mastering it separately. This module is the full rehearsal. You are going to put the seven sections to play together in a single tests.yml and check that the whole thing sounds: that the matrix does not fight with the cache, that the coverage gate does not clash with the parallelism, that the flaky does not ruin an entire run. The capstone does not teach you a new instrument; it teaches you to conduct.

A complete CI pipeline is not one stage; it is the composition of all of them. Each module gave you a piece mastered separately; the capstone weaves them into a single workflow that really protects because the seven play together.

The complete pipeline, at a glance

Before assembling it piece by piece in the coming lessons, look at the whole. A push arrives, and the pipeline processes it in stages, each built on the previous one:

  push / pull_request
         │
         ▼
  ┌──────────────────────────────────────────────────────────────┐
  │  MATRIX: opens into 3 parallel cells (3.11 · 3.12 · 3.13)     │  ← module 4
  │  ┌────────────────────────────────────────────────────────┐  │
  │  │ 1. checkout        (bring the code to the runner)      │  │  ← module 2
  │  │ 2. setup-python    (install the cell's version)        │  │  ← module 4
  │  │ 3. restore CACHE   (pinned deps, no re-download)       │  │  ← module 5
  │  │ 4. pip install     (from requirements, deterministic)  │  │  ← module 3
  │  │ 5. pytest -n auto  (the suite, in parallel)            │  │  ← module 5
  │  │    + --cov-fail-under=85  (GATE: breaks if it drops)   │  │  ← module 6
  │  │    + flaky policy         (retry/quarantine)           │  │  ← module 7
  │  └────────────────────────────────────────────────────────┘  │
  └──────────────────────────────────────────────────────────────┘
         │
         ▼
  3 verdicts (✓/✗) → branch protection → merge or block

Read it top to bottom. A push (or a pull request) triggers the pipeline. The first thing that happens is that the matrix multiplies it: instead of one job, three open, one per Python version, running in parallel. Inside each cell, the steps are the same: bring the code, install the Python version of that cell, restore the dependency cache so as not to download them from the internet each time, install them from a pinned list, and run the suite —in parallel with -n auto, with the coverage gate active and the flaky policy applied—. At the end, three green or red verdicts, which branch protection uses to decide whether the change can go into main.

Notice something important: each stage came from a different module, and none is superfluous. The checkout and the pytest are the floor of module 2. The version of each cell is the matrix of module 4. The cache and the -n auto are the speed of module 5. The deterministic installation is the reproducibility of module 3. The --cov-fail-under is the gate of module 6. And the flaky policy is module 7. The capstone is, literally, this pipe: seven modules turned into one file.

The table that connects each stage with the problem it solves

A pipeline is not assembled by putting stages together just because. Each one exists to remove a concrete pain —a specific way in which software breaks when you do not have CI—. This is the table I want you to memorize, because it is the skeleton of the whole module: which stage, which problem it removes, and which module it came from.

Pipeline stageThe problem it removesModule
Run the suite on every push and PR"I forgot to run the tests before pushing" — the human error of relying on discipline2
Install from pinned dependencies"It works on my machine" — the CI in red and your local in green because of different versions3
Matrix of Python versions"It works in my version, but the user has another and it breaks" — the blind spot of a single environment4
Dependency cache + pytest-xdist"The CI takes an eternity and stops the whole team" — the pipeline turned into a bottleneck5
Coverage gate (--cov-fail-under)"New code arrived without a single test and nobody noticed" — the silent erosion of the safety net6
Flaky policy (retry / quarantine)"The CI goes red sometimes without anything changing, and nobody believes it anymore" — the eroded trust7

Keep it. In the coming six lessons we are going to run through this table row by row, in the same order, assembling the corresponding stage inside Reservo's tests.yml. When you finish the module, you should be able to look at any real-world pipeline, recognize each of these stages, and say which problem it protects against —or notice which one is missing and what risk it leaves open—.

There is a second reading of the table, more subtle, worth pointing out. The stages are not independent: they enable one another. The matrix (module 4) multiplies the work by three, which makes speed urgent (module 5): without cache or parallelism, three cells take triple the time. The speed, in turn, brings in the parallelism (-n auto), which introduces a non-deterministic execution order, which is precisely one of the triggers of the flaky (module 7). And the coverage gate (module 6) only makes sense if the suite really runs and does so reproducibly (modules 2 and 3). It is not a list of loose options; it is a chain where each link supports and stresses the next. Designing the pipeline is understanding that chain, not flipping switches at random.

Reservo, as we left it

We continue with Reservo, the room-booking system of a coworking space that we have been testing since the first module. Pure Python logic: no database, no network, no hidden clocks, money in integer cents. A review of its pieces, because the pipeline is assembled around its structure:

  • Room (id, name, capacity, hourly_cents), Member (id, name, tier: "basic" or "pro"), Booking (with its price_cents field, the start, the end as a half-open range [start, end), and its status).
  • The core functions in the reservo/ package: price_cents, refund_cents, overlaps, is_available, book, cancel, the in-memory Calendar, and report_pages (the version-dependent feature of module 4).
  • The anchor numbers, the checksum of the whole guide: basic 3 h → 7500, pro 3 h → 6000 (20% discount), basic 1 h → 2500; and the refund on 6000 paid: 6000 if you cancel 72 h before (≥ 48 h, 100%), 3000 at 36 h (24–48 h, 50%), 0 at 12 h (< 24 h).

The suite lives in tests/, split across four files: test_pricing.py, test_refunds.py, test_availability.py, and test_version_features.py. In total, fourteen tests, one of which is skipped on Python 3.12+ (the one for the manual fallback of report_pages, which only applies on old versions). That is the material the pipeline is going to protect.

Worked example: the run the pipeline executes in each cell

Before writing a line of YAML, let us run the suite on the machine where I am writing —Python 3.14.0, pytest 9.1.1— to see, for real, what each cell of the pipeline would execute. This is the unit of work that will be repeated three times in the matrix.

python -m pytest -v -rs

The -v flag (--verbose) lists each test with its verdict; -rs (report skipped) prints the reason of each test that gets skipped.

What to expect. On Python 3.14.0, measured by executing (this is the real output, not a mock-up):

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- /private/tmp/reservo-m8/.venv/bin/python
cachedir: .pytest_cache
rootdir: /private/tmp/reservo-m8
configfile: pyproject.toml
testpaths: tests
plugins: xdist-3.8.0, rerunfailures-16.4, cov-7.1.0
collected 14 items

tests/test_availability.py::test_sequential_bookings_do_not_overlap PASSED [  7%]
tests/test_availability.py::test_crossing_ranges_overlap PASSED           [ 14%]
tests/test_availability.py::test_available_on_empty_calendar PASSED        [ 21%]
tests/test_availability.py::test_book_rejects_a_conflicting_slot PASSED    [ 28%]
tests/test_pricing.py::test_basic_three_hours PASSED                       [ 35%]
tests/test_pricing.py::test_pro_three_hours PASSED                         [ 42%]
tests/test_pricing.py::test_basic_one_hour PASSED                          [ 50%]
tests/test_pricing.py::test_pro_one_hour_rounds_with_integer_math PASSED   [ 57%]
tests/test_refunds.py::test_full_refund_72h_before PASSED                  [ 64%]
tests/test_refunds.py::test_half_refund_36h_before PASSED                  [ 71%]
tests/test_refunds.py::test_no_refund_12h_before PASSED                    [ 78%]
tests/test_version_features.py::test_report_pages_groups_bookings PASSED   [ 85%]
tests/test_version_features.py::test_report_pages_uses_stdlib_batched PASSED [ 92%]
tests/test_version_features.py::test_report_pages_manual_fallback_on_old_python SKIPPED [100%]

=========================== short test summary info ============================
SKIPPED [1] tests/test_version_features.py:24: the manual fallback is only exercised on Python < 3.12
========================= 13 passed, 1 skipped in 0.02s =========================

Read the final summary: 13 passed, 1 skipped. Thirteen tests passed and one was skipped, cleanly, with its reason printed thanks to -rs. Among the thirteen greens are the six anchor numbers that are the heart of Reservo —test_basic_three_hours (7500), test_pro_three_hours (6000), test_basic_one_hour (2500), and the three refunds (6000, 3000, 0)—. The skip is test_report_pages_manual_fallback_on_old_python: since this machine runs 3.14 (≥ 3.12), the manual fallback branch does not apply, so pytest skips it without pretending it passes or failing for something irrelevant.

Notice also the header lines, because they tell the story of the whole module. configfile: pyproject.toml and testpaths: tests say there is pinned configuration (module 3). plugins: xdist-3.8.0, rerunfailures-16.4, cov-7.1.0 lists the three tools that the coming lessons activate: xdist for parallelism (module 5), rerunfailures for the flaky (module 7), cov for the coverage gate (module 6). All already installed, waiting their turn. This run is the base state —the green suite in one environment— on top of which we will stack, layer by layer, the six stages that are missing.

The two deliverables of the capstone

The whole module points to producing two things. It is worth being clear about them from now, because each lesson contributes a piece of one or the other.

Deliverable 1: the complete tests.yml. A single GitHub Actions workflow that weaves the six stages of the table: triggers on every push and PR, opens the matrix of three versions, restores the dependency cache, installs from a pinned list, and runs the suite in parallel with the coverage gate active. Commented, so that anyone who reads it understands what each line does and what it protects against. It is content —we do not execute it here—, but it is content that you understand down to the last line.

Deliverable 2: the local parity. A command or a short script —we will call it check.sh— that runs on your machine exactly what the CI runs on the runner: the same installation, the same suite, the same coverage gate. Its value is that it turns the CI from "the place where I discover that something was wrong" into "the automatic confirmation of something I already know is right." If check.sh passes in your terminal, you have direct evidence that the pipeline will pass. The local parity is the idea that has run through the whole guide, here turned into a concrete deliverable.

They are evaluated by the method, not by the size. A tests.yml with a matrix of three well-justified versions is worth more than one with nine cells turned on by reflex. A coverage gate at a threshold you can defend with an argument is worth more than one at 100% that blows up on the first legitimately non-coverable code. The capstone does not reward the biggest pipeline; it rewards the best thought-out one.

Common mistakes

Believing that "I know how to use each stage" is the same as "I know how to design the pipeline." What happens: someone completed the seven modules, knows how to write a matrix, a cache, a gate —each one separately—, and assumes that bringing them together is trivial. When assembling the complete tests.yml, they discover that the stages interact: the order of the steps matters, the cache depends on the dependency list, the gate needs the suite to run first. Why it happens: mastering an isolated piece and composing the whole are different skills, like playing an instrument and conducting an orchestra. How to detect it: if you have never written the six stages in a single file, you do not yet know whether they fit. How to fix it: this module, which assembles them together and in order; the full rehearsal is irreplaceable.

Turning on all the stages by reflex, without asking whether the project needs them. What happens: someone copies a "complete" pipeline from the internet —3×3 matrix, cache, xdist, gate at 95%— for a project that does not warrant it, and ends up with nine cells for pure logic, parallelism that only adds overhead to a 0.02s suite, and a gate that blows up on legitimately non-coverable code. Why it happens: "more stages" feels "more professional." How to detect it: for each stage, ask yourself "what real problem of this project does it remove?". If the answer is "none yet," that stage is cost and noise. How to fix it: the table of this lesson in reverse —start from the problem, not the tool—. Reservo needs the matrix (it is a library that promises versions) but does not need -n auto on its current suite (it is 0.02s); the capstone justifies each choice.

Delivering the YAML without the local parity. What happens: someone writes an impeccable tests.yml and considers it done, without ever running the commands on their machine. They push, and the real CI reveals in minutes what the terminal would have revealed in seconds: a missing dependency, a badly written command, a badly set threshold. Why it happens: the YAML "looks good" and gives a feeling of finished work. How to detect it: if you do not have a real pytest/coverage output from your own terminal, you have verified nothing, only written intentions. How to fix it: the local parity is half the deliverable, not an extra. Run on your machine the same thing the CI will run, and paste the green output as evidence.

Exercises

Exercise 1 — Map the stage to the problem. For each symptom, say which stage of the pipeline solves it and which module it comes from. (a) "A colleague pushed a change without running the tests and broke main." (b) "The test passes on my laptop with Python 3.13 but a user with 3.11 reports an ImportError." (c) "Each push takes 14 minutes and the team complains." (d) "Someone added a 40-line function without a single test and nobody noticed in the review."

See solution
  • (a) Run the suite on every push and PR → module 2. The problem is relying on everyone remembering to run the tests; the pipeline runs them automatically on every push, so "I forgot" can no longer break main.
  • (b) Matrix of versions → module 4. A single green environment asserts a single environment. The matrix runs the suite on 3.11, 3.12, and 3.13, so an ImportError that only appears on 3.11 is caught in the 3.11 cell before reaching a user.
  • (c) Cache + parallelism → module 5. The pipeline became a bottleneck. Caching the dependencies avoids downloading them each time, and pytest-xdist runs the suite in parallel; together they cut the feedback-cycle time.
  • (d) Coverage gate → module 6. New code without tests lowers the coverage; --cov-fail-under breaks the build if it drops below the threshold, turning "it arrived without tests" from something invisible into something that stops the merge.

The mechanical rule: each symptom is the problem that a row of the table removes. If you can name the row, you understand why the stage exists.

Exercise 2 — The chain of dependencies between stages. In the lesson we said the stages "enable one another." Explain, in three sentences, why adding the matrix (module 4) makes the cache and parallelism (module 5) more urgent, and why the parallelism can in turn feed a flaky problem (module 7).

See solution

The matrix multiplies the work: a matrix of three versions runs the full suite three times per push, so any slowness —downloading dependencies, running the tests in series— is paid triple, and what was tolerable in a single job becomes a bottleneck in three. That is why the cache (not downloading the dependencies again in each cell) and the parallelism (-n auto, running each cell's tests in several processes) go from "luxury" to "necessity" as soon as there is a matrix.

The parallelism, in turn, changes the order in which the tests run: with -n auto, the tests are distributed among processes and no longer run in the predictable sequence of a serial run. If some test unintentionally depended on the order —it left a file, a global variable, a shared state that another test assumed—, that hidden coupling, invisible in series, is uncovered in parallel as an intermittent failure: a flaky. Thus, the speed stage (module 5) can expose work for the flaky stage (module 7). It is not a defect of xdist; it is that the parallelism reveals a coupling that already existed and that the serial execution hid.

Exercise 3 — Which stage does Reservo NOT need (yet), and why? The pipeline we will assemble includes six stages. Reservo is a pure-logic library (integer arithmetic, date comparison; no network, no disk, no DB), with a suite that runs in 0.02 seconds. Choose one stage that, for Reservo as it is today, is debatable or directly unnecessary, and justify why —and what would have to change in Reservo for it to be worth it—.

See solution

The most defensible answer is the parallelism with pytest-xdist (-n auto). Reservo has a suite of fourteen tests that runs in 0.02 seconds. Parallelizing it would not make it faster: pytest-xdist has to start worker processes, distribute the tests among them, and collect the results, and that fixed overhead —on the order of a second— is greater than the 0.02s the suite takes in series. Turning on -n auto on a suite like this would make it slower, not faster (you will see it measured in lesson 5). It is the right tool for the wrong problem: xdist pays off when the suite is slow (minutes), not when it is already instantaneous.

For it to be worth it, Reservo would have to grow until its suite took long enough to amortize the overhead: hundreds or thousands of tests, or genuinely slow tests (that do I/O, that wait for resources, that simulate load). The day the suite takes, say, a minute in series, -n auto over several cores would drop it to a fraction, and there the parallelism would go from overhead to investment. Another defensible answer is the operating-system dimension in the matrix: Reservo is pure logic that gives identical results on Linux, macOS, and Windows, so testing on three OSes would spend triple without catching a single extra bug —that would change the day Reservo writes files to disk, where paths and line breaks do differ by system—. The underlying lesson: a stage is justified by the problem it removes, and an instantaneous pure-logic suite does not (yet) have the speed problem or the OS-portability one.

Summary and next step

In this lesson you installed the blueprint of the capstone: a complete CI pipeline is not one stage, it is the composition of all of them. You saw Reservo's whole pipeline drawn as a pipe —push, matrix of three cells, and inside each one checkout, setup-python, cache, install, and pytest with the coverage gate and the flaky policy— and understood that each stage came from a different module and removes a concrete problem. You memorized the table that connects stage ↔ problem ↔ module, and saw that the stages are not independent: they enable and stress one another, like the sections of an orchestra in the full rehearsal.

You ran Reservo's suite for real —13 passed, 1 skipped on Python 3.14.0— and read in its header the three tools that the coming lessons activate (xdist, rerunfailures, cov), already installed and waiting their turn. And the two deliverables toward which the whole module points became clear: the complete tests.yml and the local parity, evaluated by the method of their decisions, not by the number of tests.

Before moving on you should be able to: draw the pipe of the pipeline from memory and name each stage; recite the table stage ↔ problem ↔ module; explain why the matrix makes speed urgent and why the parallelism can feed a flaky; and justify which stage Reservo does not need yet.

What follows, in lesson 2, is starting to assemble the pipeline by its base: the workflow that runs the suite. It is the layer of module 2 —checkout, setup-python, install, pytest— seen now as the floor on which the other five stages stack. You are going to write it, run its local parity, and confirm with your hands that the green skeleton works, so that then, lesson after lesson, you add the reproducibility, the matrix, the speed, the gate, and the flaky policy until you have the entire tests.yml.

Resources

  • Building and testing Python — GitHub Actions — GitHub's official guide for testing Python projects, the starting point of the pipeline we will assemble layer by layer. Return to it when you set up the CI of your own project.
  • How to invoke pytest — pytest documentation — the ways of running the suite (complete, verbose, with -rs) that we used in the base run, and the exit-codes section that connects with the color of the job. The day-to-day reference.
  • Understanding GitHub Actions — the vocabulary of the pipeline (workflow, job, step, runner) that appears in the pipe of this lesson. Read it so that the terms of the coming six lessons fall into place.
  • pytest-xdist, coverage.py, and pytest-rerunfailures — the three tools that the run's header listed (plugins: xdist, rerunfailures, cov) and that lessons 5, 6, and 7 activate. Here we only glimpse them; in their modules we open them up.