CI tests three Pythons; production runs a fourth
Take a census of the Pythons in one small web project, the day this got fixed:
| interpreter | where it lives | ran the tests? | runs the site? |
|---|---|---|---|
| 3.11 | CI matrix | yes | no |
| 3.12 | CI matrix | yes | no |
| 3.13 | CI matrix | yes | no |
| 3.12 | the deploy box's venv | no | yes |
| 3.12.4 | this dev box's venv | no | no |
Every push ran the test suite on three interpreters. The one carrying production traffic was tested by none of them -- and neither was the one the developer actually edits in. Five Pythons, three green checkmarks, and the only row that matters got there by coincidence.
The coincidence is the problem
To be precise about the failure: it is not that CI tested the wrong
version. Production's 3.12 sat inside the matrix, so the right version was
covered. The problem is that nothing connected those two facts. The
deploy box runs whatever python3 the distro ships -- chosen by apt on
the box, never asked of the repo. The matrix is a list of guesses, one of
which happens to be true. The day the box takes an LTS upgrade, the guesses
stay exactly as green as they were and the truth moves underneath them,
because CI cannot see the box at all. There is no link to break; that is
why it breaks silently.
The matrix is the honest default -- for libraries
The matrix did not get into the workflow by carelessness. It is the community default, copied from project to project, and for the projects it was designed for it is correct. A library cannot know where its consumers run: the matrix is its deployment model, and testing 3.11 through 3.13 is how it keeps a promise it made to strangers.
An app is the opposite case and gets the same default anyway. An app knows exactly where it runs: one box, one venv, one interpreter. Its CI exists to predict that machine. Testing two machines that will never exist is coverage theater -- and it is not even free theater. It triples the CI minutes, it trains you to chase deprecation warnings for interpreters you will never deploy, and worst of all it feels like thoroughness while leaving the actual deployment untested. The checkmark distribution in the table above is what that feels like in practice.
The drift mode nobody can see
The deployment story makes the blind spot worse. install.sh builds the
venv from the distro python3 exactly once. Every later deploy reuses
that venv -- deploy.sh only runs pip when requirements.txt changed,
because a redundant pip run costs seconds and a skipped one is worse.
Now let the distro upgrade its python3. Nothing rebuilds: same venv, same
service, old interpreter, and no error anywhere to say so. CI's matrix
does not notice -- it was never watching the box. The deploys do not
notice -- they only diff file lists. The site keeps serving from an
interpreter that is quietly no longer the one anyone tested, or meant to
test. Every layer of this system is behaving exactly as written, and the
answer to "which Python is production?" drifts away from every answer the
repo gives.
One file, three consumers
The fix is to stop asking anyone to remember the version and write it down once, at the repo root:
.python-version -> "3.12"
Three consumers read it:
- CI installs it:
setup-pythonwithpython-version-file: .python-version. The matrix is gone; the workflow has no version of its own to drift from. - deploy.sh enforces it: after pulling, before restarting, it compares the venv interpreter against the file. A mismatch fails the deploy -- loudly, with the rebuild commands printed -- and leaves the current service running. That is the safe failure: the site keeps serving on the old interpreter while a human decides, instead of restarting into a half-migrated venv.
- DEPLOYMENT.md documents it, along with the drift mode above, so the next person who upgrades the box meets the contract before it bites.
Change the version deliberately and it is one commit: the file, plus whatever needs it beside it. CI follows, deploy follows, and the table at the top of this note collapses to two rows that agree.
What a pin cannot do
Honesty requires the last paragraph. A file in the repo is a claim, not a
guarantee: the box still picks its own python3, and the deploy check
fires after the drift exists rather than preventing it. The strong
version of this fix -- provisioning the interpreter from the repo, the way
some stacks vendor their runtimes -- would make drift impossible instead
of visible, and is more machinery than one box on one distro has earned.
The failure that used to be invisible is now loud, cheap, and carries its
own repair instructions. For a web app, that is the whole difference
between a version matrix and a version contract.
Keep the matrix for libraries. For an app: test the Python you ship, and make the repo -- not apt -- the place the version is decided.