ci: smoke the runtime image, pin CI deps, structure test results - #125
Merged
Conversation
Four independent CI improvements, all off the back of measured run data: the compose job is 9m13s, of which the root pytest run is 481s (92% of the critical path) with a flat duration profile (slowest test 2.45s). 1. Drop Postgres durability in the CI stack. The CI cluster is rebuilt from the bundled migrations on every run and destroyed with `down -v`, so fsync/synchronous_commit/full_page_writes buy nothing while costing a barrier on every one of ~3000 committing tests. PGDATA moves to a size-capped tmpfs. max_connections is raised to 200 because each test process opens several pooled engines and the default leaves no headroom for running the suite in parallel later. 2. Build and smoke the production `runtime` image. CI only ever built the `ci` target, so the `runtime` target — a different install path (non-editable `pip install .`, no dev extras) behind a different entrypoint (uvicorn) — could break and reach main undetected. A new job builds it, asserts the package imports and the console script resolves, then boots it against a real database and probes /health and /ready. /ready is the load-bearing assertion: it proves the production image reached Postgres as the non-owner app role under FORCE RLS, resolved a tenant context, and found pgvector >= 0.8 — the one place CI exercises the production role configuration end to end. The service lives in docker-compose.ci.yml behind a `smoke` profile so it is excluded from the default `up` that runs the test suite. 3. Add a per-test timeout and JUnit XML output. The suite is concurrency-heavy and a deadlock previously consumed the full 30-minute job timeout without naming the offending test. pytest-timeout now caps each test at 60s (~24x the slowest test). The `signal` method is deliberate: `thread` hard-exits via os._exit() and would abort pytest before it writes the results below. All four suites emit JUnit XML, which both workflows copy out of the stopped container and upload as an artifact, so failures are readable as structured results instead of by scrolling a Compose log interleaved with Postgres output. 4. Pin the CI image's dependencies to uv.lock. The image was built with a bare `pip install -e ".[dev]"`, re-resolving the floating floors in pyproject.toml (fastapi>=0.115, openai>=1.0, ...) on every cache miss. CI could therefore test against versions that differ from `uv sync --extra dev`, and an upstream release could break the build with no change to this repository — while the lock-drift job passed. uv.lock is now exported as a pip constraints file at build time. It is a constraints file rather than a direct install because uv.lock covers only the root project; the SDK and adapters still resolve their own trees (notably `mcp` and `pyyaml`), now bounded by these pins where they overlap. `uv export --frozen` never rewrites the lockfile and fails if it has drifted, so this repeats the lock-drift gate inside the build. Also adds the missing timeout-minutes to compose-validate, the only job that was inheriting the 6-hour default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PHp9CTivLBMLqkZFsRr227
tests/test_ci_contract.py pins the shape of the hosted CI configuration, and the previous commit changed four of the things it asserts on. The contract did its job — it caught every structural change — so this updates it to match, and extends it to cover the new behavior rather than merely relaxing the counts. Updated: - Read-only hosted runners: four jobs -> five, for runtime-image-smoke. The permissions assertions are unchanged and still pass; the new job adds no write scope and no credential persistence. - Build isolation: two build-push-action steps -> three. The assertions that actually encode "the CI image is built exactly once per event" — two mutually exclusive `target: ci` builds sharing one tag and one cache scope — are unchanged, and are now stated separately from the raw counts so the intent survives the next edit. - Dockerfile metadata copy now includes uv.lock. - The root-suite selector line now carries the shared pytest flags. Added: - The runtime smoke job and its profile-gated Compose service: the job exists, probes both /health and /ready, and the service builds `target: runtime` and connects as the non-owner application role. - The runtime build uses a distinct image tag and its own cache scope, so it can neither be mistaken for the CI image nor evict the CI cache entries. - The uv.lock constraints export: `--frozen`, `--no-emit-project`, the pinned uv version, and that the build never rewrites the lockfile. - The timeout and JUnit guards on all four suites, including that the timeout method stays `signal` — `thread` hard-exits via os._exit() and would abort pytest before it writes the results. - The Postgres durability flags, and that the JUnit export runs before teardown in both real-DB workflows. The four failures were the only ones in the run: 4 failed, 2952 passed, 34 skipped — the same 2956 total as before the change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PHp9CTivLBMLqkZFsRr227
The MCP adapter suite failed with `ModuleNotFoundError: No module named 'mcp.server.fastmcp'`. Two separate problems, one of which is a real defect that predates this branch. 1. `mcp>=1.0` was wrong on both ends. engram_mcp.server imports `mcp.server.fastmcp`. That module first appears in mcp 1.2.0 (1.1.3 and earlier lack it) and is removed again in 2.0.0, which replaces it with `mcp.server.mcpserver`. The declared floor admitted both broken ends. CI stayed green only because the newest release happened to sit inside the working range — and stopped being green the moment 2.0.0 shipped, with no change to this repository. The requirement is now `mcp>=1.2,<2`, which is exactly the range that provides the imported API. Verified against 1.1.3 (absent), 1.2.0 (present), 1.29.0 (present) and 2.0.0 (absent). Widening it again requires porting the adapter off FastMCP. Nothing pinned this before: the CI image resolved dependencies fresh on every layer-cache miss, so the break was latent and would have surfaced on the next dependency change regardless of which change that was. 2. uv.lock could not pin the CI image, because it only covered the root project. The previous commit exported it as a pip constraints file anyway. That is unsound: mcp pulls in httpx-sse, jsonschema, pyjwt and others that a root-only lock has no entry for, while that same lock bounds packages mcp shares with the service. A constraints file bounds versions but never forces one, so pip resolves whatever fits instead of failing — the failure mode is silent by construction. The SDK and adapters are now uv workspace members, so the lock resolves one consistent set across everything installed together, and the export uses --all-packages --all-extras --no-emit-workspace. Every package is pinned to a set uv proved consistent, so pip must install that set or fail loudly. Adding the members does not perturb the service's own resolution: starlette 1.3.1, fastapi 0.139.0, pydantic 2.13.4, httpx 0.28.1 and anyio 4.14.1 are all unchanged. It also widens the existing lock-drift gate, which until now silently ignored the SDK and adapters. Verified locally by reproducing the Dockerfile install (pip with the exported constraints, all four members editable): mcp resolves to 1.29.0, and the MCP adapter (33 passed, 3 DB-skipped — the same 36 the hosted run reports), SDK (55), engram-hooks (186) and CI-contract (13) suites all pass, with `uv lock --check` and `ruff check .` clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PHp9CTivLBMLqkZFsRr227
PR #125 claimed the Postgres durability flags would speed up the real-DB suite. This run measured it, and they do not. Root suite before: 481s across 2956 tests (run 30300374394, PR #123's merge-ref run, so the test content is comparable). After: 508.32s and 500.01s across 2992 tests, on the merge-ref and exact-head jobs of run 30376403187 — the same commit twice, which puts run-to-run variance at ~8s. Per-test cost is 0.163s before and 0.167-0.170s after: unchanged to within that variance, and certainly not faster. The profile already predicted this and I did not follow it through. A flat `--durations` (slowest test 2.45s, top 25 ~7% of wall clock) means the suite is bound by per-test fixed overhead, not by commit durability, so removing write barriers had nothing to bite on. The rationale in the compose file was reasoning about a bottleneck the measurement had already ruled out. The settings stay: they are free and correct for a cluster that is rebuilt from migrations every run and destroyed with `down -v`, and max_connections=200 is the prerequisite for sharding the suite, which is where the actual time is. The comment now says that, and records the numbers so the next reader does not re-litigate it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PHp9CTivLBMLqkZFsRr227
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four independent CI changes, from a review of the existing workflows against measured run data.
Baseline
From run 30300374394 (PR #123's merge-ref run, so its test content is comparable to this PR's base):
compose-real-db-merge-refcompose-real-db-exact-head(separate workflow)conformance-vectorscompose-validatelock-driftInside the 9m13s job: checkout 2s, buildx 7s, Docker build 21s (layer caching is already well tuned), compose stack 8m 40s, cleanup 2s. Inside that, the root pytest run is 481s — 92% of the critical path. Its
--durations=25profile is flat: slowest test 2.45s, top 25 summing to ~35s (7%), remaining 2,931 tests averaging 0.152s. There is no hot spot; the suite is dominated by per-test fixed overhead.1. Postgres durability off in the CI stack — no measured gain
docker-compose.ci.ymlran stock durability settings while ~3000 tests each committed at least once. The cluster is rebuilt from the bundled migrations every run and destroyed withdown -v, sofsync/synchronous_commit/full_page_writesprotect nothing. PGDATA moves to a size-capped (2g) tmpfs.This was expected to be the speedup, and it is not. Measured below: per-test cost is unchanged to within run-to-run variance. That is what the baseline profile already implied and I did not follow through on — a flat
--durationsmeans the suite is bound by per-test fixed overhead, not by commit durability, so removing write barriers had nothing to bite on. The premise was reasoning about a bottleneck the profile had already ruled out.The settings stay, but as correctness-and-hygiene for a disposable cluster rather than as an optimization.
max_connectionsis raised to 200 because each test process opens several pooled engines (runtime, owner, read, provisioner) and the default 100 leaves no headroom for running the suite in parallel — that is a prerequisite for sharding, which is where the time actually is.2. Build and smoke the production
runtimeimageCI only ever built the
citarget.compose-validaterunsdocker compose config, which validates syntax but never builds. So theruntimetarget — a different install path (non-editablepip install ., no dev extras) behind a different entrypoint (uvicorn) — could break and reachmainundetected.A new
runtime-image-smokejob builds it, asserts the package imports and theengramconsole script resolves, then boots it against a real database and probes/healthand/ready./readyis the load-bearing assertion: it proves the production image reached Postgres as the non-ownerengram_approle under FORCE RLS, resolved a tenant context, and found pgvector >= 0.8. That makes this the one place CI exercises the production role configuration end to end — the main suite connects as the owner, which bypasses RLS.The service lives in
docker-compose.ci.ymlbehind asmokeprofile so it is excluded from the defaultupthat runs the test suite.3. Per-test timeout and JUnit XML
The suite is concurrency-heavy (worker dedup/auto-supersede/flagging, promotion review/feedback, manual invalidation) and a deadlock previously consumed the full 30-minute job timeout without naming the offending test.
pytest-timeoutnow caps each test at 60s, ~24x the slowest test.--timeout-method=signalis deliberate:threadhard-exits viaos._exit()and would abort pytest before it writes the results below.All four suites emit JUnit XML, which both workflows copy out of the stopped container and upload as an artifact. Failures become structured results instead of a scroll through a Compose log interleaved with Postgres output.
4. Pin the CI image's dependencies to
uv.lockThe image was built with a bare
pip install -e ".[dev]", re-resolving the floating floors inpyproject.toml(fastapi>=0.115,openai>=1.0, ...) on every cache miss. CI could therefore test against versions differing from whatuv sync --extra devgives developers, and an upstream release could break the build with no change to this repository — all while thelock-driftjob passed, since it only comparesuv.locktopyproject.toml.This was not hypothetical: it surfaced a live defect.
engram_mcp.serverimportsmcp.server.fastmcp, which exists only inmcp>=1.2,<2— 1.1.3 and earlier lack it, and 2.0.0 removes it in favour ofmcp.server.mcpserver. The declaredmcp>=1.0admitted both broken ends, and CI stayed green only because the newest release happened to sit inside the working range. It stopped being green the moment 2.0.0 shipped, with no change here. The bound is nowmcp>=1.2,<2.Pinning is done by exporting
uv.lockat build time as a pip constraints file, with the SDK and adapters added as uv workspace members so one consistent set is resolved across everything installed together. Constraints alone would have been unsound: a root-only lock has no entry forhttpx-sse,jsonschema,pyjwtand the rest ofmcp's tree, and a constraints file bounds versions without forcing any, so pip would silently resolve whatever fits instead of failing.uv export --frozennever rewrites the lockfile and fails if it has drifted, so this also repeats the lock-drift gate inside the build. uv is pinned to 0.11.29, the same version thelock-driftjob uses.Also
Adds the missing
timeout-minutestocompose-validate, the only job that was inheriting the 6-hour default.Measured results
From runs 30376403187 / 30376403318 and 30377598800, all six checks green.
Root suite, the 92% critical path:
The three "after" rows are the same suite on unchanged test code, and they span 13.9s. That spread is the measurement floor, and the gap to baseline sits barely outside it while the suite carries 36 more tests (+1.2%). The durability change is therefore not measurable — treat it as zero, not as a small win.
Whole-job wall clock, merge-ref: 9m13s baseline → 10m33s on dadf4fc → 9m29s on 70447d4. The middle figure was a one-time
Dockerfilelayer-cache miss, now confirmed rather than assumed: 70447d4 leaves theDockerfileuntouched and its build step came in at 24.1s against the 21s baseline, versus 64s when the dependency layer rebuilt. So the steady-state cost of item 4 is ~3s ofuv export, and the residual +16s on the job is the extra tests.New job:
runtime-image-smoke, 1m 15s, runs in parallel and does not extend the critical path.Net: this PR buys coverage (production image, RLS-as-app-role), diagnosability (JUnit artifacts, per-test timeout) and reproducibility (pinned deps, plus one real latent break fixed), at a steady-state cost of ~3s of build time. It does not buy time.
Verification
Validated locally before pushing:
uv lock --checkunder uv 0.11.29 (the versionlock-driftpins)ruff check .under ruff 0.15.21 (the versionuv.lockpins)docker compose config -qon both compose files; the smoke service is absent from the default service list and present under--profile smoketests/test_ci_contract.py— 13 passed, updated to pin the new gatesmcpresolves to 1.29.0; MCP adapter 36, SDK 55, engram-hooks 186 all passConfirmed in the hosted run: the constraints install, the tmpfs mount,
/readyunder the app role, and the JUnit artifacts (4 XML files uploaded per real-DB job).Not in scope
The review behind this PR also covered sharding the root suite, the duplicated full-suite run across
ci.ymlandexact-head-ci.yml, a deadengram-ci-exact-headcache scope, untested Python 3.11 support, mypy covering onlyengram/, a missingruff format --check, and no coverage measurement despitepytest-covbeing a dev dependency.Sharding is now the load-bearing one. With durability ruled out and the profile flat, per-test fixed overhead across parallel runners is the only remaining lever on the 8-minute suite.
Generated by Claude Code