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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ jobs:
# the same tables) and the suite is small enough that
# serial is fine.
- name: unit
paths: tests/auth tests/runner tests/storage tests/test_logging_config.py
paths: tests/auth tests/runner tests/storage tests/test_logging_config.py tests/test_dependency_pins.py
pytest_args: -n auto
- name: services-api
paths: tests/services tests/api
Expand Down
11 changes: 11 additions & 0 deletions services/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ packages = [{include = "terrapod"}]
[tool.poetry.dependencies]
python = ">=3.13,<3.14"
fastapi = ">=0.135.2,!=0.136.3,<0.137.0"
# Pin starlette directly even though it is fastapi's dependency. fastapi
# only declares a FLOOR (`starlette>=0.46`, no ceiling), so without our own
# upper bound starlette floats to the latest release on every build — a
# starlette major/minor can then change framework behaviour with no
# deliberate bump on our side. Capping it here forces every starlette move
# to be an explicit, reviewed edit, and makes a fastapi bump that needs a
# starlette outside this range FAIL TO RESOLVE rather than silently swap it
# (fastapi 0.137 + starlette 1.x is exactly the trap this guards). See
# tests/test_dependency_pins.py — keep the upper bound. Bump this line in
# lock-step when intentionally taking a new starlette.
starlette = ">=1.3.1,<1.4.0"
uvicorn = {extras = ["standard"], version = ">=0.42,<0.50"}
pydantic = "^2.10.0"
pydantic-settings = "^2.13.1"
Expand Down
60 changes: 60 additions & 0 deletions services/tests/test_dependency_pins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Guard rails on dependency pins that have bitten us before.

These read pyproject.toml and assert structural invariants about a few
dependencies whose unpinned/under-pinned state has caused (or could
cause) silent framework-level breakage. They are deliberately cheap
source-introspection checks — no install, no import.
"""

from __future__ import annotations

import pathlib
import tomllib

_PYPROJECT = pathlib.Path(__file__).resolve().parents[1] / "pyproject.toml"


def _deps() -> dict[str, object]:
data = tomllib.loads(_PYPROJECT.read_text())
return data["tool"]["poetry"]["dependencies"]


def _version_spec(spec: object) -> str:
# A dependency value is either a bare version string or a table with a
# `version` key (e.g. uvicorn = {extras = [...], version = "..."}).
if isinstance(spec, str):
return spec
if isinstance(spec, dict):
return str(spec.get("version", ""))
return ""


def test_starlette_is_directly_pinned_with_upper_bound() -> None:
"""starlette MUST be a direct dependency with an explicit upper bound.

fastapi only declares a floor (``starlette>=0.46``, no ceiling), so
without our own cap starlette floats to the latest release on every
build — a starlette major/minor can then change framework behaviour
with no deliberate bump on our side, and a fastapi bump can drag a new
starlette in silently. Pinning it directly forces every starlette move
to be an explicit, reviewed edit; a fastapi version needing a starlette
outside our range then fails to resolve instead of swapping it quietly.
(fastapi 0.137 + starlette 1.x is exactly the trap this guards.)

If you are intentionally taking a new starlette, bump the pin in
pyproject.toml — do not delete the upper bound.
"""
deps = _deps()
assert "starlette" in deps, (
"starlette must be declared as a DIRECT dependency in pyproject.toml, "
"not left as a floor-only transitive of fastapi"
)
spec = _version_spec(deps["starlette"])
assert "<" in spec, f"starlette needs an explicit upper bound; got {spec!r}"


def test_fastapi_has_upper_bound() -> None:
"""fastapi must keep an explicit upper bound (it ships breaking changes
in 0.x minors — e.g. the 0.137 include_router refactor)."""
spec = _version_spec(_deps()["fastapi"])
assert "<" in spec, f"fastapi needs an explicit upper bound; got {spec!r}"
Loading