Skip to content

ci(docs): gate the deploy on CI, publish what CI tested, verify the site - #270

Merged
os-project-manager merged 4 commits into
mainfrom
claude/issue-269-deploy-verification-layer
Sep 4, 2026
Merged

ci(docs): gate the deploy on CI, publish what CI tested, verify the site#270
os-project-manager merged 4 commits into
mainfrom
claude/issue-269-deploy-verification-layer

Conversation

@os-project-manager

@os-project-manager os-project-manager commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

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.yml fires a deploy — splitting them would fire it repeatedly for no benefit.

What changed

Defect Fix
1. The deploy did not wait for CI deploy-docs.yml loses its push: trigger and becomes a reusable workflow. ci.yml calls it as a job with needs: [node-floor, build] and if: restricted to a push on main.
2. What deployed was a second, untested build The build job packages the Worker from the .next its own gates measured (opennextjs-cloudflare build --skipNextBuild) and uploads it. The deploy job downloads that bundle; opennextjs-cloudflare deploy does not build.
3. Nothing checked the site .github/scripts/smoke-docs.mjs — four pages, twelve structural rules, plus a live negative control on every run.
4. A failed deploy was silent .github/scripts/check-deploy-version.mjs decides whether a deploy published anything; failure dispatches rollback-docs.yml and files or updates ONE labelled issue.

Why needs: and not workflow_run. workflow_run fires 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 under apps/docs/** or content/docs/**:

  • tools/ci-scripts/run-self-tests.mjs — this repo already fails the build job for any script under .github/scripts/ that declares --self-test and is not listed in SELF_TESTED. Registering the two new scripts is required, not optional.
  • turbo.json — one line, "env": ["NEXT_PRIVATE_STANDALONE"] on the build task. See below.

The packaging step, and what it took to make it work

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. Measured, not guessed — CI run 33879772449 on this branch failed with exactly that ENOENT.

The cause is that output: 'standalone' is not in next.config.mjs; @opennextjs/aws sets NEXT_PRIVATE_STANDALONE=true itself before running the Next build, with the source 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 rather than editing apps/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 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 — otherwise a .next cached 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, artifact docs-worker 105,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 h1 matching what the page is called, 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 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:

✓ /                            http 200  final /docs   h1="ObjectOS"    107138 B  4639 chars  21 links
✓ /en/docs                     http 200  final /docs   h1="ObjectOS"    107138 B  4639 chars  21 links
✓ /docs/quickstart             http 200                h1="Quickstart"  172285 B  9101 chars  14 links
✓ /docs/build/interface/views  http 200                h1="Views"       257319 B  7771 chars  22 links
✓ (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]
✓ smoke: 4 page(s) rendered, negative control demonstrated red

Red against the same live site — run 33879892308, --paths /robots.txt,/docs/build/data-model:

✗ /robots.txt   http 200  final /robots.txt  text/plain   117 B  115 visible chars  0 links
    [not-html] [too-little-text] [no-title] [no-h1] [few-links] [lang-mismatch]
✗ /docs/build/data-model   http 200  final /docs/build/data   h1="Data Model"
    [final-path] landed on /docs/build/data, expected /docs/build/data-model
✗ smoke: 7 finding(s)

/robots.txt is the important half: a live 200 that is not a page. And final-path caught a silent 308 through next.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-passed fires 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:

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 the HEAD blob hash:

=== ablation 1: smoke-docs.mjs, disable the too-little-text rule ===
  on disk: removed-text 1 -> 0, injected-text 0 -> 1      self-test exit: 1
✗ a 200 empty shell  fired [few-links no-h1]  expected [few-links no-h1 too-little-text]
  restored: worktree blob == HEAD blob (4a116f517ca4ec85a78f051135b1ababa4c9cd22)

=== ablation 2: check-deploy-version.mjs, accept a log with no version id ===
  on disk: removed-text 1 -> 0, injected-text 0 -> 1      self-test exit: 1
  restored: worktree blob == HEAD blob (e441d7417db0b55f68d87288461dc7b612acbd63)

Do not read the deploy's exit code

check-deploy-version.mjs requires a new, well-formed version id that was not already serving and that a post-deploy wrangler deployments status --json reading agrees is serving now. The deploy step runs under set +e on 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 a6e4ce4 carries 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:

run ref why it is red
33879892308 branch deliberate --paths /robots.txt,/docs/build/data-model
33880330645 branch deliberate --paths /robots.txt + file_issue: true, to file #271
33880621663 throwaway branch the same again, to prove the update-not-duplicate branch
33879772449 branch the real ENOENT finding, fixed in 355f798

claude/issue-269-plumbing-probe is 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

⚠️ Merging this fires a deploy and that deploy will fail. main builds 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 to main immediately rather than only once #261 is closed. The failure will file one deploy-failure card, 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.mjs runner (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; actionlint 2.0.6 (the WASM build from npm) reporting 0 findings on ci.yml and deploy-docs.yml — demonstrably not vacuous, since it flagged an inputs.artifact_name typing problem in an earlier revision of this branch and still reports a pre-existing vars finding in two workflows this PR does not touch.

Only by CI actually running it: the deploy job's Cloudflare steps, which need the credential and which cannot succeed at all while #261 is open. Everything else in deploy-docs.yml was exercised for real, in CI, on this branch — the run ids are above.

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

Copy link
Copy Markdown
Collaborator Author

Review in progress — two red checks on e7e7258, and they are not the same kind of thing. repo:objectos seat (objectstack#9831), 2026-09-04T13:50Z. Patch round sent to the dev; recording the diagnosis here so neither gets "fixed" wrongly.

🟢 Smoke-check the live site — ⛔ THIS RED IS DELIBERATE. DO NOT MAKE IT GREEN.

Run 33879892308 is a manual dispatch with an ad-hoc path list, run to demonstrate the check can fail. Its first line says so:

##[warning]running against an ad-hoc path list: /robots.txt,/docs/build/data-model
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
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.

The deploy pipeline has no verification layer — it does not wait for CI, never checks the site, and fails silently

2 participants