ci: add the ingestion image build+import gate (PR A0 of 3, registers #52, #53)#71
Merged
Merged
Conversation
PR A0 of the three-PR (b1) sequence. CI-ONLY: zero files under src/, zero files under services/. Adds .github/workflows/ingestion-image.yml, which builds services/ingestion through Railway's own build context (Root Directory /services/ingestion, empty Dockerfile Path), then imports the module and boots the container inside the built image. Closes the gap that tsc and db-types inspect no Python at all, so a service-only PR passes both vacuously while Railway's "Wait for CI" is OFF. Opens register #52 (the vacuous-green gap + the 20-green-run promotion rule) and register #53 (the tiktoken cold-start network dependency the workflow surfaced, pre-existing on main, not a (b1) regression). Deliberately NOT added to branch protection. #51 is skipped and reserved for (b1)'s own row, which PR A adds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
… output The first run of this workflow (PR #71, run 30218194135) reported the ingestion-image job GREEN in 38 seconds with its import step emitting zero bytes of stdout: no ROUTES line, no IMPORT OK, no IMPORT FAILED. Cause, proven by direct experiment: `python -` reads its program from stdin, and `docker run` WITHOUT `-i` does not attach stdin to the container. Python read EOF, executed an empty program, exited 0, and the step passed having asserted nothing. A workflow written to end vacuous green checks shipped a vacuous green check. Severity stated accurately: the job was not proving nothing about the import. The boot probe transitively proves the module imports, since uvicorn cannot serve /health without importing main:app. What the dead step cost was the route assertions, the ROUTES evidence line, and the network-vs-ImportError diagnostic banner. Two fixes, the second being the one that matters: 1. Add -i, so the heredoc reaches the interpreter. 2. Pipe through tee and make a trailing `grep -q "IMPORT OK"` the thing that actually fails the step. GitHub runs steps with `bash -e` and NOT `-o pipefail`, so tee would otherwise mask a non-zero docker run. Both paths tested locally under bash -e before pushing: with -i the step exits 0 and prints IMPORT OK; with -i removed it now exits 1, so the regression cannot silently return. Audited the other four steps under the same lens. The build step is not exposed (its exit code and its effect are the same fact). The boot probe already asserts on its payload, in an if-body where bash -e applies. Checkout is self-verifying downstream. The logs step is `|| true` by design. The import step was the only exposed one; no other fix was invented. Recorded in the PR A0 section 7 block and in register #52, whose thesis this is now the first instance of. Kept as a separate commit rather than an amend so the defect stays in the PR's own history. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
PR A0 of the three-PR (b1) sequence. CI-ONLY. Zero files under
src/, zero files underservices/. No service code, no migration, no DB change, no dashboard change.Adds one workflow,
.github/workflows/ingestion-image.yml, plus this repo's requiredPROGRESS.mdrecords.What this PR proves
/services/ingestion, empty Dockerfile Path, read from the dashboard on 2026-07-26 rather than assumed.main.pyimports inside that built image, with the image's own interpreter at its own WORKDIR, and the job asserts on the printed output that it did.services/ingestion/main.py:13importsacreate_client/AsyncClient/AsyncClientOptionsfromsupabaseat module scope, and:50callstiktoken.get_encodingat module scope, so a bad resolve is a boot-time crash, not a runtime one./health— strictly stronger than an import, because it catches ASGI/lifespan startup failures a bare import does not.All four endpoints (
/health,/embed,/convert,/ingest) share one uvicorn process, so a boot crash takes the Ask path down with the upload path. That is the failure this closes.What this PR does NOT prove
It does not prove the Railway environment is correct. CI has no
SUPABASE_URLorSUPABASE_ANON_KEY, so the CI container's/healthcorrectly reports"supabase_configured": false. The job asserts only"ok":true.That separation is load-bearing and must not be collapsed later: this job is the CI twin of the live boot probe. The environment probe has no CI twin and cannot have one — only a live
GET /healthagainst the deployed service can assertsupabase_configured: true.It also proves nothing about ingestion behaviour. It never runs an upload, never touches Supabase, never embeds anything.
The vacuous-green defect
The first version of this workflow (
64e4e80) reported theingestion-imagejob green in 38 seconds with its import step emitting zero bytes of stdout — noROUTES:, noIMPORT OK, noIMPORT FAILED. Run 30218194135.Cause, proven by direct experiment rather than inferred.
python -reads its program from stdin, anddocker runwithout-idoes not attach stdin to the container. Python read EOF, executed an empty program, exited 0, and the step passed having asserted nothing:A workflow written to end vacuous green checks shipped a vacuous green check.
Severity stated accurately, not dramatically. The job was not proving nothing about the import — the boot probe transitively proves the module imports, since uvicorn cannot serve
/healthwithout importingmain:app. What the dead step actually cost was the route assertions, theROUTES:evidence line, and the network-vs-ImportErrordiagnostic banner.Two fixes (
ac14b46), and the second is the one that matters:-i, so the heredoc reaches the interpreter.teeand make a trailinggrep -q "IMPORT OK"the thing that actually fails the step. GitHub runs steps withbash -eand NOT-o pipefail(shell: /usr/bin/bash -e {0}), soteewould otherwise mask a non-zerodocker run.Both paths were tested locally under
bash -ebefore pushing — with-ithe step exits 0 and printsIMPORT OK; with-iremoved the step now exits 1, so the regression cannot silently return. A comment at the step records why-iis load-bearing so a future reader cannot innocently remove it.The generalised rule: a check step must assert on its own OUTPUT, not merely on its exit code. Register #52 now carries it.
Audit of the other four steps under the same lens, so the fix is not mistaken for a sweep:
Check out the repositoryBuild the ingestion imageCOPY requirements.txt/COPY main.pymake a wrong context or missing file fail loudly. Nothing further to assert on.Import the module inside the imageBoot the container and probe /healthgrep -q '"ok":true'), in anifbody wherebash -eapplies.Container logs (always)|| trueby design, claims nothing.The import step was the only exposed one. No other fix was invented.
Runtime: measured, and my estimate was wrong by an order of magnitude
Measured: 38 seconds (build ~24 s). Projected before the run: 5-9 minutes. The projection is recorded rather than deleted so the next estimate carries a known error bar. It was wrong because every dependency in
markitdown[all]resolves to a prebuilt manylinux wheel and GitHub's path to PyPI is very fast — nothing compiles from source.Two riders on that number:
main'srequirements.txt, which does not yet containsupabase==2.29.0. PR A's run will be slower — though on this evidence nowhere near the original projection.timeout-minutes: 20now carries far more headroom than intended. Left as-is: a generous ceiling costs nothing, and a hung third-party pull still fails loudly instead of burning the 6-hour default.V11 is satisfied only by a green run on
mainof the CORRECTED workflow. The 38 s figure came from a run whose central assertion did not execute; it pins the runtime and nothing else.Deliberately NOT a required check
This is not added to branch protection, and that is intentional.
Three third-party network dependencies sit in this job's hot path — Docker Hub (
python:3.11-slim), PyPI (markitdown[all]+ thesupabasetree), and Azure blob storage (tiktoken'scl100k_base, see #53).db-typesnow has one, digest-pinned.Register #43 is precisely this class. It redded
maintwice ondocker: toomanyrequestswith no commit to this repo, and its residual (1) records that Docker Hub is also anonymous-rate-limited per IP with GitHub runners sharing NAT pools. #43's governing quotes apply unchanged:Promotion rule, binding, recorded in register #52: required-check status is considered only after 20 consecutive green runs on
mainwith zero re-runs. Any red caused by a third party's network resets the counter to zero, and weakens rather than strengthens the case. Never being promoted is an acceptable outcome. Mechanically it also cannot be required until a green run onmainexists (typecheck.yml:11-12).Register rows: why two new ones, and why #51 is skipped
tiktokencold-start network dependency) is its own row because it is a production dependency, not a CI one, and it is pre-existing onmain. Recording it under a (b1) row would misattribute it as a (b1) regression. Verified in the vendored package:tiktoken_ext/openai_public.py:77resolvescl100k_baseto anopenaipublic.blob.core.windows.netURL, andtiktoken/load.py:36read_file_cachedis cache-first — a fresh container's cache is empty, so the fetch is real on every cold start. No fix attempted here; both candidates change the production image.Other design notes
services/ingestion/DockerfilesaysFROM python:3.11-slim. Pinning in CI but not in the Dockerfile would make CI green on an image production never builds — a silent false-green, worse than the flakiness it avoids. If the base should be pinned, pin it in the Dockerfile so both paths move together.paths:filter, deliberately. A path-filtered job never reports a status on PRs it skips; if this is ever promoted to required, a rule demanding a context nothing publishes blocks every unrelated PR indefinitely.INGESTION_TOKEN.permissions: contents: readis the entire grant; the job cannot reach, read, or mutate production.Known forward reference
The workflow's comment header cites
docs/b1-verification-protocol.md, which does not exist yet — PR A creates it. Accurate for the sequence, but a dangling path for the A0-to-A window. Flagged rather than silently left.Logged for later, deliberately out of scope here
actions/checkout's post-job cleanup emitsfatal: No url found for submodule path 'profile-readme' in .gitmodulesand agit failed with exit code 128warning. Non-fatal, does not affect any result, and near-certainly pre-existing. Not investigated in this PR, including whethertscanddb-typesshow it too.Checks
tscanddb-typeswill pass — vacuously, since this PR contains zero TypeScript and zero SQL. That is the entire point of register #52. The only meaningful signal here isingestion-image, and it is meaningful only now that its import step actually executes.Deliberately not in this PR
docs/b1-verification-protocol.md(PR A) · any change toservices/ingestion/· any change tosrc/· any branch-protection change · any dashboard change · closing PR #70.🤖 Generated with Claude Code