ci: build the API image and smoke the container before merge - #11205
Conversation
The first build attempt of a changed Dockerfile happened in Cloud Build, after the merge — which is how the deploy-api trigger sat red from 2026-08-30 until #10821 while every PR check stayed green. ci-image.yml builds api/Dockerfile with Buildx (push: false, load: true, GHA layer cache), runs the result with no database and no secrets, and asserts what only a running container can show: /health answers, its version equals pyproject.toml's, the OG disk fallback survived the runtime stage's COPY, and the process runs as uid 1000. Two hadolint steps gate both Dockerfiles at threshold warning, so a NEW warning blocks; the three exceptions in api/Dockerfile are named at their line. Change detection follows the ci-lint/ci-tests shape and deliberately excludes plots/**: the plot pipeline's PRs touch nothing the image serves. Adopted from the sibling repo kurrentschrift, which added the same job after its pyproject.toml fell out of the runtime stage. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke
There was a problem hiding this comment.
🟡 Changes recommended
Change detection can fail open and omits build-context inputs, allowing relevant changes to bypass the image gate.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds a pre-merge CI gate for building, linting, and smoke-testing the API container.
Changes:
- Adds image build, runtime assertions, and Hadolint checks.
- Documents the workflow and adds a changelog entry.
File summaries
| File | Description |
|---|---|
.github/workflows/ci-image.yml |
Adds the container CI job. |
docs/workflows/overview.md |
Documents the workflow. |
CHANGELOG.md |
Records the CI improvement. |
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 3
- Review effort level: Balanced
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Review findings on #11205: - The change detector failed OPEN: a git diff that could not be computed produced empty output, which read as "nothing changed" and skipped the gate — a check that passes by failing. A PR or merge-group diff that fails now stops the job (fetch-depth 0 guarantees those refs), and a push with no reachable parent builds rather than skips. - README.md and a root .dockerignore are build-context inputs too: the builder copies README.md next to pyproject.toml before `uv sync`, and a root .dockerignore decides what a `context: .` build can see at all. Both now trigger the job. - The workflow inventory in agentic/docs/project-guide.md carries the same list as docs/workflows/overview.md; ci-image.yml was missing there. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke
There was a problem hiding this comment.
🟡 Changes recommended
Push detection can miss earlier commits, and global Hadolint ignores allow future violations of the supposedly gated rules.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 3
- Review effort level: Balanced
… cover Second review round on #11205: - A push can carry several commits; HEAD~1..HEAD missed an earlier one touching api/ under an unrelated tip commit. The push branch now diffs github.event.before..after, with a new branch (before = zeroes) and an unreachable old tip after a force-push both building rather than skipping. The four branches are a case statement now, one per event. - hadolint's `ignore` is file-wide, so the comment claiming "a NEW warning blocks" was too strong: a second DL3013/DL3008/DL3025 anywhere in api/Dockerfile is suppressed too. The comment now says exactly what is covered and names the line-scoped follow-up (`# hadolint ignore=<code>` next to each instruction), which is a Dockerfile edit this change does not make. - Changelog: all-caps emphasis replaced with sentence case, and the entry carries its PR reference like every other bullet. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke
There was a problem hiding this comment.
🔵 Needs a closer look
The change introduces branch-gating CI infrastructure and therefore warrants final human review despite successful workflow runs.
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Merge resolves the CHANGELOG conflict by keeping both Added entries (#11205 landed on main while this branch was open). Copilot review, two findings. 1. /debug/cache/invalidate is exempt from the gate on the grounds that it has its own lock - so that lock has to be as cheap to fail as the gate is, and it was not: it compared the token as str, and it is the one endpoint reachable on the direct run.app URL, so a non-ASCII X-Cache-Token turned a 401 into an unhandled, logged 500. X-Admin-Token had the same comparison. Rather than fixing three call sites separately, the byte-wise compare moves into api/secret_compare.py and all three use it; a comparator that is correct in two places out of three is exactly what nobody notices. 2. --set-env-vars is destructive over the revision environment, where a secret-backed variable lives alongside a literal one - so it was a second way for a pipeline deploy to drop the hand-attached ORIGIN_SECRET and disarm the gate, undoing what --update-secrets had just bought. Both flags are additive now. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke
Merge resolves the CHANGELOG conflict by keeping both Added entries (#11205 and #11206 landed on main while this branch was open). Copilot review: _candidate_paths treated a whole code span as one token, so '.claude/commands/ -> ../agentic/commands/' - a sentence about two paths - was not path-shaped and went unchecked as a whole. Spans are now split on whitespace before the shape test; fragments that are not path-shaped (the arrow, a command word, a flag) drop out, which is what makes the split safe. A '../'-relative fragment is skipped rather than resolved against the repository root, where it would mean something else entirely. That leaves the arrow's meaning unpinned, and the sharper failure is the one where somebody replaces the symlink with a real directory: both ends still exist, the guide still reads true, and commands written on either side quietly stop matching the other. So the symlink gets its own test, which follows the link instead of matching its text. (The specific example was in fact already covered, because '.claude/commands/' appears standalone later in the same sentence - but the hole in the heuristic was real.) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke
Why
The first build attempt of a changed
api/Dockerfilehappened in Cloud Build — after the merge. That is not a hypothetical: thedeploy-apitrigger sat red from 2026-08-30 until #10821, and every PR check was green the whole time, because nothing in CI ever built or ran the image. The sibling repo kurrentschrift added the same job for the same class of miss (itspyproject.tomlfell out of the runtime stage, so the API would have reported version0.0.0in production and no check could have seen it). Transferred here per the sibling rule; keep the two jobs in the same shape.What the new
ci-image.ymldoesA single
imagejob, in the shapeci-lint.yml/ci-tests.ymlalready use:api/**,core/**,pyproject.toml,uv.lock,README.md, a root.dockerignore,app/Dockerfileor the workflow itself changed.plots/**is deliberately excluded — the automated plot pipeline opens hundreds of PRs that touch nothing the image serves, and each would otherwise pay for a container build. Verified against a sample file list:plots/…/plot.py,app/src/App.tsx,app/README.mdanddocs/*.mddo not match;api/main.py,core/images.py,pyproject.toml,uv.lock,README.md,.dockerignore,app/Dockerfileand.github/workflows/ci-image.ymldo. Onecasebranch per event, and each fails closed: a PR or merge-group diff that cannot be computed stops the job rather than reading as "nothing changed", and a push diffs its whole range (github.event.before..after, so an earlier commit touchingapi/under an unrelated tip commit still counts) with a new branch or an unreachable force-pushed tip building rather than skipping. All four branches simulated against a throwaway repo — see Evidence.docker/build-push-action,push: false/load: true(nothing reaches a registry — Cloud Build still owns the published image;loadputs the result in the local daemon so it can actually be run) andcache-from/cache-to: type=gha.docker runwith no database and no secrets —api/main.pyguards its DB init withis_db_configured(), so a bare container boots. Then:/healthanswers"healthy"within a 90 s readiness window (this image imports matplotlib, scikit-learn, statsmodels and the MCP server first)./health'sversionmust equalpyproject.toml's.api/version.pyreads the installed distribution's metadata and falls back to0.0.0+unknownwhen it is absent — silently, in a field/health,/openapi.jsonand the MCP server all report. The builder stage installs the project from a context that haspyproject.tomlanduv.lockbut no source yet, so that dist-info is a genuinely fragile artefact of the stage ordering and nothing else in CI looks at it.test -f /app/api/static/og-image.png— the one payload the runtime stage must ship that no import would catch.og_images.pyreads it off disk as the last resort when dynamic OG rendering fails, i.e. exactly in a fresh container that cannot reach the font bucket.id -umust be 1000.USER appuseris one line above theCMD; nothing else notices if a rebuild drops it, and Cloud Run runs whatever the image says.warningrather than a non-blocking run, so a warning of any other code blocks — which is the point of having the linter. The three exceptions are named at their line. Note honestly whatignoreis: hadolint applies it file-wide, so a second DL3013/DL3008/DL3025 elsewhere inapi/Dockerfileis suppressed too. Line-scoping needs# hadolint ignore=<code>comments in the Dockerfile itself — the better home, and the named follow-up below, but a Dockerfile edit is not this change's to make.Evidence
Both runs on this PR,
Build API image and smoke the container:The job runs in parallel with the other CI workflows, so the pipeline's wall clock is unchanged; it only adds runner minutes, and only on PRs that touch the image.
Smoke output, verbatim:
(Container answered
/healthabout 6 s afterdocker run;3.2.0ispyproject.toml'sproject.version.)Hadolint was reproduced locally against 2.15.1, the version
hadolint-action@v3.5.0pins:warning, no ignoresapi/DockerfileDL3013(line 25),DL3008(47),DL3025(90) → exit 1;DL3066(80) at infoapp/Dockerfile--failure-threshold info)The three ignores and why each is a deliberate choice, not a suppression:
DL3013—pip install uvunpinned. uv is the installer; the versions that matter are pinned inuv.lock, which the very next line honours withuv sync --frozen.DL3008— unpinned aptcurl/libraqm0. Pinning a Debian point release breaks the build on every security update of the base image.DL3025— shell-formHEALTHCHECK CMD … || exit 1. The fallback needs a shell; JSON form cannot express it.DL3066(non-numericUSER) also fires but only at info level, so it stays visible in the log without blocking —useradd -u 1000already gives the user a fixed uid.Change detection simulated against a throwaway repo, all four branches:
api/, tip is unrelatedshould_build=true(the oldHEAD~1..HEADform saw onlyunrelated.txt)before= zeroes)beforepull_requestwith an unreachable base sha::error::…refusing to decide…should_build=falseactionlint1.7.7 on the new file: clean (exit 0).bash -non the extracted change-detection script: clean.Decisions taken here (routine, flagged for override)
ci-*.ymlrather than a job insideci-tests.yml. The repo already separates CI concerns file by file (ci-lint,ci-tests,bot-serving-check,notify-deployment,sync-postgres), and a docker build has a different runtime profile and a different skip condition than the Python test job. This extends the existing layout rather than changing it.env:, not${{ }}inside the script. Same logic as the sibling files, one notch safer; a shell that never sees interpolated event data cannot be made to execute it.uses:are pinned by commit SHA of the current major tag, as everything else in.github/workflows/is; all three SHAs were verified against the upstream tag lists (docker/setup-buildx-actionv4.3.0 = v4,docker/build-push-actionv7.3.0 = v7,hadolint/hadolint-actionv3.5.0). Dependabot'sgithub-actionsecosystem keeps them current.Findings for the author — not changed here,
api/is out of this PR's scopeapi/.dockerignoreis dead. The build context is the repo root (-f api/Dockerfile .), so Docker looks for/.dockerignore, which does not exist. Proof:api/.dockerignoreexcludes*.md, yet the builder'sCOPY pyproject.toml uv.lock README.md ./succeeds — it could not if that file were in effect. Consequence: every build ships the full repo (~210 MB incl..gitandplots/) as context.api/Dockerfileas# hadolint ignore=<code>comments, one line aboveRUN pip install uv, theapt-get install, and theHEALTHCHECK. That makes them line-scoped, so a new occurrence of the same code elsewhere blocks — which the workflow-levelignorecannot do. Three comment lines; left out only becauseapi/is not this PR's to touch.COPY plots/ ./plots/in the runtime stage looks like dead weight (~98 MB). Nothing underapi/orcore/reads the directory at runtime — implementations are served from Postgres (sync-postgres.yml). If that holds, dropping it would take about a fifth off the image. I did not touch it: it is a behaviour change in a file this PR only lints.🤖 Generated with Claude Code
https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke