Module 8: Project A Ci Pipeline For Reservo
3. Pinned dependencies and reproducibility
Description
The floor you set up in the previous lesson runs the suite on every push. But it has a silent crack: it installs pytest and that's it, letting the runner resolve the other versions on its own —pytest's transitive dependencies, and the CI tools the coming lessons will bring—. As long as those versions match yours, all goes well. The day they do not match —the runner installs a newer version of a library than the one you have—, the ghost that gives its name to half the guide appears: the CI in red and your local in green, without you having touched the code. This lesson closes that crack with the reproducibility layer: fixing the exact versions of everything the pipeline installs, so that the runner installs identically to you.
It is the layer of module 3, seen now as part of the whole. The idea is simple to state and easy to underestimate: a pipeline is only reliable if it is deterministic, and an installation is deterministic only if every version is pinned. You are going to distinguish the production requirements.txt from the requirements-dev.txt that brings the CI tools, understand why the exact pin (==) beats the ranges (>=), and use pip freeze to take the snapshot of versions that turns "install whatever is there" into "install exactly this." The pip freeze output you will see is real, taken from the environment where the guide runs.
By the end you will know how to make your pipeline install the same thing on the runner as in your terminal, and understand why that equality —not the luck of the versions matching— is what makes the local parity a promise and not a hope.
Connection with the module: this is the second layer we stack on the floor of lesson 2. The base workflow already installs from a file; here that file becomes fixing everything with exact versions, and splits into two —production and development/CI—. The reproducibility you gain here is what makes every run of the following lessons legitimate: when in lesson 6 we measure 88% coverage, that number is only reproducible if the versions are pinned; when in lesson 4 the matrix runs on three versions, each cell installs a deterministic snapshot. Without this layer, the pipeline's numbers would float. With it, they are a contract.
The recipe that said "a pinch of salt"
Think of a recipe that passes from one kitchen to another. The first version says "a pinch of salt, a splash of oil, bake until it looks ready." In the kitchen of whoever invented it, it comes out perfect —because "a pinch" is their pinch, "until it looks ready" is their eye—. But when the recipe travels to another kitchen, with another hand and another oven, "a pinch" becomes double, "a splash" half, and "until it looks ready" ten minutes too many. The dish comes out different, and nobody understands why, if "it's the same recipe."
The problem is not the recipe; it is that it is not reproducible. The vague quantities let each kitchen interpret them, so the same text produces different dishes. The solution of a chef who wants their recipe to come out the same anywhere is to be precise: "5 grams of salt, 15 milliliters of oil, 18 minutes at 180°C." Now the recipe no longer depends on the hand or the eye of whoever executes it; it produces the same dish in any kitchen, because it leaves nothing to interpretation.
Your dependencies are the quantities of the recipe. pytest on its own is "a pinch of pytest": the runner installs some version, you have another, and the same requirements.txt produces different environments. pytest==9.1.1 is "5 grams of pytest": it pins the exact version, so the runner and your machine install identically, and the pipeline becomes reproducible. Pinning the dependencies is writing the recipe in grams instead of pinches —so that "it works in my kitchen" stops being a difference between kitchens—.
A reproducible pipeline installs exact versions, not ranges.
pytest==9.1.1produces the same environment on the runner and on your machine;pyteston its own lets each one install whatever is there, and there the "it works on my machine" is born.
Two files: production and development/CI
Reservo does not need any external library to run —it is pure Python logic—. What it needs are tools to test itself in CI: pytest, and the ones the coming lessons activate (coverage, parallelism, retries). That distinction is reflected in two files, and separating them is a good practice worth understanding.
requirements.txt — what the project needs to run in production. For Reservo, which is pure stdlib, it is almost empty: only pytest, because even in production you would want to be able to run the tests. In a project with real dependencies (a web app, say), here would go the framework, the database client, etc.
# requirements.txt
pytest==9.1.1
requirements-dev.txt — what you need to develop and test in CI, which is a superset: it includes the production stuff plus the pipeline tools. It is the file the CI installs, because the CI needs the whole testing toolkit:
# requirements-dev.txt
pytest==9.1.1
pytest-cov==7.1.0
pytest-xdist==3.8.0
pytest-rerunfailures==16.4
Each line, with its ==, is a future stage of the pipeline waiting: pytest-cov is the coverage gate (lesson 6), pytest-xdist is the parallelism (lesson 5), pytest-rerunfailures is the flaky policy (lesson 7). By pinning them all now, you guarantee that the runner and your machine bring exactly the same tools —and therefore behave the same—. The workflow, from this lesson on, installs from requirements-dev.txt:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
pip freeze: the exact snapshot of versions
Pinning the four tools above fixes what you asked for, but each one drags transitive dependencies —pytest needs pluggy, iniconfig, packaging; xdist needs execnet— and those can also vary between environments. The tool that captures the whole tree, yours and the transitive one, is pip freeze: it lists every installed package with its exact version, in the format that a requirements understands. It is the complete snapshot of your environment.
pip freeze
What to expect (real output of the environment where the guide runs, filtered to the relevant packages):
coverage==7.15.2
execnet==2.1.2
iniconfig==2.3.0
packaging==26.2
pluggy==1.6.0
Pygments==2.20.0
pytest==9.1.1
pytest-cov==7.1.0
pytest-rerunfailures==16.4
pytest-xdist==3.8.0
Look at everything that appears that you did not write by hand. You asked for four packages (pytest, pytest-cov, pytest-xdist, pytest-rerunfailures), but the environment has ten, because each one brought its entourage: coverage (the engine behind pytest-cov), execnet (the one pytest-xdist uses to talk between processes), pluggy, iniconfig, packaging, Pygments. Those transitive dependencies are the ones that most silently get out of sync: you never named them, so you do not even notice when the runner installs a different version —until a change of behavior between versions gives you an inexplicable red—.
Here is the fine point of reproducibility. A requirements-dev.txt with only the four high-level lines is fairly deterministic, but not entirely: the transitive versions are left at the mercy of what pip resolves on the day of the installation. For a truly pinned pipeline, the strictest practice is to freeze the whole snapshot —save the output of pip freeze as the file the CI installs—, so that even execnet and pluggy have their fixed version. For Reservo, with such stable dependencies, the four high-level lines suffice; in a big project, with dozens of dependencies that move fast, freezing the complete snapshot (or using a lock file like the ones pip-tools, Poetry, or uv generate) is what makes "the CI installed the same thing as me" literally true, not approximately.
Worked example: reproduce locally what the CI installed
The local parity is not just running the same suite; it is running it on the same versions. With the pip freeze snapshot, you can recreate in your terminal the exact environment of the runner. The pattern, which is the heart of module 3:
# 1. clean virtual environment, so as not to drag in old versions
python3.14 -m venv .venv
source .venv/bin/activate
# 2. install exactly what the CI installs, from the pinned list
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
# 3. confirm that the versions match the runner's snapshot
pip freeze
If your pip freeze produces the same list that the runner would report, you have environment parity: not only do you run the same suite, you run it on the same tooling, version by version. And then —only then— does it make sense to compare results: if your suite passes here, it will pass there, because here and there are the same environment.
The clean virtual environment (python -m venv .venv) is the non-negotiable half of the trick. Without it, you would install on top of whatever you already have —old versions from previous projects, global packages—, and your "reproduction" would drag in contamination that the runner (which starts from scratch each time) does not have. A new venv is the only way to match the runner's starting point: a clean machine where only what requirements-dev.txt installs exists.
Why this layer makes the pipeline's numbers legitimate
It is worth pausing on something that becomes critical in the coming lessons. Every number the pipeline reports —how many tests pass, how much coverage there is, how long it takes— is a number about an environment. If the environment changes between runs, the number changes without the code changing, and it stops meaning anything.
Think of it with the coverage, which is lesson 6. We are going to measure that Reservo has 88% coverage and put up a gate that breaks the build if it drops. But "88%" is only a reproducible contract if the next run measures on the same coverage, the same pytest, the same suite. If coverage jumped from version 7.15 to an 8.0 that counts the branches differently, the 88% could become 86% without anyone touching the code, and the gate would break the build over a ghost. Pinning coverage==7.15.2 (via pytest-cov==7.1.0, which drags it in) is what makes today's 88% comparable with tomorrow's 88%.
The same with the matrix (lesson 4): each cell installs its own snapshot, and only if that snapshot is deterministic can you read "the 3.11 cell failed" as "the bug is from 3.11" and not as "maybe the 3.11 cell installed a weird version of a dependency." Reproducibility is not an isolated stage; it is the firm ground on which the other stages give measurements you can trust. A pipeline without pinned dependencies measures on sand.
Common mistakes
Pinning only the high-level stuff and believing it is already deterministic. What happens: someone puts pytest==9.1.1 in their requirements, feels reproducible, and one day the CI goes red because pluggy —which they never named— jumped to a version with a change of behavior. Why it happens: it is easy to forget that each dependency drags in a tree of transitive dependencies that also have versions. How to detect it: run pip freeze and count; if you installed four packages and freeze lists ten, there are six versions you are not controlling. How to fix it: for strict pipelines, freeze the complete pip freeze snapshot (or use a lock file from pip-tools/Poetry/uv); for projects with stable dependencies like Reservo, pinning the high-level stuff suffices, but knowing that the transitive ones are left at the mercy of pip.
Reproducing without a clean virtual environment. What happens: someone tries to reproduce the CI environment by installing requirements-dev.txt on top of their global environment, which already has old versions of half the world. The installation "works," but their environment is not the runner's —it has contamination the runner does not—, so the "reproduction" lies. Why it happens: creating a venv feels like a dispensable extra step. How to detect it: if your pip freeze lists packages that requirements-dev.txt does not mention or drag in, you are in a dirty environment. How to fix it: always a new venv (python -m venv .venv) before reproducing; it is the only way to match the runner's clean starting point.
Confusing "it passes on my machine" with "it is reproducible." What happens: someone insists that their suite "works" because it passes in their terminal, without realizing it passes thanks to versions only they have. The CI, with other versions, goes red, and the debate becomes "but it works on my machine" against "well, here it does not." Why it happens: one's own machine is an environment accumulated over months, full of specific versions one does not remember choosing. How to detect it: if you cannot recreate your environment from scratch in a clean venv with a requirements and get the same result, your "it works" depends on something not captured. How to fix it: the discipline of this lesson —fixing versions, reproducing in a clean venv—, which turns "it works on my particular machine" into "it works on any machine that installs this snapshot," which is the only thing the CI can promise.
Exercises
Exercise 1 — >= vs ==. A colleague defends using pytest>=9.0 instead of pytest==9.1.1 "to always have the latest." Explain what they gain and what they lose with >=, and why in a CI pipeline the == usually wins the debate.
See solution
With pytest>=9.0 you gain automatic freshness: each installation brings the newest version available within the range, so the improvements and patches arrive without editing the file. What you lose is exactly what a pipeline needs: determinism. With a range, two installations on different days can bring different versions —today 9.1.1, tomorrow 9.2.0 if it comes out—, so the same requirements produces different environments, and a new red could be due to a version change and not a code change. Worse: your machine (which installed yesterday) and the runner (which installs today) could have different versions of the same range, reopening the "it works on my machine" gap.
In CI the == wins because the central value of a pipeline is that it is reproducible: the same input (code + requirements) must give the same output (green/red, coverage, times) no matter when or where it runs. The == pins the version, so a red is always because of the code, not because of a dependency that moved on its own. Freshness is managed separately, with intention: you update the pinned version when you decide to do so (you read the changelog, you ran the suite against the new one), not as a side effect of today happening to reinstall. Tools like Dependabot automate proposing those bumps as pull requests that the CI itself validates —controlled freshness, not random freshness—.
Exercise 2 — The transitive red. Reservo has been green for months. Nobody touched the code or the requirements-dev.txt, but today the CI went red with an internal error of pytest-xdist. pip freeze on the runner shows execnet==2.2.0, while your requirements-dev.txt only pins pytest-xdist==3.8.0. Explain what happened and how you prevent it.
See solution
What happened: execnet is a transitive dependency of pytest-xdist —xdist uses it to communicate between the worker processes—, and you never pinned it. Your requirements-dev.txt fixes pytest-xdist==3.8.0, but leaves execnet free, so the runner installed the newest version available today (2.2.0), which happened to bring a change of behavior incompatible with how xdist used it. The code did not change, the high-level requirements did not change, but the environment changed underneath, in a dependency you were not controlling. That is the transitive red: it is born in a version you never named.
How it is prevented: by freezing the complete snapshot. If your installation file were the output of pip freeze —with execnet==2.1.2 explicit, along with pluggy, iniconfig, and the other transitive ones—, the runner would install execnet==2.1.2 like you, and the jump to 2.2.0 would not have happened until you, deliberately, updated the snapshot and ran the suite to validate. The discipline: for a pipeline that does not want surprises, pin the whole tree, not just the high-level leaves. A lock file (pip-tools, Poetry, uv) automates generating and maintaining that complete snapshot, resolving the tree once and pinning it whole.
Exercise 3 — One file or two? A colleague proposes having a single requirements.txt with everything —pytest, pytest-cov, pytest-xdist, pytest-rerunfailures— and deleting requirements-dev.txt, "to simplify." For Reservo, is it defensible? And for a web app with real production dependencies? Justify.
See solution
For Reservo, it is defensible but of little importance. Reservo has no production dependencies (it is pure stdlib), so its "production" requirements.txt would have only pytest, and the difference between one and two files is almost cosmetic: there is no "production environment" of Reservo you want to keep lightweight. A single file with the four tools would work with no real harm.
For a web app with real dependencies, separating into two files does matter, and deleting requirements-dev.txt would be a mistake. The reason is the production environment: when you deploy the app, you want to install only what it needs to run —the web framework, the database client—, not the testing toolkit —pytest, coverage, xdist—. Putting the test tools in the production requirements.txt inflates the deployment image with packages the server never uses, increases the security surface (more installed code = more things that can have vulnerabilities), and confuses the contract of "what this needs to work." The standard separation is: requirements.txt = the minimum to run in production; requirements-dev.txt = that plus the development and CI tools (it usually starts with -r requirements.txt to include it and then adds the test ones). The CI installs the dev one (it needs to test); the deployment installs the production one (it only needs to run). The lesson: separating is not bureaucracy, it is keeping each environment's contract honest.
Summary and next step
In this lesson you stacked the second layer of the pipeline: reproducibility. You closed the crack of the floor —which installed without fixing everything— understanding that a pipeline is only reliable if it is deterministic, and an installation is deterministic only if every version is pinned. You separated the production requirements.txt from the requirements-dev.txt that brings the CI tools (each one a future stage: cov, xdist, rerunfailures), and saw why the exact pin == beats the range >= in a pipeline: the same input must give the same output, without a dependency moving on its own.
You used pip freeze to see the complete snapshot of the environment —ten packages where you asked for four— and understood that the transitive dependencies (execnet, pluggy) are the ones that most silently get out of sync. You learned the reproduction pattern —clean venv, install from the pinned list, confirm with pip freeze— and why this layer makes the numbers of the following lessons legitimate: the 88% coverage, the xdist times, the matrix verdicts only mean something if they are measured on a pinned environment.
Before moving on you should be able to: explain why == beats >= in CI; distinguish requirements.txt from requirements-dev.txt and say what goes in each; use pip freeze to see the complete tree and recognize the transitive ones; and reproduce the CI environment in a clean venv.
What follows, in lesson 4, is the third layer: the version matrix. So far your pipeline runs on a single Python (3.14) on a reproducible environment —good—, but a single green environment asserts a single environment, and Reservo, as a library, promises several versions to its users. You are going to wrap the base workflow in a strategy.matrix that runs it on 3.11, 3.12, and 3.13 at once, each cell with its own deterministic snapshot, and you will see the version-dependent feature (report_pages) behave differently according to the version that runs it. The reproducibility of this lesson is what makes each cell of that matrix a clean experiment.
Resources
- pip: Requirements files — the format of the requirements file: how to pin with
==, how ranges work, and why the order and the comments matter. The canonical reference for what you wrote in this lesson. - pip freeze — pip documentation — the command that takes the exact snapshot of the environment, including the transitive dependencies. Read it to understand why its output is directly installable as a
requirements. - Managing dependencies (Python Packaging User Guide) — the overview of lock tools (
pip-tools,Poetry,uv) that freeze the complete tree automatically, the next step when pinning by hand falls short. - Keeping your dependencies updated — Dependabot — how to automate controlled freshness: bumping pinned versions as pull requests that the CI itself validates, instead of leaving open ranges. The other half of the
==vs>=debate.