Module 8: Project A Ci Pipeline For Reservo

2. The base workflow that runs the suite

Description

Every pipeline, however sophisticated it may become, starts with the same humble question: how do I make my suite run on its own on every change? The answer is a minimal workflow —bring the code, install Python, install dependencies, run pytest— and that is the layer we assemble here, the first of the six that the capstone weaves. It is the floor. Without this layer there is no pipeline: the matrix has nothing to multiply, the cache has nothing to speed up, the gate has nothing to watch. Everything else stacks on top of these four steps.

You already set up this workflow in module 2, and we are not going to relearn it from scratch. What we do here is different: we look at it as the base layer of a whole, understanding what responsibility it has and where it ends, so that the coming five lessons know exactly what they add to it. You are going to write the tests.yml in its simplest form that works, run its local parity —the same commands in your terminal, with the real output— and confirm that Reservo's green skeleton works before complicating it. A pipeline that starts simple and grows with intention is robust; one that is born huge and copied is a black box.

By the end you will have the floor of the pipeline in place and verified: the base workflow written and explained step by step, the local parity executed (13 passed, 1 skipped, exit code 0), and the understanding of why this is the skeleton on which everything rests —and what, deliberately, it still does not do—.

Connection with the module: this lesson opens the journey that assembles the pipeline piece by piece. Lesson 1 gave you the complete blueprint; here you place the first layer. Lesson 3 will add reproducibility (pinned dependencies), lesson 4 the version matrix, lesson 5 the speed, lesson 6 the coverage gate, lesson 7 the flaky policy. Each one takes this base workflow and adds its stage. That is why it matters to understand it as the floor: when in lesson 4 we add strategy.matrix, you will see that it wraps exactly these steps; when in lesson 6 we add --cov-fail-under, you will see that it modifies exactly this pytest. The rest of the module is this skeleton, fattened with intention.

The foundations before the floors

Think of building a building. Nobody starts with the penthouse. You start with the foundations: a leveled, plumb slab, capable of holding what comes on top. The foundations are not glamorous —nobody photographs a slab—, but if they are wrong, each floor you add amplifies the error, and by the fifth floor nothing lines up. The serious builder invests in the foundations precisely because everything else rests there.

The base workflow is your pipeline's slab. It has no matrix, no cache, no coverage gate; it does the minimum —runs the suite on every push— but does it well: the code arrives at the runner, the correct Python is installed, the dependencies go in, pytest runs and its verdict paints the job. When in the coming lessons you stack the matrix on top, or the cache, or the gate, all those layers will rest on these four steps. If the floor is crooked —the checkout is missing, the version misspelled—, each layer you add will inherit the problem. That is why we start here, we leave it plumb, and only then do we go up.

The base workflow is the pipeline's slab: the four steps —checkout, setup-python, install, pytest— on which all the following stages rest. Simple on purpose, plumb on purpose, because everything else stacks on top.

The base workflow, step by step

Here is the floor of Reservo's pipeline. Four steps, fourteen lines of content:

# .github/workflows/tests.yml
name: tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Check out the code
        uses: actions/checkout@v5

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.14"

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Run the test suite
        run: python -m pytest

Review each decision —all from module 2, but now read as the base of a whole that is going to grow:

  • name: tests — the label of the workflow in the Actions tab. Descriptive and stable, so that the badge and the history always say the same thing.
  • on: [push, pull_request] — the two standard triggers. push gives feedback on every change you push to your branch; pull_request puts the guardian on every merge request toward main. The two bells of the pipeline.
  • runs-on: ubuntu-latest — a clean Linux machine, cheap and sufficient for pure Python logic. (In lesson 4, when the matrix arrives, this value can become ${{ matrix.os }}; for now, a single row.)
  • Check out the code with actions/checkout@v5 — first of all, because nothing can be done without the code on the runner. Without this step, pip install -r requirements.txt would not find the file and pytest would not find tests.
  • Set up Python with python-version: "3.14" — the version of the guide, in quotes so that YAML does not read it as the number 3.14 and lose precision. It goes before installing, so that pip and pytest run on this Python. (In lesson 4 this line becomes ${{ matrix.python-version }}.)
  • Install dependencies — the | groups two commands: upgrade pip (hygiene) and install from requirements.txt. (In lesson 3 we will switch to requirements-dev.txt to bring the CI tools; in lesson 5, this step will gain the cache.)
  • Run the test suite with python -m pytest — the heart: it discovers and runs the fourteen tests, and its exit code paints the job. (In lesson 5 it will gain -n auto; in lesson 6, --cov-fail-under.)

Every parenthesis I just wrote is a promise of the coming lessons. That is the point of looking at the workflow as a base: each step has a place where a future stage will hook in. The requirements.txt that this workflow installs, for now, is one line —Reservo is pure stdlib and only needs pytest to test itself—:

# requirements.txt
pytest==9.1.1

The local parity: run the floor in your terminal

The idea that holds up the whole guide: what the CI does to your suite is the same thing your machine does to it. You can check it by running, in your terminal, the same steps that the workflow would run on the runner. If you get the same green output, you have direct evidence that the pipeline will do the right thing. Let us run through the steps of the base workflow, one by one, locally. Each command was really executed with Python 3.14.0 and pytest 9.1.1.

The Check out the code step locally is, simply, being in your already-cloned project. The runner brings the code; you already have it. There is no command to run.

The Set up Python step locally is having the correct Python active, in a clean virtual environment:

python3.14 -m venv .venv
source .venv/bin/activate
python --version
Python 3.14.0

Same Python that setup-python: "3.14" would leave on the runner. Parity in the version: confirmed.

The Install dependencies step locally is the two same commands of the run::

python -m pip install --upgrade pip
pip install -r requirements.txt

It installs pytest and its transitive dependencies; the exact versions we will see as a snapshot in lesson 3.

The Run the test suite step locally is the workflow's pytest:

python -m pytest

What to expect (real output of Reservo's suite, with the dot output that pytest uses by default):

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
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 ....                                          [ 28%]
tests/test_pricing.py ....                                               [ 57%]
tests/test_refunds.py ...                                                [ 78%]
tests/test_version_features.py ..s                                       [100%]

======================== 13 passed, 1 skipped in 0.02s =========================

Read the output slowly. Each dot is a test that passed; the s at the end of test_version_features.py is the test that was skipped (the manual fallback, which on 3.14 does not apply). The summary —13 passed, 1 skipped— is identical to the one you would see on the runner, except for the platform line: here darwin (macOS), on the runner it would be linux (Ubuntu). Everything else —Python 3.14.0, pytest 9.1.1, collected 14 items, the final count— matches, because it is the same command on the same suite with the same dependencies.

The exit code: how pytest paints the job

A runner does not "read" pytest's output to decide whether the job passes; it reads its exit code, the number that every command-line program returns when it finishes. By universal convention, 0 means success and any number other than 0 means failure. pytest respects that convention: if all the tests pass (or are skipped), it returns 0; if any fails, it returns 1. The runner runs python -m pytest, looks at the number, and paints the step green if it is 0 or red if not. Check it locally:

python -m pytest -q > /dev/null; echo "exit code: $?"
exit code: 0

$? is the shell variable that holds the exit code of the last command; > /dev/null hides the output to see only the number. 0: on the runner, that zero would paint the Run the test suite step green with a ✓, and the whole job green. A 1 skipped does not alter the 0 —a skip is not a failure, it is a third state—, so Reservo's suite, with its skipped test, still gives exit 0 and a green job.

To see the other side —that the floor bites when it should—, break a Reservo rule on purpose and run again. If you changed the pro discount from 20% to 25% in reservo/pricing.py, test_pro_three_hours would expect 6000 and receive 5625, and you would see:

=========================== short test summary info ============================
FAILED tests/test_pricing.py::test_pro_three_hours - assert 5625 == 6000
========================= 1 failed, 13 passed in 0.02s =========================
python -m pytest -q > /dev/null 2>&1; echo "exit code: $?"
exit code: 1

Exit code 1: on the runner, that one would paint the step red with a ✗ and the whole job red, and —with branch protection— would block the merge. You restore the code (back to 20%) and the exit code returns to 0. This cycle —green gives 0, red gives 1, I restore, it returns to 0— is the CI machine verified in your own terminal. A pipeline you never saw go red is one you should not fully trust.

What the floor does, and what it deliberately does not

Being honest about the scope of this layer is part of the craft, and it is what makes the coming lessons make sense. The base workflow does: run Reservo's full suite on every push and pull request, on Python 3.14 on Ubuntu, installing from requirements.txt, and paints the job according to the exit code. That is already an enormous leap —from "I run the tests when I remember" to "they run on their own on every change"—.

What it deliberately does not do yet, and which lesson adds it:

  • It does not fix the exact versions of everything it installs beyond pytest, nor guarantee that the runner and your machine install identically → lesson 3 (pinned dependencies, reproducibility).
  • It does not test on more than one Python version → lesson 4 (the matrix).
  • It does not cache anything, so it downloads the dependencies from the internet on every run, nor does it run the tests in parallel → lesson 5 (cache and speed).
  • It does not require a minimum of coverage; a change that arrives without tests passes just the same → lesson 6 (the coverage gate).
  • It has no policy for a test that fails intermittently → lesson 7 (flaky).

Notice what this list does: it is not a confession that the floor is "incomplete," but the construction map. Each point is a layer that will rest on these four steps. The floor is the minimum that works, and it is fine for it to be so for now, because each improvement has its lesson and its justification. A pipeline that presents itself as "complete" while being the basic one deceives; one that says "I do this, I left that for later, for this reason" is trustworthy and makes the path clear.

Common mistakes

Copying a "complete" workflow and skipping the floor. What happens: someone, anxious to have "real CI," pastes a tests.yml with a matrix, cache, and coverage gate from a tutorial, without ever having understood the four base steps. The day the pipeline fails for an infrastructure reason —an absent checkout, a misspelled version— they do not even know where to start, because they never saw the skeleton alone. Why it happens: starting with the elaborate feels more productive than starting with the humble. How to detect it: if you cannot explain what each of the four base steps does and what would happen without it, you built on foundations you do not understand. How to fix it: write and run the floor first, check its local parity, and only then stack. This module does it in that order on purpose.

Forgetting the checkout (the invisible step). What happens: the workflow has setup-python, install, and pytest, but is missing actions/checkout. The code never arrives at the runner, so pip install -r requirements.txt does not find the file and pytest does not find tests (exit code 5). Why it happens: locally the code "is already there," so it is easy to forget that on the runner it has to be brought explicitly. How to detect it: if the CI log says it does not find files that on your machine do exist, suspect the checkout. How to fix it: the checkout goes always first, before any step that touches the code. It is the step that turns an empty runner into your project.

Not checking that the floor bites. What happens: someone sets up the base workflow, sees it green once, and trusts it. They never check what happens when a test fails, so they do not know whether the pipeline would really go red —maybe a configuration error makes pytest not find the tests and return green by emptiness—. Why it happens: seeing green once gives a false sense of security. How to detect it: if you never provoked a red on purpose, you do not know whether your pipeline distinguishes "all good" from "I tested nothing." How to fix it: break a Reservo rule, confirm the exit code 1 and the red job, and restore. The green→red→green cycle is the proof that the floor reacts to what it should.

Exercises

Exercise 1 — Order the steps and justify. A colleague wrote the four steps of the base workflow but out of order: (A) pytest, (B) setup-python, (C) checkout, (D) pip install. Put them in the correct order and explain in one sentence why each one goes where it goes.

See solution

The correct order is C → B → D → A: checkout, setup-python, pip install, pytest.

  • (C) checkout first — nothing can be done without the code on the runner; the three steps that follow need it present.
  • (B) setup-python second — you have to have the correct Python installed before using pip or pytest, which run on that Python.
  • (D) pip install third — the dependencies (pytest, and in lesson 3 the CI tools) have to be installed before running the suite.
  • (A) pytest last — the heart: it only makes sense when the code is present, Python installed, and the dependencies in place.

The mechanical rule: each step prepares the ground for the ones that follow. A wrong order breaks the chain —pytest before pip install would not find pytest; anything before checkout would not find the code—.

Exercise 2 — The version without quotes. A colleague wrote python-version: 3.14 without quotes and their workflow "installs a weird version of Python." Explain what happens and why the quotes fix it.

See solution

Without quotes, YAML reads 3.14 as a floating-point number, and 3.14 as a float is exactly 3.14. So far it seems harmless, but the classic problem is with versions like 3.10: YAML reads them as the float 3.1 (the trailing zero of a number means nothing), so setup-python receives "3.1" and looks for Python 3.1 —a version from over a decade ago that does not exist on the runner— and fails. With 3.14 the risk is smaller, but the rule is the same: a version is a text string, not a number, and must be written in quotes: python-version: "3.14".

The quotes tell YAML "this is literal text, do not interpret it as a number," so "3.10" stays as "3.10" and "3.14" as "3.14", and setup-python receives exactly what you wrote. The practical lesson: always in quotes the versions in the YAML, even though in your particular case it seems to work without them —the day you use a version ending in zero, the bug appears—.

Exercise 3 — Write the scope note of the floor. You have just set up Reservo's base workflow. Write a scope note of three or four lines: what your pipeline does today and what remains, on purpose, for the following lessons. (This is the habit that lesson 1 asked for in the second deliverable, applied to the base layer.)

See solution

A reasonable note:

Scope of tests.yml (base layer). Runs Reservo's full suite (14 tests: pricing, refunds, availability, and the version feature) on every push and pull request, on Python 3.14 on Ubuntu, installing from requirements.txt. The job goes red if any test fails (exit code ≠ 0); a skipped test does not break the build. Remaining for the following lessons: fix all versions for deterministic installations (lesson 3); run on 3.11/3.12/3.13 with a matrix (lesson 4); cache dependencies and parallelize with -n auto (lesson 5); require a coverage threshold that breaks the build (lesson 6); and define the flaky policy (lesson 7). This pipeline is the floor: the minimum that works, ready to grow with intention.

The important thing about the note: it honestly acknowledges that the floor is the simplest that works, says what it already protects (the 14 tests on every change), and enumerates what is missing with the lesson number that adds it. It is not an apology for being incomplete; it is the map of the five layers that are missing. A good scope note evolves with the pipeline: in each lesson you will cross out a line from "remaining for later" and move it to "does."

Summary and next step

In this lesson you placed the first layer of the pipeline: the base workflow that runs the suite, the slab on which the other five stages stack. You wrote the tests.yml in its minimal form that works —on: [push, pull_request], runs-on: ubuntu-latest, the four steps checkout, setup-python, install, and pytest—, and justified each decision by reading it as the base of a whole that is going to grow: each step has a place where a future stage will hook in.

You checked the local parity by executing the same steps in your terminal: Python 3.14.0 confirmed, dependencies installed, and the suite green —13 passed, 1 skipped, exit code 0—, with the only expected difference in the platform line. You saw how the exit code paints the job (0 green, 1 red) and checked that the floor bites by provoking a real red and restoring it. And you wrote the scope note: what the floor does and what it leaves, on purpose, for the following layers.

Before moving on you should be able to: write the four steps of the base workflow from memory in the correct order and justify each one; run the local parity of your suite and read its output; explain how pytest's exit code translates into the color of the job; and draft an honest scope note of your base layer.

What follows, in lesson 3, is the first layer we stack on top: pinned dependencies and reproducibility. So far your requirements.txt fixes pytest, but the rest of what is installed —the transitive dependencies, and in lesson 5 the CI tools— can vary between your machine and the runner, and that variation is the classic cause of "it works on my machine" with the CI in red. You are going to learn to fix everything the pipeline installs, with an exact snapshot of versions, so that the runner installs identically to you and the floor you just set up is, besides automatic, reproducible.

Resources

  • Building and testing Python — GitHub Actions — the official guide with the example Python workflow, identical in structure to the floor you set up. The first place to consult when you assemble the base CI of a real project.
  • actions/checkout — the action that brings your code to the runner, the first step and the easiest to forget. Its README explains why it goes first and what happens without it.
  • actions/setup-python — the action that installs the Python version; its documentation details the accepted version formats and why they go in quotes. In lesson 5 we will return to it for its cache option.
  • How to invoke pytest — pytest documentation — the ways of running the suite and, above all, the exit codes section that connects the result of pytest with the color of the job. The reference that explains the 0 and the 1 you saw in the local parity.