build: pin one Python version across the app, the images and the gate

0.93.2 shipped a week's worth of confusion about which Node this repo
runs on; the Python side had the identical shape and nobody had looked.
Prod (Dockerfile), the test image (Dockerfile.test) and CI (check.yml)
each spelled 3.12 as an independent literal, there was no
`.python-version`, no `requires-python`, and ruff had no target-version
so it judged our syntax against some other interpreter. The dev machine
had no 3.12 installed at all — only 3.13 and 3.14 — so every ad-hoc host
script ran two majors ahead of production.

`.python-version` is now the single source of truth, and the contract is
held by tests rather than by prose. It is split in two because no single
runner can see every link: the pytest suite runs inside the test image,
which mounts backend/ alone and cannot read the repo-root Dockerfile or
the workflow.

  test_python_engine_contract.py : running interpreter == pyrightconfig
                                   == ruff.toml
  pythonEngine.contract.test.ts  : .python-version == pyrightconfig ==
                                   ruff.toml == Dockerfile ==
                                   Dockerfile.test == check.yml

Together they close the loop, and the backend half carries the assertion
that pays — that the interpreter running the suite is the pinned one. A
green suite on another minor does not mean the shipped image is green;
it means they have drifted and nobody has noticed yet.
This commit is contained in:
2026-09-20 23:02:11 +02:00
parent c9101e3450
commit cc9fc3b544
4 changed files with 157 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
3.12
+4
View File
@@ -7,6 +7,10 @@
# the repo wrap at the same width, and it's the width `ruff format` enforces (gated via # the repo wrap at the same width, and it's the width `ruff format` enforces (gated via
# `ruff format --check` alongside `ruff check`). # `ruff format --check` alongside `ruff check`).
line-length = 100 line-length = 100
# The interpreter this code actually runs on, so ruff judges syntax and upgrade hints against the
# right one. Left unset it falls back to a default that has nothing to do with us. `/.python-version`
# is the single source of truth; tests/test_python_engine_contract.py holds this to it.
target-version = "py312"
[lint] [lint]
# Beyond the default set, the rules that hold a LOG LINE to what E22.5 makes of it: # Beyond the default set, the rules that hold a LOG LINE to what E22.5 makes of it:
@@ -0,0 +1,66 @@
"""The Python major is a contract, not a coincidence — the backend half (E27 S3).
`0.93.2` was released to fix exactly this bug on the JavaScript side: four machines built or checked
the app and none of them agreed on which Node they ran, the server gate went red for 73 of its last
90 runs, and nothing told anyone. The Python side had the same shape and nobody had looked: prod
(`Dockerfile`), the test image (`Dockerfile.test`) and CI (`check.yml`) each spelled **3.12** as an
independent literal, there was no `.python-version`, no `requires-python`, and ruff had no
`target-version` — while the dev machine had no 3.12 installed at all, only 3.13 and 3.14.
**The contract is a chain, and it is split across two test files because no single runner can see
every link.** This suite runs inside the test image, which mounts `backend/` and nothing else, so it
cannot read the repo-root Dockerfile or the workflow. It therefore holds the link it CAN see, and the
one that matters most here:
the interpreter actually running == backend/pyrightconfig.json == backend/ruff.toml
and `frontend/src/lib/pythonEngine.contract.test.ts` — which runs on the host, with the whole tree —
holds the rest of the chain:
/.python-version == pyrightconfig.json == ruff.toml == Dockerfile == Dockerfile.test == check.yml
Together they close the loop. To move the version: edit `/.python-version`, then follow the failures
in both files. Every place they name is a place that would otherwise have been forgotten.
"""
import re
import sys
from pathlib import Path
BACKEND = Path(__file__).resolve().parents[1]
def _version_in(path: Path, pattern: str) -> tuple[int, int]:
"""The (major, minor) the given file declares. A rename or a moved line lands here — re-point
the test, the contract did not go away with it."""
text = path.read_text(encoding="utf-8")
match = re.search(pattern, text, re.MULTILINE)
assert match is not None, f"{pattern!r} not found in {path.name}"
return int(match.group(1)), int(match.group(2))
# pyrightconfig.json is JSONC (it carries comments), so it is read by regex rather than json.loads.
PINNED = _version_in(BACKEND / "pyrightconfig.json", r'"pythonVersion"\s*:\s*"(\d+)\.(\d+)"')
def test_pins_a_plausible_python_version():
"""Guards against a typo agreeing with nothing at all."""
assert PINNED >= (3, 12), PINNED
def test_this_interpreter_is_the_pinned_one():
"""The assertion that pays. A green suite on another minor does not mean the shipped image is
green — it means the suite and the image have drifted and nobody noticed yet."""
running = sys.version_info[:2]
assert running == PINNED, (
f"these tests are running on Python {running[0]}.{running[1]}, but the app is pinned to "
f"{PINNED[0]}.{PINNED[1]} (backend/pyrightconfig.json, /.python-version). The suite runs in "
f"the test image, so this failing means backend/Dockerfile.test has drifted from the pin."
)
def test_ruff_judges_the_pinned_version():
"""Unset, ruff falls back to a default that has nothing to do with us, and its syntax and
upgrade advice are then aimed at some other interpreter."""
major, minor = _version_in(BACKEND / "ruff.toml", r'^target-version\s*=\s*"py(\d)(\d+)"')
assert (major, minor) == PINNED
@@ -0,0 +1,86 @@
import { readFileSync } from "node:fs";
import { expect, it } from "vitest";
// The Python major is a contract, not a coincidence — the host half. The twin of
// `nodeEngine.contract.test.ts`, and written the day after that one was: `0.93.2` shipped because
// four machines disagreed about Node, and the Python side turned out to have the identical shape
// with nobody watching. Prod (`Dockerfile`), the test image (`backend/Dockerfile.test`) and CI
// (`check.yml`) each spelled 3.12 as its own literal, there was no `.python-version`, and the dev
// machine had only 3.13 and 3.14 installed — so every ad-hoc host script ran two majors ahead of
// production.
//
// It lives in the FRONTEND suite for one boring reason: this is the only runner that can see the
// whole repository. The backend suite runs inside the test image, which mounts `backend/` alone, so
// it cannot read the repo-root Dockerfile or the workflow. It holds the other half of the chain —
// including "the interpreter running these tests IS the pinned one", which only it can assert:
//
// backend/tests/test_python_engine_contract.py : running interpreter == pyrightconfig == ruff.toml
// here : .python-version == pyrightconfig == ruff.toml
// == Dockerfile == Dockerfile.test == check.yml
//
// To move the version: edit `.python-version`, then follow the failures in both files.
const readRoot = (rel: string) => readFileSync(new URL(`../../../${rel}`, import.meta.url), "utf8");
function versionIn(source: string, pattern: RegExp, where: string): string {
const m = pattern.exec(source);
// A rename or a moved line lands here. Re-point the test — the contract did not go away with it.
expect(m, `${pattern} not found in ${where}`).not.toBeNull();
return `${m![1]}.${m![2]}`;
}
const pinned = versionIn(readRoot(".python-version"), /^(\d+)\.(\d+)\s*$/m, ".python-version");
it("pins a plausible Python version", () => {
// Guards against a typo'd `.python-version` silently agreeing with nothing.
expect(Number(pinned.split(".")[1])).toBeGreaterThanOrEqual(12);
expect(pinned.split(".")[0]).toBe("3");
});
it("runs the shipped backend on the pinned version", () => {
// The image our users' instance actually runs.
expect(versionIn(readRoot("Dockerfile"), /^FROM python:(\d+)\.(\d+)-slim/m, "Dockerfile")).toBe(
pinned,
);
});
it("tests on the pinned version", () => {
// The image the whole pytest suite runs in — including the backend half of this contract, which
// asserts its own `sys.version_info` against the pin.
expect(
versionIn(
readRoot("backend/Dockerfile.test"),
/^FROM python:(\d+)\.(\d+)-slim/m,
"Dockerfile.test",
),
).toBe(pinned);
});
it("runs the server gate on the pinned version", () => {
// The backend job's container. Its NODE is deliberately outside the Node contract (it only runs
// the checkout action), but its PYTHON runs our code and our tests, so it is inside this one.
expect(
versionIn(
readRoot(".gitea/workflows/check.yml"),
/^\s*container: nikolaik\/python-nodejs:python(\d+)\.(\d+)-nodejs\d+$/m,
"check.yml",
),
).toBe(pinned);
});
it("type-checks against the pinned version", () => {
// A type checker aimed at another version green-lights APIs the shipped interpreter does not have
// — the same defect as the image split, moved to check time.
expect(
versionIn(
readRoot("backend/pyrightconfig.json"),
/"pythonVersion"\s*:\s*"(\d+)\.(\d+)"/,
"pyrightconfig.json",
),
).toBe(pinned);
});
it("lints against the pinned version", () => {
expect(
versionIn(readRoot("backend/ruff.toml"), /^target-version = "py(\d)(\d+)"$/m, "ruff.toml"),
).toBe(pinned);
});