ci(docs): gate the deploy on CI, publish what CI tested, verify the site - #270
Conversation
The deploy pipeline had no verification layer. Four defects, all in the same two files, fixed together because each edit to `deploy-docs.yml` fires a deploy and splitting them would fire it repeatedly for no benefit. 1. `deploy-docs.yml` hung off `push: branches: [main]` exactly as `ci.yml` does, so the two ran in parallel and a commit that failed any gate still deployed. It is now a reusable workflow called from `ci.yml` as a job with `needs: [node-floor, build]`, restricted by `if:` to a push on `main`. `workflow_run` was the alternative and is not used: it fires on a failed run too, and it runs detached from the run whose artifact it publishes. 2. What deployed was a second, independent build — CI built the site, threw it away, and the deploy built again and published that. The `build` job now packages the Worker from the `.next` output its own gates measured (`opennextjs-cloudflare build --skipNextBuild`) and uploads it; the deploy job downloads that bundle and only uploads it. 3. Nothing checked the site after a deploy. `.github/scripts/smoke-docs.mjs` fetches `/`, `/en/docs` and two docs pages and asserts structure, not 200: an `h1` matching the page, visible prose above a floor measured after `script` and `style` are stripped, the sidebar's same-site links, the document language, and the path each request finally landed on. Every run also fetches a path that must NOT render and fails if it comes back clean, so a green carries a live demonstration that the check can go red. 4. A failed deploy was silent — 36 red runs over 10 days produced no card. `check-deploy-version.mjs` refuses to call a deploy successful without a new, well-formed version id that was not already serving and that the post-deploy reading agrees is serving; an unreadable reading is a finding, never a skip. A smoke failure dispatches `rollback-docs.yml`, and any failure files or updates ONE labelled issue. Both new scripts declare `--self-test` and are registered in `tools/ci-scripts/run-self-tests.mjs`, so every rule they enforce is proved able to fail on every pull request — which matters here more than usual, because their gate modes need a Cloudflare credential and a live site and cannot run for real on a PR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GkauAsZBEemRbco2rEX9Lx
…equests too The two packaging steps are gated to a push on `main`, which is the only event that deploys — so on this pull request they would not run, and the one part of this change that cannot be exercised locally would ship unexercised. This widens the `if:` to `pull_request` for exactly one CI run, to establish that `opennextjs-cloudflare build --skipNextBuild` accepts the `.next` output `pnpm turbo run build` produced and that the bundle uploads, and to measure it. Reverted in the following commit; the shipped steps differ from the ones this run exercises only in the event predicate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GkauAsZBEemRbco2rEX9Lx
|
Review in progress — two red checks on 🟢
|
| Target | Why it correctly goes red |
|---|---|
/robots.txt |
7 findings — not-html, too-little-text (115 visible chars against a floor of 500), no-title, no-h1, few-links, lang-mismatch |
/docs/build/data-model |
[final-path] landed on /docs/build/data — the page renders fine (h1="Data Model", 10926 visible chars) but the request ended on a different path. A silent redirect, caught |
This is the deliverable the card is accepted on, not a defect. The scheduled run on this PR — 33879778522 — is green against the real target list.
The final-path rule was not asked for and is the better idea: a page can render perfectly and still be the wrong page.
The negative control is the part that makes the greens worth reading
Every run, including the green one, also fetches a slug no page claims:
✓ (control, expected red) /docs/objectos-smoke-negative-control-269
http 404 lang=null h1=null
37962 B ← 8 visible chars ← 0 same-site links
control tripped [status too-little-text no-h1 few-links lang-mismatch]
37962 bytes, 8 visible characters. A byte-size floor passes that; measuring visible text after stripping script and style does not. That is precisely the shape of the 2026-09-04 outage — a 200 that renders nothing — and it means every green from this script carries a live red from the same code path, same host, same run.
🔴 build — a real blocker, sent back
missing: apps/docs/.next/standalone/apps/docs/.next/server/pages-manifest.json
opennextjs-cloudflare build --skipNextBuild wants a standalone Next output that pnpm turbo run build does not produce, so defect 2 (publish what CI tested) does not work as designed on this repo's build.
Two acceptable outcomes, chosen from measurement, not preference: make it work — declaring the breach if the answer lives in apps/docs/next.config.mjs, which is outside the declared surface — or take the fallback the card allows explicitly, keeping the needs: gating, filing defect 2 as its own card with the measurement, and saying plainly in this PR body that the published artifact is still not the tested one. ⛔ Not by weakening a gate.
Also sent back: the two paths that only ever run in an emergency
rollback dispatches rollback-docs.yml with GH_TOKEN: ${{ github.token }}. Whether GITHUB_TOKEN can actually trigger a workflow here is unverified, and GitHub suppresses token-driven workflow triggering in several cases. If that applies, the auto-rollback silently never fires — which is the exact failure class this card exists to remove, discovered during an outage with a broken site live.
It is cheap to prove: a blank version_id makes rollback-docs.yml list versions and change nothing. Same for the report job's issues: write path.
Today's lesson, applied to this PR: a code path that has never executed does not exist. Both of these run only when something is already wrong, so they get exercised before merge, not during the next incident.
Generated by Claude Code
…kaged `opennextjs-cloudflare build --skipNextBuild` reads `.next/standalone/apps/docs/.next/server/pages-manifest.json`, and a plain `next build` writes no `.next/standalone/` at all — so the packaging step added in the previous commit failed with ENOENT on that path. Measured, not guessed: CI run 33879772449 on this branch. The reason is that `output: 'standalone'` is not in `next.config.mjs`; `@opennextjs/aws` sets `NEXT_PRIVATE_STANDALONE=true` before it runs the Next build itself, with the comment "Equivalent to setting `output: \"standalone\"` in next.config.js". Skipping its build skipped that too. So the env var is set on the `build` job instead of editing `apps/docs/next.config.mjs`, which is outside this card's file surface and would change how the app builds for every consumer rather than only in CI. It must also be declared in `turbo.json`: turbo 2 runs tasks in strict env mode, so an undeclared variable never reaches `next build` — and declaring it is what puts it in the cache key, so a `.next` cached from before this change cannot be replayed without the standalone tree the packaging step needs. Also adds a TEMPORARY `verify-plumbing` job, removed before merge, that runs the rollback job's own `gh workflow run` command with the same GITHUB_TOKEN and `actions: write` and confirms from the API that a run was really created. That path only ever executes during an outage, and if GITHUB_TOKEN cannot dispatch a workflow here the automatic rollback would silently never fire. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GkauAsZBEemRbco2rEX9Lx
…oors from measurement Removes the two pieces that existed only to exercise paths a pull request cannot reach, both now measured: - the widened `if:` on the packaging steps. CI run 33880241443 on this branch packaged the Worker in 77 s and uploaded a 100.9 MiB artifact in 14 s, from the `.next` the same job's gates measured. The shipped steps differ from the ones that run only in the event predicate. - the `verify-plumbing` job. It proved GITHUB_TOKEN can dispatch a workflow here: run 33880259315 of `rollback-docs.yml` was created by `github-actions[bot]` and completed successfully, so the automatic rollback is not silently inert. The smoke floors are now set against readings rather than guesses. On the live site the four targets carried 4639-9101 visible characters and 14-22 same-site links; the 404 shell carried 8 characters and 0 links in 37962 bytes. The link floor moves 10 -> 8 for margin against the smallest real page, because a false red here dispatches a rollback; it is still an order of magnitude above the shell it has to catch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GkauAsZBEemRbco2rEX9Lx
Fixes #269
Refs #261 (the outage), #267 (the rollback workflow reused here), #266 (the human gate this keeps room for), #262 (the bundle budget, deliberately not here).
All four defects are fixed together. They live in the same two files, and every edit to
deploy-docs.ymlfires a deploy — splitting them would fire it repeatedly for no benefit.What changed
deploy-docs.ymlloses itspush:trigger and becomes a reusable workflow.ci.ymlcalls it as a job withneeds: [node-floor, build]andif:restricted to a push onmain.buildjob packages the Worker from the.nextits own gates measured (opennextjs-cloudflare build --skipNextBuild) and uploads it. The deploy job downloads that bundle;opennextjs-cloudflare deploydoes not build..github/scripts/smoke-docs.mjs— four pages, twelve structural rules, plus a live negative control on every run..github/scripts/check-deploy-version.mjsdecides whether a deploy published anything; failure dispatchesrollback-docs.ymland files or updates ONE labelled issue.Why
needs:and notworkflow_run.workflow_runfires on a failed run too, so the conclusion has to be re-checked by hand inside the workflow, and it runs detached from the run whose artifact it is supposed to publish — which would have made defect 2 unfixable without reaching across runs for the artifact.File surface: two declared breaches
The card scoped this to
.github/workflows/**plus a new script under.github/scripts/. Two files outside that were touched, both mechanically forced, neither underapps/docs/**orcontent/docs/**:tools/ci-scripts/run-self-tests.mjs— this repo already fails thebuildjob for any script under.github/scripts/that declares--self-testand is not listed inSELF_TESTED. Registering the two new scripts is required, not optional.turbo.json— one line,"env": ["NEXT_PRIVATE_STANDALONE"]on thebuildtask. See below.The packaging step, and what it took to make it work
opennextjs-cloudflare build --skipNextBuildreads.next/standalone/apps/docs/.next/server/pages-manifest.json, and a plainnext buildwrites no.next/standalone/at all. Measured, not guessed — CI run33879772449on this branch failed with exactly thatENOENT.The cause is that
output: 'standalone'is not innext.config.mjs;@opennextjs/awssetsNEXT_PRIVATE_STANDALONE=trueitself before running the Next build, with the source comment "Equivalent to settingoutput: \"standalone\"in next.config.js". Skipping its build skipped that too.So the env var is set on the
buildjob rather than editingapps/docs/next.config.mjs— that file is outside this card's surface and the setting would change how the app builds for every consumer, not only in CI. It also has to be declared inturbo.json: turbo 2 runs tasks in strict env mode, so an undeclared variable never reachesnext build, and declaring it is what puts it in the cache key — otherwise a.nextcached from before this change replays without the standalone tree and the packaging step fails on a green cache.Measured afterwards, CI run
33880241443: packaging 77 s, upload 14 s, artifactdocs-worker105,841,052 B (100.9 MiB) zipped, retained 3 days. Proportionate.A 200 is not the assertion
Status is one rule out of twelve. The ones carrying weight are structural: an
h1matching what the page is called, visible prose above a floor measured afterscriptandstyleare stripped, the sidebar's same-site links, the document language, and the path each request finally landed on after redirects.The live 404 shell is the argument for measuring prose rather than bytes: 37962 B carrying 8 visible characters. A byte floor would have passed it.
Both floors are set from readings in both directions. On the live site the four targets carried 4639–9101 visible characters and 14–22 same-site links; the shell carried 8 and 0. Floors: 500 characters, 8 links. The link floor was widened from 10 after the first live run measured 14 on the tightest page — a false red here dispatches a rollback, so the margin matters in both directions.
Expectations are deliberately generic and never a sentence out of a page body: the live site is pinned to a version 36 rejected deploys older than
main, so an expectation derived from the working tree would be a content-drift gate wearing a smoke check's name.Evidence, all of it measured
Green against the live site — run
33879778522:Red against the same live site — run
33879892308,--paths /robots.txt,/docs/build/data-model:/robots.txtis the important half: a live 200 that is not a page. Andfinal-pathcaught a silent 308 throughnext.config.mjs's redirect table on a page that renders perfectly.The negative control runs forever, on every deploy. If it ever comes back clean,
negative-control-passedfires and the run goes red — a green from this script always carries a red from the same code path, against the same host, in the same run.The emergency paths, which only ever run during an outage:
GITHUB_TOKENmay dispatch a workflow here was not assumed. A temporary job ran the rollback job's owngh workflow runcommand with the same token andactions: write: it createdrollback-docs.ymlrun33880259315, triggering actorgithub-actions[bot], conclusion success. Dispatched with a blankversion_id, so it listed versions and changed nothing.33880330645(smoke pointed at/robots.txt,file_issue: true) created The docs deploy pipeline is failing #271 with thedeploy-failurelabel. A second failure, run33880621663, commented on The docs deploy pipeline is failing #271 rather than opening The docs deploy pipeline is failing #272 — one card, updated. The docs deploy pipeline is failing #271 is closed with an explanation of what filed it.Fixtures and ablation. Both scripts assert that every rule they enforce has a fixture that trips it, and that runs on every PR via
pnpm turbo run test. Weakening one rule in each and re-running its self-test, with the mutation confirmed on disk by grep counts and the restore proved against theHEADblob hash:Do not read the deploy's exit code
check-deploy-version.mjsrequires a new, well-formed version id that was not already serving and that a post-deploywrangler deployments status --jsonreading agrees is serving now. The deploy step runs underset +eon purpose: its exit code is captured as evidence and handed to the gate, which is the only thing allowed to call the run a success. Every "could not read that" path is a finding, never a skip — an unreadable pre-deploy reading would otherwise leave nothing to disagree with, and therefore green.Checks on this PR
The head commit
a6e4ce4carries four scheduled checks and all are green or skipped. The red runs on this branch are dispatch-triggered demonstrations attached to superseded commits (e7e7258,355f798), enumerated here so the exception is written down rather than recognised by eye:33879892308--paths /robots.txt,/docs/build/data-model33880330645--paths /robots.txt+file_issue: true, to file #2713388062166333879772449ENOENTfinding, fixed in355f798claude/issue-269-plumbing-probeis a leftover throwaway branch — this container's push route refuses branch deletion (send-pack: unexpected disconnect, three attempts, identical), so it needs deleting by hand.Landing note
mainbuilds a Worker over Cloudflare's 64 MiB limit (#261), so the upload is rejected at version creation and the serving version cannot be displaced. That is the current deliberate steady state, not a regression from this wiring — and it is exactly why this lands first. The smoke job deliberately still runs when the deploy fails, so the live site starts being checked on every push tomainimmediately rather than only once #261 is closed. The failure will file onedeploy-failurecard, updated rather than duplicated. That is defect 4 working, not noise.Validated locally / only by CI
Locally: both self-tests plus the full
tools/ci-scripts/run-self-tests.mjsrunner (6 of 6, the four pre-existing ones included); the ablation above;check-half-states.mjs --self-test(1551 cases); a control-character scan over every touched file;actionlint2.0.6 (the WASM build from npm) reporting 0 findings onci.ymlanddeploy-docs.yml— demonstrably not vacuous, since it flagged aninputs.artifact_nametyping problem in an earlier revision of this branch and still reports a pre-existingvarsfinding in two workflows this PR does not touch.Only by CI actually running it: the
deployjob's Cloudflare steps, which need the credential and which cannot succeed at all while #261 is open. Everything else indeploy-docs.ymlwas exercised for real, in CI, on this branch — the run ids are above.