Module 3: Reproducing A Ci Failure Locally
8. Mini-project: reproduce and fix a failure from an unpinned dependency
Description
The time has come to execute the whole module with your own hands. In this mini-project you take a real Reservo failure —CI red, your machine green, without anyone touching the code— caused by an unpinned dependency, and you carry it from start to finish: you reproduce it on your machine with lesson 7's method, you confirm it red, you fix it (correct pin + updated expectation), and you leave the build green reproducibly. It's not a reading exercise: it's a doing one. At the end you'll have the pinned requirements.txt, the clean venv, the reproduction output, and the fix output —the complete deliverable of a well-closed CI failure—.
This is the close of the guide to reproducing environment failures, and it uses the module's central tool —a clean venv that replicates CI's— on the case that ran through it: Reservo's local-time test that passes with an old pytz and fails with a new one. Everything you saw demonstrated in the previous lessons, now you produce yourself.
Connection to the module. The previous seven lessons gave you the concepts (the gap, the layers), the tools (the pin, the clean venv, variable control) and the method (the five steps). The mini-project integrates them into a single real flow. And it marks the module's boundary with a clean case: this failure is closed here because its cause is an expectation that aged, not a code bug —if it were a bug, the reproduction would be the starting point of the sibling diagnosis guide—.
The assignment
You're the one on call for Reservo this week. This alert arrives:
The
masterbuild is red. The testtest_summer_booking_starts_at_16_localfails in CI withAssertionError: assert 15 == 16. No one has touched that file in weeks. On the machine of whoever wrote it, the suite passes. We need the build green and to understand what happened, without crudely turning off the test.
You have the project material at hand.
The little function's code (hasn't changed):
# reservo/localtime.py
import pytz
def local_start_hour(booking, tz_name):
"""Wall-clock hour a booking starts, in the member's zone.
booking.start is a UTC datetime with zone. Reservo uses the local time
to label the booking as daytime/evening in the member's city.
"""
tz = pytz.timezone(tz_name)
return booking.start.astimezone(tz).hour
The test (hasn't changed):
# test_localtime.py
from datetime import datetime
import pytz
from reservo.models import Booking
from reservo.localtime import local_start_hour
UTC = pytz.utc
SUMMER_START = UTC.localize(datetime(2023, 7, 15, 21, 0)) # 21:00 UTC, a summer day
def a_booking():
return Booking(id="bk-1", room_id="r-focus", member_id="m-1",
start=SUMMER_START, end=SUMMER_START,
status="confirmed", price_cents=6000)
def test_summer_booking_starts_at_16_local():
assert local_start_hour(a_booking(), "America/Mexico_City") == 16
The requirements.txt (here is the seed of the problem):
# requirements.txt
pytz>=2022.1
The CI log (the failure and the versions it installed):
Run actions/setup-python@v5 with python-version 3.14
$ pip install -r requirements.txt
$ pip freeze
iniconfig==2.3.0
packaging==26.2
pluggy==1.6.0
pytest==9.1.1
pytz==2026.3.post1
$ pytest -q
============================= test session starts ==============================
platform linux -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 1 item
test_localtime.py::test_summer_booking_starts_at_16_local FAILED [100%]
FAILED test_localtime.py::test_summer_booking_starts_at_16_local - assert 15 == 16
1 failed in 0.04s
Your task
In five parts, following the module's method:
- Diagnose the gap (on paper). With lesson 2's catalog and the log, say which is the suspect, and why the same
requirements.txtproduced different versions ofpytzon the dev's machine and in CI. - Reproduce the failure. Set up a clean venv with CI's Python version, pin
pytzto the exact version CI installed, run the same command, and confirm you get the same red (assert 15 == 16). - Confirm the cause. Set up a second venv with the old version of
pytz(the one the dev had) and show that there the test passes —proving the dependency version is the only difference—. - Fix it. Decide and apply the correct fix: pin
pytzso CI and local don't diverge again, and correct the test's expectation that aged. Justify why15is the correct value and not16. - Deliver. Gather the pinned
requirements.txt, the fixed test, and the outputs of the reproduction (red) and the fix (reproducible green).
Try to do it yourself before looking at the solution. Everything you need is in lessons 2, 4, 5, and 7.
Hints
- Part 1: the value is shifted exactly one hour (
15vs16), in a time-zone calculation. Think about which layer the time-zone data lives in and why a>=lets two machines install different versions depending on when they installed. - Part 2: the method's step 0 already gave you the ID card in the log: Python 3.14,
pytz==2026.3.post1, commandpytest -q. Create the venv withpython3.14, install the exact pin, verify the version before running. - Part 4: did Mexico have daylight saving time in July 2023? Look it up (it abolished it in October 2022). If in 2023 there was no daylight saving time, 21:00 UTC in Mexico City (UTC−6) is 15:00, not 16:00. The test was right when it was written (with the old data), and the new
pytzcorrects it. The fix isn't "make it give 16"; it's accepting that15is the truth and pinning so the result is stable.
Reference solution
See the complete solution (diagnosis + reproduction + confirmation + fix + delivery, with real outputs)
Part 1 — Diagnosing the gap
The suspect is a dependency with another version (lesson 2, suspect #2), which also overlaps with the time zone (#5) because the time-zone data travels inside pytz. The fingerprint confirms it: the value is shifted exactly one hour (15 vs 16) in a time-zone calculation.
Why the same requirements.txt produced different versions: the line pytz>=2022.1 is a range, not a pin. It means "2022.1 or any newer". The dev installed a while ago, when the newest was 2022.1, and that stayed sedimented on their machine. CI is ephemeral: it installs fresh on every run, and today "the newest that's ≥ 2022.1" is 2026.3.post1, so it grabs that. Same file, two versions —2022.1 locally, 2026.3.post1 in CI—, because the >= let "the moment of installing" choose. And since Mexico abolished daylight saving time in October 2022, the old pytz still believes CDMX is at UTC−5 in summer (16:00) and the new one already knows it's at UTC−6 (15:00).
Part 2 — Reproducing the failure
Log ID card (step 0): Python 3.14.0, pytz==2026.3.post1, pytest 9.1.1, command pytest -q, failure assert 15 == 16. We set up the clean venv with that Python version, pin pytz to CI's, verify, and run:
$ python3.14 -m venv repro-venv
$ repro-venv/bin/python --version
Python 3.14.0
$ repro-venv/bin/python -m pip install pytz==2026.3.post1 pytest==9.1.1
$ repro-venv/bin/python -c "import pytz; print('pytz:', pytz.__version__)"
pytz: 2026.3.post1
$ repro-venv/bin/python -m pytest test_localtime.py -q
F [100%]
=================================== FAILURES ===================================
____________________ test_summer_booking_starts_at_16_local ____________________
> assert local_start_hour(a_booking(), "America/Mexico_City") == 16
E AssertionError: assert 15 == 16
E + where 15 = local_start_hour(Booking(id='bk-1', ...), 'America/Mexico_City')
test_localtime.py:21: AssertionError
=========================== short test summary info ============================
FAILED test_localtime.py::test_summer_booking_starts_at_16_local - assert 15 == 16
1 failed in 0.03s
Reproduced. The same assert 15 == 16 from CI, now on the machine, at will.
Part 3 — Confirming the cause
We set up a second venv, identical except for the version of pytz (the old one, the dev's), and run the same:
$ python3.14 -m venv old-venv
$ old-venv/bin/python -m pip install pytz==2022.1 pytest==9.1.1
$ old-venv/bin/python -c "import pytz; print('pytz:', pytz.__version__)"
pytz: 2022.1
$ old-venv/bin/python -m pytest test_localtime.py -q
1 passed in 0.02s
Green with the old. Two clean venvs, same machine, same code, same command; the only difference is the version number of pytz, and with it the color changes. It's proven that the dependency is the cause: 2026.3.post1 → red, 2022.1 → green.
Part 4 — The fix
Did Mexico have daylight saving time in July 2023? No: it abolished it in October 2022. So in summer 2023, Mexico City was at UTC−6 all year, and 21:00 UTC is 15:00 local, not 16:00. The test expected 16 because it was written with a pytz that still carried the old daylight-saving rule. The new pytz isn't "broken": it's correcting a fact of the world. Therefore, the correct value is 15, and the fix has two parts:
- Pin
pytzso CI and local always install the same version (end of the divergence). Therequirements.txtgoes from a range to an exact pin:
# requirements.txt (fixed)
pytz==2026.3.post1
- Update the test's expectation to the correct value, and along the way rename it and comment why, so it doesn't age silently again:
# test_localtime.py (fixed)
def test_summer_booking_starts_at_15_local():
# CDMX abolished daylight saving time in Oct-2022: in summer it's UTC-6 (CST),
# so 21:00 UTC = 15:00 local. (Before 2022 it would have been 16:00 with DST.)
assert local_start_hour(a_booking(), "America/Mexico_City") == 15
We run the fixed test in the venv with CI's pinned pytz:
$ repro-venv/bin/python -m pytest test_localtime_fixed.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 1 item
test_localtime_fixed.py::test_summer_booking_starts_at_15_local PASSED [100%]
============================== 1 passed in 0.02s ===============================
Green, and reproducible. Now the test asserts the truth (15), and the pinned requirements.txt guarantees that CI and any machine install pytz==2026.3.post1, so the result is the same everywhere. The gap was closed at the root: there will no longer be "red here, green there" from the pytz version, because there's no longer any margin for it to differ.
Note on why it wasn't resolved by turning off the test: marking skip would have left the build falsely green —the local-time calculation would be left without coverage, and some user would see the wrong hour without anyone finding out—. The test was right; what was wrong was its expected number, not the test. Fixing the expectation (and pinning) keeps the protection; turning it off throws it away.
Part 5 — The delivery
The complete package of the closed failure:
requirements.txtchanged frompytz>=2022.1topytz==2026.3.post1(exact pin → end of the divergence).test_localtime.pywith the expectation corrected to15(with a comment explaining why, so it doesn't age silently again).- Reproduction output (Part 2):
assert 15 == 16in a clean venv withpytz==2026.3.post1→ CI's red, reproduced. - Confirmation output (Part 3):
1 passedwithpytz==2022.1→ the dependency was the only difference. - Fix output (Part 4):
1 passedwith the corrected test and pinnedpytz→ reproducible green.
With this, the build goes back to green understanding what happened, not covering it up, and the problem can't recur because the cause (the unpinned range) was eliminated.
How the fix looks in the pipeline
You closed the failure on your machine, but the ultimate goal was the CI build. It's worth seeing how your fix translates into the pipeline, because it closes the loop with the workflow you set up in module 2. The workflow doesn't change; what changes is that it now installs a fixed version of pytz and runs a test whose expectation is correct:
# .github/workflows/ci.yml (unchanged; the fix lives in requirements.txt and the test)
- uses: actions/setup-python@v5
with:
python-version: "3.14"
- run: pip install -r requirements.txt # now resolves pytz==2026.3.post1, not "the newest"
- run: pip freeze # good practice: leaves the versions in the log
- run: pytest -q
With the pinned requirements.txt, the pip install step no longer depends on when it runs: it always installs pytz==2026.3.post1, the same version you reproduced and fixed with. And since you added the pip freeze, the next time something breaks, the CI log will bring the exact versions ready to copy —the method's step 0 (lesson 7) becomes copy and paste—. This is how the tests step would read in green, in the CI log format:
$ pytest -q
============================= test session starts ==============================
platform linux -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 1 item
test_localtime.py::test_summer_booking_starts_at_15_local PASSED [100%]
1 passed in 0.03s
Note that here the header says platform linux —it's the runner— while your local reproduction said platform darwin —your Mac—. That platform difference is the only layer you didn't match (nor did you need to: the pytz failure didn't depend on the OS). It's an honest reminder that reproducing doesn't aim to clone the whole CI kitchen, but to match it in the layers that matter for this failure —and for a dependency-version failure, those layers were the interpreter and pytz, not the operating system—. The build goes back to green, on the real runner, for the same reason it went back to green in your venv: the pytz version was fixed and the expectation was made correct.
Common mistakes
"Fixing it" by changing 16 for whatever makes the test pass, without understanding why. What happens: you see assert 15 == 16, you change the 16 to 15 mechanically so it passes, without finding out whether 15 is correct. Why it happens: the hurry for green. How to spot it: if you can't explain why 15 is the truth (the abolished daylight saving time), you're adjusting numbers blindly. How to fix it: understand the cause before touching the number. Here it turned out 15 is correct, but in another case the failure could be a code bug and changing the expected would cover the bug. Change the expectation only when you verify the new reality is the correct one.
Fixing the expectation but forgetting to pin. What happens: you correct the 16 to 15, the build passes, and you leave the requirements.txt with pytz>=2022.1. Why it happens: the visible symptom (the test) is already green, so it seems resolved. How to spot it: if your requirements.txt still has a >=, the root cause is still alive. How to fix it: pin. If you don't, the next time pytz changes something, CI and local will diverge again and you'll have another ghost. Fixing the expectation cures this symptom; pinning cures the disease (the version divergence).
Reproducing in the global environment instead of a clean venv. What happens: you install pytz==2026.3.post1 on top of your usual Python to reproduce, and along the way you break other projects that depended on your old pytz. Why it happens: creating a venv feels like an extra step. How to spot it: if you ran pip install without an active/pointed-at venv, you touched your global environment. How to fix it: always reproduce in a clean, disposable venv (rm -rf when you're done). Isolate the experiment and leave no collateral damage on your machine.
Exercises
Exercise 1 — Another dependency, same pattern. Suppose the failure weren't about pytz but about a date-formatting library, dateformat, with dateformat>=1.0 in the requirements.txt; CI installed dateformat==3.0 and your machine has 1.0. Write the commands to reproduce the failure in a clean venv, assuming Python 3.14 and command pytest -q.
See solution
The pattern is identical to pytz's; only the dependency name changes:
# 1. Clean venv with CI's Python version
python3.14 -m venv repro-venv
# 2. Pin the dependency to the EXACT version CI installed (from the log's pip freeze)
repro-venv/bin/python -m pip install dateformat==3.0 pytest==9.1.1
# 3. Verify the version before running
repro-venv/bin/python -c "import dateformat; print(dateformat.__version__)" # -> 3.0
# 4. Run the same command, from the project root
repro-venv/bin/python -m pytest -q
If it reproduces the red, confirm the cause by setting up a second venv with dateformat==1.0 (the old one) and seeing that there it passes. The fix: pin dateformat to a decided version (==3.0 if the new behavior is the correct one) and adjust the test if its expectation aged. Same method, any dependency.
Exercise 2 — The fix that covers a bug. In a different case, you reproduce a failure assert 5000 == 6000: the 72h refund test expected 6000 and now gives 5000. Investigating, you see the new dependency version has nothing to do with it —someone changed refund_cents to return price_paid_cents * 5 // 6 in the 100% tranche—. Why should you not change the 6000 to 5000 here, and what should you do?
See solution
Here 6000 is the truth and 5000 is the error: Reservo's anchor says that a refund 72 hours ahead (≥ 48h) returns 100% of what was paid, and for a price of 6000 that's 6000, not 5000. The 5 // 6 someone put in refund_cents is a bug —it turns the 100% into ~83%—. Changing the test to assert 5000 == 5000 would cover that bug: it would leave the build green while the real refund would be wrong, and customers would receive too little.
The correct thing: don't touch the expectation (the 6000 is correct) and fix the code —revert refund_cents to return the full price_paid_cents in the 100% tranche—. This is exactly the case that forks to diagnosis: reproducing gave you the failure, but understanding that the bug is in the code (and not in an expectation that aged) is what decides that the fix goes in the code, not in the test. The rule: change the expected number only when the new reality is the correct one; if the expected was still the truth, the bug is elsewhere.
Exercise 3 — Prevention. Beyond this one-off failure, propose two changes in the Reservo project that reduce the probability of again having a "CI red, local green" from dependency versions. Explain what each one prevents.
See solution
Two preventive changes:
-
Pin everything with a lockfile (
pip freeze), not justpytz. Replace the rangerequirements.txtwith thepip freezeoutput from the green environment, with==on each line, including the indirect dependencies. It prevents the divergence at the root: CI and local install exactly the same version tree, so the "when you installed" can no longer move anything. Updates become deliberate (you change the pin, run the suite, push if it's still green). -
Have the CI workflow print
pip freeze(a one-line- run: pip freeze). It doesn't prevent the failure, but it makes the reproduction trivial next time: the log will have the exact versions ready to copy, without you having to guess what a range resolved to. It turns the method's step 0 (extracting the facts) into copy and paste.
Complement (preview of module 4): run the suite in a matrix of Python and dependency versions on purpose, to find out about an incompatibility before a user or CI discovers it by surprise. Instead of only preventing the versions from changing, you test against several deliberately and know which ones you work with.
Summary and next step
In this mini-project you executed the whole module with your hands: you took a real Reservo failure —CI red, local green, from an unpinned pytz>=2022.1— and closed it well. You diagnosed the gap (a dependency with another version, with the tz data inside), reproduced it in a clean venv with CI's exact version (assert 15 == 16), confirmed the cause by showing that with the old pytz the test passes, and fixed it at the root: pinning pytz==2026.3.post1 to end the divergence and correcting the test's expectation to 15 —the truth, because Mexico City no longer has daylight saving time—, with real green and reproducible output. And you saw why turning off the test with skip would have been the worst way out: the test was right; what was old was its expected number.
With this you close the guide to reproducing a CI failure on your machine. You now know how to turn a pipeline's most expensive ghost —"it works on my machine"— into a failure that happens at will, closing the environment gap layer by layer: the interpreter, the pinned dependencies, the clean venv, the variables and the time zone, and the five-step method that pulls it all together. And you know where your work ends (with the failure reproduced) and where the sibling diagnosis guide's begins (understanding why a real bug fails).
What's next is flipping the coin. Instead of reproducing a version that broke your suite after it passed, module 4 teaches you to get ahead of it: the version matrix, running your suite on purpose against several Python versions (3.12, 3.13, 3.14) and operating systems on every push, to find out about an incompatibility before it surprises you in red. You went from putting out fires to installing smoke detectors.
Resources
pip freeze— pip documentation — the command that generates the lockfile with which to pin the whole dependency tree, the underlying prevention against version divergence.- Requirements file format — pip documentation — the
requirements.txtreference for writing the exact pin (==) that replaces the project's range (>=). - Virtual environments (
venv) — Python documentation — the tool with which you set up the clean, disposable venvs where you reproduced and fixed the failure without touching your global environment. - How to run pytest — pytest documentation — to run CI's exact command in your reproduction venv and compare the result character by character.