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" },