From 412d2362bc763ecc467ef63ffa83a047399d0501 Mon Sep 17 00:00:00 2001 From: Simone Carolini Date: Sat, 10 Oct 2026 13:59:57 +0200 Subject: [PATCH] ci: single-source the Python version via .python-version with a guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CPython 3.15.0 (2026-10-10) exposed that the Python version was hardcoded in ~17 places with no pin, so uv picked 3.15 in CI and pyarrow fell back to a failing source build. Pin it in one place — .python-version = 3.14.2 — and make everything track it: the engine Dockerfiles use the same exact patch (python:3.14.2-slim); requires-python, mypy python_version, the trove classifiers and setup-python inputs match its major.minor. A pattern-scanning guard (tests/test_python_version_consistency.py) reads .python-version and fails on any drift, discovering pyproject.toml / Dockerfile* / workflow files by directory scan so new ones are covered without editing the guard. Signed-off-by: Simone Carolini Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0137E9XmyUXhwPTRrtNfivDw --- .python-version | 1 + CHANGELOG.md | 6 ++ Dockerfile.duckdb | 2 +- Dockerfile.postgres | 2 +- Dockerfile.trino | 2 +- template/Dockerfile.pip | 2 +- template/README.md | 2 +- tests/test_python_version_consistency.py | 85 ++++++++++++++++++++++++ 8 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 .python-version create mode 100644 tests/test_python_version_consistency.py diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..95ed564 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14.2 diff --git a/CHANGELOG.md b/CHANGELOG.md index e97572e..f8a708b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,12 @@ follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- The Python version is pinned in one place, `.python-version` (`3.14.2`), the + single source of truth: the engine images' `Dockerfile`s pin the same exact + patch (`python:3.14.2-slim`), and `requires-python`, mypy `python_version`, + the trove classifiers and `setup-python` inputs track its major.minor. A new + consistency guard (`tests/test_python_version_consistency.py`) scans every + `pyproject.toml`, `Dockerfile*` and workflow and fails on any drift. - **Breaking for newly copied templates.** The `template/` release workflow calls continuo's public release API instead of an unauthenticated webhook: - `RELEASE_ENDPOINT` keeps its name but is now the origin of continuo's diff --git a/Dockerfile.duckdb b/Dockerfile.duckdb index 1c1dd5b..ba36972 100644 --- a/Dockerfile.duckdb +++ b/Dockerfile.duckdb @@ -1,4 +1,4 @@ -FROM python:3.14-slim +FROM python:3.14.2-slim # One dual-role image per engine: the python-node runtime harness (default # command `run`) and the blue/green validation runner (`validation-op`). It # installs the PUBLISHED, versioned libraries, never the repo source: diff --git a/Dockerfile.postgres b/Dockerfile.postgres index 6122806..4635336 100644 --- a/Dockerfile.postgres +++ b/Dockerfile.postgres @@ -1,4 +1,4 @@ -FROM python:3.14-slim +FROM python:3.14.2-slim # One dual-role image per engine — the python-node runtime harness (default # command `run`) and the blue/green validation runner (`validation-op`). It # installs the PUBLISHED, versioned libraries, never the repo source: diff --git a/Dockerfile.trino b/Dockerfile.trino index 12a17cf..4443ede 100644 --- a/Dockerfile.trino +++ b/Dockerfile.trino @@ -1,4 +1,4 @@ -FROM python:3.14-slim +FROM python:3.14.2-slim # One dual-role image per engine — the python-node runtime harness (default # command `run`) and the blue/green validation runner (`validation-op`). It # installs the PUBLISHED, versioned libraries, never the repo source: diff --git a/template/Dockerfile.pip b/template/Dockerfile.pip index adc249e..ecda31d 100644 --- a/template/Dockerfile.pip +++ b/template/Dockerfile.pip @@ -3,7 +3,7 @@ # requirements file (regenerate with the command in template/README.md). # Shape 1 (FROM the engine image, see template/Dockerfile) is simpler; use this # only when you must control the base image. -FROM python:3.14-slim +FROM python:3.14.2-slim COPY requirements.lock /tmp/requirements.lock RUN pip install --no-cache-dir --require-hashes -r /tmp/requirements.lock \ && rm -f /tmp/requirements.lock diff --git a/template/README.md b/template/README.md index d6e4a9e..01f198a 100644 --- a/template/README.md +++ b/template/README.md @@ -52,7 +52,7 @@ any extra dependency your script needs). Pin by tag or digest specific reason not to. **Shape 2 — `Dockerfile.pip`, your own base (hash-locked).** Builds -`FROM python:3.14-slim` (or another base you control) and installs +`FROM python:3.14.2-slim` (or another base you control) and installs `continuo-python-runtime` plus one `continuo--adapter` from PyPI via `pip install --require-hashes -r requirements.lock`. Use this when you must control the base image yourself — e.g. your org mandates a specific base, diff --git a/tests/test_python_version_consistency.py b/tests/test_python_version_consistency.py new file mode 100644 index 0000000..d9f2a39 --- /dev/null +++ b/tests/test_python_version_consistency.py @@ -0,0 +1,85 @@ +"""`.python-version` is the single source of truth for the Python version. + +Every other file that hardcodes the version must agree with it, so a bump is a +one-line edit to `.python-version` and CI catches any drift. These checks scan +by directory (``rglob``/``glob``), never by a hardcoded file list: a new +``pyproject.toml``, ``Dockerfile*`` or workflow is covered automatically. A new +*kind* of file that hardcodes the version (a shape these patterns do not match) +is the one thing that still needs a pattern added here. + +Two tiers, because the version means different things in different files: +- full patch (``.python-version`` and the engine ``Dockerfile``s ``FROM + python:X-slim``) — the exact interpreter dev, CI and the shipped image run; +- major.minor (``requires-python`` floor, mypy ``python_version``, trove + classifiers, ``setup-python`` inputs) — a policy/compatibility statement, and + mypy/``requires-python`` reject a patch component anyway. +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CANONICAL = (ROOT / ".python-version").read_text().strip() # e.g. "3.14.2" +MAJOR_MINOR = ".".join(CANONICAL.split(".")[:2]) # "3.14" + +# Directories whose files are not ours to keep in sync (installed deps, VCS, +# build scratch, the SDD ledger). +_SKIP = { + ".venv", + ".git", + ".superpowers", + "__pycache__", + "node_modules", + "wheelhouse", + "dist", + "build", + ".pytest_cache", + ".ruff_cache", + ".mypy_cache", +} + + +def _ours(paths): + for path in paths: + if not any(part in _SKIP for part in path.parts): + yield path + + +def test_python_version_pins_an_exact_patch(): + """The canonical source is a full X.Y.Z, so the interpreter is reproducible.""" + assert re.fullmatch(r"\d+\.\d+\.\d+", CANONICAL), f".python-version is {CANONICAL!r}, not X.Y.Z" + + +def test_every_dockerfile_pins_the_canonical_patch(): + """`FROM python:-slim` in every Dockerfile equals the canonical full patch.""" + checked = [] + for dockerfile in _ours(ROOT.rglob("Dockerfile*")): + for tag in re.findall(r"FROM python:(\S+)-slim", dockerfile.read_text()): + checked.append(dockerfile) + assert tag == CANONICAL, f"{dockerfile}: python:{tag}-slim != python:{CANONICAL}-slim" + assert checked, "no `FROM python:-slim` found — add the pattern if the base moved" + + +def test_every_pyproject_agrees_on_major_minor(): + """requires-python floor, mypy python_version and trove classifiers match X.Y.""" + checked = [] + for pyproject in _ours(ROOT.rglob("pyproject.toml")): + text = pyproject.read_text() + for floor in re.findall(r'requires-python\s*=\s*">=\s*([\d.]+)\s*"', text): + checked.append(pyproject) + assert floor == MAJOR_MINOR, f"{pyproject}: requires-python >= {floor} != {MAJOR_MINOR}" + for version in re.findall(r'python_version\s*=\s*"([\d.]+)"', text): + assert version == MAJOR_MINOR, f"{pyproject}: mypy python_version {version} != {MAJOR_MINOR}" + # Only major.minor classifiers (`:: 3.14`); a bare `:: 3` is ignored. + for classifier in re.findall(r"Programming Language :: Python :: (\d+\.\d+)", text): + assert classifier == MAJOR_MINOR, f"{pyproject}: classifier {classifier} != {MAJOR_MINOR}" + assert checked, "no pyproject.toml with requires-python found" + + +def test_every_workflow_setup_python_agrees_on_major_minor(): + """`actions/setup-python` inputs across all workflows match X.Y (patch allowed).""" + workflows = ROOT / ".github" / "workflows" + for workflow in [*workflows.glob("*.yml"), *workflows.glob("*.yaml")]: + for version in re.findall(r'python-version:\s*"([\d.]+)"', workflow.read_text()): + got = ".".join(version.split(".")[:2]) + assert got == MAJOR_MINOR, f"{workflow}: setup-python {version} != {MAJOR_MINOR}"