Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,35 @@ 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

- 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
Expand All @@ -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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<x.y>-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.
Expand Down
2 changes: 1 addition & 1 deletion CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
78 changes: 78 additions & 0 deletions tests/unit/test_ci_python_matrix.py
Original file line number Diff line number Diff line change
@@ -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:<major>.<minor>-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"
)
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading