Skip to content

ci: resolve the declared dependency floors on every PR - #188

Merged
lesnik512 merged 1 commit into
mainfrom
ci/lowest-bound-resolution
Sep 20, 2026
Merged

lesnik512 merged 1 commit into
mainfrom
ci/lowest-bound-resolution

Conversation

@lesnik512

Copy link
Copy Markdown
Member

Why

Every CI leg resolves highest. pytest and freethreaded run uv sync, and
scheduled-dep-check re-runs the same checks daily, so the upper bound is well
covered: a new upstream release turns main red within a day. Nothing installs
the declared lower bounds, so they ship untested.

That is not hypothetical. 0.13.2 declared faststream>=0.7.1,<0.8 and was green
the whole time. faststream 0.7.6 then made address a required field of
SubscriberSpec and PublisherSpec; a fresh resolve of an unchanged pin picked
it up and AsyncAPI generation started raising TypeError. It was found in a
running service, not in CI. #182 noted the cause at the time: "Nothing in CI
resolves at the lower bound, which is why the previous floor went stale
unnoticed." #185, #186 and #187 then corrected six floors by hand in one day,
which is what an untested constraint looks like.

What

A lowest job in _checks.yml that resolves --resolution lowest-direct and
runs the suite against it, on the same triggers as the rest (every PR, every
push to main, and the daily schedule).

One interpreter per distinct floor set, since the markers define exactly three:

leg sqlalchemy pydantic asyncpg
3.11 2.0.0 2.0.2 0.29.0
3.13 2.0.31 2.8.0 0.30.0
3.14 2.0.31 2.12.0 0.31.0

3.12 resolves the same set as 3.11 and would add nothing.

The dev group needed floors before this could work: uv sync --resolution lowest-direct reads an unbounded pytest as pytest==2.0.0, whose sdist does
not build on a modern interpreter. pytest>=8, pytest-asyncio>=0.24 and
pytest-cov>=5 are the versions this suite actually needs (asyncio_mode and
asyncio_default_fixture_loop_scope are both 0.24-era options).

Verified

Ran the exact step sequence locally on all three legs before opening this. Each
resolves to the floors in the table and passes:

517 passed, 107 skipped

The 107 skips are the Postgres-backed tests, which skip without a database; the
job runs the same postgres:17 service the other legs do, so they execute in CI.

Two judgment calls worth a look

uv pip install httpx as a step. fastapi's floor (0.113) pulls starlette
0.38.6, whose TestClient predates httpx2 support, so tests/test_fastapi.py
cannot even collect without httpx. No test imports httpx directly; it is purely
starlette's dependency. Adding it to the dev group instead would install a second
HTTP client in every other leg to serve this one, so it is a step here. The
alternative is raising the fastapi floor to the first release whose starlette
takes httpx2, which trades a CI line for a narrower supported range. Happy to
switch if you prefer that.

3.14 is noisy. pytest-asyncio 0.24 calls asyncio.iscoroutinefunction,
deprecated in 3.14, so that leg emits ~33k warnings. It passes. A marked
pytest-asyncio>=1.0; python_version >= '3.14' would silence it at the cost of
no longer testing the declared floor there, so I left it.

Not covered

This gates the floors from here on. It does not help anyone already pinned to
faststream-outbox~=0.13.2, whose range still resolves the broken pair; that was
considered and declined separately.

Every existing leg resolves highest, so the declared lower bounds ship
untested. That is how 0.13.2 went out with `faststream>=0.7.1` and stayed
green until 0.7.6 landed, made `address` a required field of `SubscriberSpec`
and `PublisherSpec`, and broke AsyncAPI generation for anyone who resolved
fresh.

The new `lowest` leg resolves `--resolution lowest-direct` and runs the suite
against it, one interpreter per distinct floor set. The dev harness needed
floors of its own first: an unbounded `pytest` there resolves to 2.0.0, which
does not build.
@github-actions

Copy link
Copy Markdown

Benchmark gate

✅ gate passed

scenario msg/s delete/msg WALrec/msg WALB/msg fpi upd del dead_tup
consumer/w1/b10 1128 1.000 6.80 909 242 5000 5000 10000
consumer/w1/b100 1144 1.000 6.86 916 243 5000 5000 10000
consumer/w2/b10 1404 1.000 6.79 960 242 5000 5000 10000
consumer/w2/b100 1673 1.000 6.73 953 243 5000 5000 10000
consumer/w4/b10 1322 1.000 6.70 929 242 5000 5000 10000
consumer/w4/b100 2098 1.000 6.91 1072 263 5000 5000 10000
consumer/w1/b100/tfbs100 8731 0.010 6.07 1118 244 5000 5000 10000
producer/w1/b100 3125 0.000 3.04 584 0 0 0 0

Gated (fails the build): delete_calls + tuple counters (upd/del/ins) + the producer's insert_calls, exact; select_calls within +2; wal_records within a 10% band. msg/s, WAL bytes and total calls are informational (timing/FPI noise).

@lesnik512
lesnik512 merged commit 89b5d8e into main Sep 20, 2026
12 checks passed
@lesnik512
lesnik512 deleted the ci/lowest-bound-resolution branch September 20, 2026 13:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant