From 4e0834c677461fbcd79b276c3c20fc6b044742ed Mon Sep 17 00:00:00 2001 From: Bernt Popp Date: Thu, 30 Jul 2026 17:57:46 +0200 Subject: [PATCH] ci: test the interpreter we actually ship (3.12 + 3.14 matrix) docker/Dockerfile ships python:3.14-slim, but the CI test job ran only on Python 3.12. The image was exercised on 3.14 by container-ci and conformance; the test suite never was. A 3.14-only stdlib or typing regression could ship uncaught. The quality job now runs a ["3.12", "3.14"] matrix. Both ends matter: 3.12 is the declared requires-python floor and must stay tested or the floor is a false claim, and 3.14 is what reaches production. The coverage gate still runs once (on 3.14, push to main), so the cost is one extra fast test run per event. The matrix needs UV_PYTHON to be real. uv resolves its interpreter from .python-version -- pinned to 3.12 for local dev -- before it consults PATH, so actions/setup-python alone does not move it: both legs would build a 3.12 environment and the "3.14" leg would test the floor twice, green and meaningless. The job-level env var also covers every `uv run` inside `make ci-local`, not just the explicit `uv sync`. tests/unit/test_ci_python_matrix.py pins the matrix against pyproject's floor and the Dockerfile's FROM python:-slim, plus the UV_PYTHON wiring and the single coverage run, so a base-image bump that is not mirrored into CI fails instead of silently narrowing it. requires-python, ruff target-version and mypy python_version are deliberately unchanged at 3.12. Verified locally on both interpreters: 478 passed, 2 skipped (live conformance probes) on 3.12 and on 3.14. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EUS8yK2hqkNpfoTxFR4RPR --- .github/workflows/ci.yml | 23 +++++++-- CHANGELOG.md | 26 ++++++++++ CITATION.cff | 2 +- pyproject.toml | 2 +- tests/unit/test_ci_python_matrix.py | 78 +++++++++++++++++++++++++++++ uv.lock | 2 +- 6 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 tests/unit/test_ci_python_matrix.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 772ef98..fdc94a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,10 +15,27 @@ permissions: jobs: quality: - name: Format, lint, typecheck, tests, and coverage + name: Format, lint, typecheck, tests, and coverage (py${{ matrix.python-version }}) runs-on: ubuntu-latest timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + # 3.12 is the `requires-python` floor; 3.14 is the interpreter the + # container actually ships (docker/Dockerfile). Both ends are tested so a + # base-image bump can never ship an interpreter CI has not exercised. + python-version: ["3.12", "3.14"] + + env: + # Without this the matrix is a lie. `uv` resolves its interpreter from + # `.python-version` (pinned to 3.12 for local dev) *before* it looks at + # PATH, so `setup-python` installing 3.14 changes nothing: both legs would + # build a 3.12 environment and the "3.14" leg would test the floor twice. + # `UV_PYTHON` outranks `.python-version`, and applies to every `uv run` + # inside `make ci-local`, not just the explicit `uv sync` below. + UV_PYTHON: ${{ matrix.python-version }} + steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -26,7 +43,7 @@ jobs: - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: "3.12" + python-version: ${{ matrix.python-version }} - name: Set up uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 @@ -44,5 +61,5 @@ jobs: # Coverage gate runs only on push to main to avoid a second full test run on # every PR iteration (concurrency-cancel + this split are the compute savers). - name: Coverage gate - if: github.event_name == 'push' + if: github.event_name == 'push' && matrix.python-version == '3.14' run: make test-cov diff --git a/CHANGELOG.md b/CHANGELOG.md index 3295bcd..4eb8c11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [4.1.2] - 2026-07-30 + +### Changed + +- **CI tests the interpreter that actually ships.** The `quality` job now runs + on a `["3.12", "3.14"]` matrix instead of 3.12 only. `docker/Dockerfile` ships + `python:3.14-slim`, so until now the test suite was never exercised on the + interpreter that reaches production — only the image itself was, via + `container-ci`/`conformance`. A 3.14-only stdlib or typing regression would + have shipped uncaught. 3.12 stays in the matrix because it is the declared + `requires-python` floor; dropping it would make that floor a false claim. The + coverage gate still runs once (on 3.14, push to `main`). `requires-python`, + ruff `target-version` and mypy `python_version` are deliberately unchanged at + 3.12. +- **The matrix is forced with `UV_PYTHON`, not just `setup-python`.** `uv` + resolves its interpreter from `.python-version` (pinned to 3.12 for local dev) + *before* it consults `PATH`, so `actions/setup-python` alone does not move it: + both legs would have built a 3.12 environment and the "3.14" leg would have + tested the floor twice — green, and meaningless. The job now exports + `UV_PYTHON: ${{ matrix.python-version }}`, which outranks `.python-version` + and applies to every `uv run` inside `make ci-local`. +- **New guard `tests/unit/test_ci_python_matrix.py`.** Pins the matrix against + `pyproject.toml`'s floor and the Dockerfile's `FROM python:-slim`, so a + base-image bump that is not mirrored into CI fails instead of silently + re-opening the blind spot. + ## [4.1.1] - 2026-07-30 Dependabot onboarding plus the accumulated dependency sweep it had been hiding. diff --git a/CITATION.cff b/CITATION.cff index d309f24..475e8be 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -13,7 +13,7 @@ authors: given-names: Bernt email: bernt.popp@charite.de affiliation: Charité – Universitätsmedizin Berlin -version: 4.1.1 +version: 4.1.2 license: MIT repository-code: https://github.com/berntpopp/stringdb-link url: https://genefoundry.org diff --git a/pyproject.toml b/pyproject.toml index d7c1072..df141c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "stringdb-link" -version = "4.1.1" +version = "4.1.2" description = "High-performance unified API server for STRING protein-protein interaction database with MCP integration" readme = "README.md" license = { text = "MIT" } diff --git a/tests/unit/test_ci_python_matrix.py b/tests/unit/test_ci_python_matrix.py new file mode 100644 index 0000000..bbe99dc --- /dev/null +++ b/tests/unit/test_ci_python_matrix.py @@ -0,0 +1,78 @@ +"""CI must run the test suite on the interpreter the container actually ships. + +``docker/Dockerfile`` ships ``python:3.14-slim``, but the CI test job ran only on +Python 3.12. ``container-ci``/``conformance`` exercise the *image* on 3.14; the +unit and API suites never did, so a 3.14-only stdlib or typing regression could +ship uncaught. + +Both ends of the matrix are required. 3.12 is the declared ``requires-python`` +floor -- drop it and the floor is a false claim. 3.14 is what reaches +production. And ``UV_PYTHON`` is load-bearing: ``uv`` resolves its interpreter +from ``.python-version`` *before* it consults ``PATH``, so ``setup-python`` +alone would leave both matrix legs on 3.12 -- green, and meaningless. +""" + +from __future__ import annotations + +import re +import tomllib +from pathlib import Path +from typing import Any + +import yaml + +ROOT = Path(__file__).resolve().parents[2] # tests/unit/ -> repo root +WORKFLOW = ROOT / ".github" / "workflows" / "ci.yml" +DOCKERFILE = ROOT / "docker" / "Dockerfile" + + +def _quality_job() -> dict[str, Any]: + workflow = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + return dict(workflow["jobs"]["quality"]) + + +def _matrix_versions() -> list[str]: + return [str(v) for v in _quality_job()["strategy"]["matrix"]["python-version"]] + + +def _requires_python_floor() -> str: + pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + return str(pyproject["project"]["requires-python"]).removeprefix(">=") + + +def _dockerfile_python_version() -> str: + match = re.search( + r"^FROM python:(\d+\.\d+)-slim", DOCKERFILE.read_text(encoding="utf-8"), re.MULTILINE + ) + assert match is not None, "docker/Dockerfile must pin a python:.-slim base" + return match.group(1) + + +def test_ci_tests_the_requires_python_floor() -> None: + assert _requires_python_floor() in _matrix_versions(), ( + "the declared requires-python floor must stay under test, or it is a false claim" + ) + + +def test_ci_tests_the_interpreter_the_container_ships() -> None: + assert _dockerfile_python_version() in _matrix_versions(), ( + "docker/Dockerfile ships a CPython the test matrix never runs" + ) + + +def test_matrix_forces_uv_to_the_matrix_interpreter() -> None: + assert (ROOT / ".python-version").exists() + assert _quality_job()["env"]["UV_PYTHON"] == "${{ matrix.python-version }}", ( + "uv reads .python-version before PATH, so without UV_PYTHON both matrix " + "legs build a 3.12 environment and the matrix silently tests the floor twice" + ) + + +def test_coverage_gate_runs_once_not_per_matrix_leg() -> None: + steps = _quality_job()["steps"] + coverage = [step for step in steps if step.get("run") == "make test-cov"] + + assert len(coverage) == 1 + assert "matrix.python-version == '3.14'" in coverage[0]["if"], ( + "the coverage gate must run on one interpreter, not once per matrix leg" + ) diff --git a/uv.lock b/uv.lock index 3efbc08..97169ff 100644 --- a/uv.lock +++ b/uv.lock @@ -1739,7 +1739,7 @@ wheels = [ [[package]] name = "stringdb-link" -version = "4.1.1" +version = "4.1.2" source = { editable = "." } dependencies = [ { name = "asgi-correlation-id" },