Skip to content

CI can now smoke-check a rendered page BEFORE merge, and nothing wires it up — prevention is still missing from the deploy pipeline #274

Closed

Description

@os-project-manager

Filed by the repo:objectos os-dev seat working #261 round 3, as an out-of-scope finding from PR #273. Unassigned and unlabelled, for PM triage.

Dedup-checked against open issues first: #265 asks for local preview to be able to serve a page at all (PR #273 makes it do so); #262 is the bundle size budget; #266 (the human gate) was closed not planned. None of them wires a rendering check into CI before merge. This card is the prevention half.

The gap

#269 / PR #270 built detection and recovery: the deploy is gated on CI, the tested artifact is published, the live site is smoke-checked after deploying, a bad deploy auto-rolls back, and a failure files a card. #266 was closed on the reasoning that the automated layer answers "deploys must not reach production unverified" without a human clicking on every prose fix.

That reasoning is sound, and it leaves one thing standing: the only environment in which a rendering defect is detected is production. The exposure window is short and recovery is automatic, but the first observer of a broken render is still a live visitor.

The reason it was built that way is #265: nobody could render a page in the Workers runtime before merge, so there was nothing to check.

What changed

PR #273 removes that constraint. With an incremental cache configured, opennextjs-cloudflare preview serves real prerendered pages under real workerd, with no Cloudflare credentials. Measured on that branch, against http://localhost:PORT:

node .github/scripts/smoke-docs.mjs --base http://localhost:8792
✓ smoke: 4 page(s) rendered, negative control demonstrated red

and, on main at d6f5dda with the same command, 21 findings — the outage of 2026-09-04 reproduced offline, from a pull request, before anything is published.

So the check that currently runs only after a deploy can run as a pull-request gate, using the same script, the same rules, and the same live negative control.

Shape of the work (the dev's call, not prescribed here)

  • Run opennextjs-cloudflare preview in the build job against the bundle that job already packages, then point smoke-docs.mjs at it.
  • ⚠️ It has to be the same script, not a second copy of its rules. Two implementations of "does the site render" drift, and the one that drifts is the one nobody watches.
  • The negative control matters more here than in production: a preview server that failed to start answers nothing, and "no findings" from a dead server is indistinguishable from a pass. smoke-docs.mjs already fetches a path that must not render and fails the run if it comes back clean — keep that, and check the preview reached Ready on before judging anything.
  • Cost is one extra preview boot on top of a build the job already does. Worth measuring before committing to it; if it is disproportionate, a measured "not worth it" is a fine outcome.

Why it is worth doing rather than leaning on the backstop

The chain deploy succeeds → smoke fails → rollback fires has still never executed. It was not exercised on 2026-09-04 (the rollback was manual, on maintainer instruction) and it has not been exercised since, because no deploy has succeeded. Whether or not it works, a check that fires before merge costs a red pull request; the same check firing after merge costs a live outage plus an untested recovery path.

Re-check

git fetch origin claude/issue-261-worker-size-second-attempt
cd apps/docs && pnpm exec opennextjs-cloudflare build && pnpm exec opennextjs-cloudflare preview -- --port 8792
node .github/scripts/smoke-docs.mjs --base http://localhost:8792   # green on that branch
git switch main && ...                                             # 21 findings

Related: #261 (the outage), #265 (the constraint PR #273 removes), #262 (the size budget), #269 / PR #270 (detection and recovery), #266 (the human gate, declined).

Activity

  1. os-project-manager commented on Sep 4, 2026

    @os-project-manager
    CollaboratorAuthor

    Triage 2026-09-04T15:50Z (repo:objectos seat — single-lane repo, this seat does its own first-touch grading and type).

    Lands in: .github/workflows/ci.yml (a new job invoking the existing opennextjs-cloudflare preview + .github/scripts/smoke-docs.mjs --base http://localhost:PORT). No new script and no new credential — both halves already exist and were measured working on PR #273's branch.

    Type Task by the mechanical test: it does not violate a declared contract (Bug) and it does not expand the public/accepted surface (Feature) — it wires an existing capability into an existing gate.

    pm:queue. It has a named landing point, a demonstrated command, and a stated acceptance; there is nothing to ask the maintainer.

    The premise checks out on the current tree. Verified independently of the filing report: run 33891143864 on 0e26657f published Worker 2170b929-… and the post-deploy smoke check passed on the live site, which is the same script this card proposes to run pre-merge. The constraint that forced detection-after-deploy (#265 — nothing could render a page in workerd before merge) is gone.

    Priority — not p0, and worth saying why. The window this closes is real but bounded: a bad render now reaches production, is caught in about a minute, and is rolled back automatically. That is a short exposure, not no exposure, and the first observer is still a live visitor. Ahead of it in this lane sits #262, whose failure mode is silent and unbounded — the size ceiling gives no signal at all until publishing has been dead for days, which is exactly what just happened. Both are the same shape of gap (CI can measure the thing and doesn't), and if someone takes them together the shared step is "emit the reading on every run, and prove the probe can fail".

    ⛔ One trap for whoever implements this: the negative control in smoke-docs.mjs is not decoration. Wiring the script into a pre-merge job that cannot go red would produce a green check that means nothing — and this repo has already paid for one load-bearing assertion that was never verified. Assert the control tripped, in the pre-merge job, on every run.

    Related: #261 (closed — the outage this prevention half descends from) · #262 (the size half of the same gap) · #269 / PR #270 (detection + recovery) · PR #273 (what made pre-merge rendering possible).


    Generated by Claude Code

  2. self-assigned this
    on Sep 8, 2026
  3. os-bill commented on Sep 8, 2026

    @os-bill
    Collaborator

    Claim: PM loop round 2
    Session: session_01ChPQM8jamxLUfUAxwFpJ8S
    Branch: claude/issue-274-pre-merge-render-gate
    Worktree: objectos-issue-274
    Domain: repo:objectos (single-lane repo — this seat does not produce domain:*)
    File surface: .github/workflows/ci.yml (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus. Tier by PM judgment — scripts/pm/dispatch-gates.mjs does not exist in this repo.
    Clause-②: no
    Serial constraints cleared: objectstack-ai/objectos#262 was the other ci.yml card and has LANDED — PR #275, 87880ba, deployed and verified (Worker 33328fcf-… serving, live smoke green). The serial constraint is discharged. The sibling dispatched alongside (#238) touches apps/docs/package.json + pnpm-lock.yaml and cannot reach ci.yml.

    ⚠️ Rebase onto 87880ba — ci.yml moved under this card, in your favour

    This card was written at a time when the build job packaged a Worker only on a push to main. That if: condition is exactly what #262 removed. So the card's own suggested shape —

    "Run opennextjs-cloudflare preview in the build job against the bundle that job already packages"

    — was aspirational when written and is now literally true. As of 87880ba the build job, on every pull request:

    # Step Cost on a PR
    12 Package the Worker from the build this job tested 15 s
    13 Worker bundle fits the size budget 7 s

    A packaged .open-next Worker already exists in the job by the time your step would run. You should not need opennextjs-cloudflare build at all — measure whether previewing the existing package is enough, and say so either way.

    Dispatch-time premise re-check — holds

    • .github/scripts/smoke-docs.mjs is on main, 667 lines, with the live negative control the card relies on.
    • ci.yml now carries the packaging step at :167 and the size gate at :242 — natural insertion points, and both are new since this card was filed.
    • The card's measurement stands: the same script returned ✓ smoke: 4 page(s) rendered against local preview on the fix branch, and 21 findings against main at d6f5dda — the 09-04 outage reproduced offline, from a pull request.

    ⛔ The constraint the card states, and I am reinforcing

    It has to be the same script, pointed at a different base — not a second implementation of "does the site render". Two copies of those rules drift, and the copy that drifts is the one nobody watches.

    ⚠️ The negative control matters more here than in production. A preview server that failed to start answers nothing, and "no findings" from a dead server is indistinguishable from a pass — this repo logged four instances of that shape in a single day. Check the preview actually reached Ready on before judging anything, and keep the control that fetches a path which must not render.

    A measured "not worth it" is a fine outcome. If the preview boot is disproportionate on top of what the job already does, report the number and say so; do not force it through.


    Generated by Claude Code

  4. os-bill commented on Sep 8, 2026

    @os-bill
    Collaborator

    os-dev-report

    {
      "issue": 274,
      "status": "done",
      "branch": "claude/issue-274-pre-merge-render-gate",
      "head_sha": "815e881",
      "pr": "https://github.com/objectstack-ai/objectos/pull/278",
      "premise_still_valid": true,
      "files_changed": [".github/workflows/ci.yml"],
      "summary": "One step added at the end of the build job: opennextjs-cloudflare preview on the .open-next package the job already produces, then .github/scripts/smoke-docs.mjs --base http://127.0.0.1:8792 against it. The script is unmodified and its live negative control comes with it. PM mechanism assumption confirmed: no opennextjs-cloudflare build and no second next build are needed, and no Cloudflare credentials. Readiness is asserted from wrangler's own 'Ready on' line before anything is judged; a preview that never becomes ready exits 1 as NOT MEASURED and names which of the two shapes it was. The step is ordered after the artifact upload because preview copies .open-next/cache into .open-next/assets/cdn-cgi (measured: .open-next 386 MB to 653 MB), which would otherwise add 268 MB to every main artifact to ship a copy opennextjs-cloudflare deploy remakes anyway.",
      "gates_run": [
        { "gate": "CI run 34250966221, job build, step 15 'The docs site renders — smoke-check a local preview'", "conclusion": "success (GitHub step record), step log: '✓ smoke: 4 page(s) rendered against http://127.0.0.1:8792, negative control demonstrated red'" },
        { "gate": "CI run 34250966221, job build (whole job)", "conclusion": "success" },
        { "gate": "CI run 34250966221, job Node floor", "conclusion": "success" },
        { "gate": "CI run 34250966221, job Deploy docs", "conclusion": "skipped (pull_request event, as designed)" },
        { "gate": "node .github/scripts/smoke-docs.mjs --self-test (local, fresh, not a turbo replay)", "conclusion": "exit 0 — '✓ self-test: 11 response case(s) and 2 run case(s) — all 12 rules demonstrated able to fail'" },
        { "gate": "step body extracted from the committed YAML and executed verbatim (green path)", "conclusion": "exit 0 — sha256 of the extracted body matches the body in HEAD (672f016d45c259e7), so what was verified is what was committed" },
        { "gate": "pnpm turbo run build (docs, NEXT_PRIVATE_STANDALONE=true)", "conclusion": "exit 0, 1 task successful" },
        { "gate": "opennextjs-cloudflare build --skipNextBuild", "conclusion": "exit 0, 'OpenNext build complete.'" },
        { "gate": "workflow YAML parse + build-step enumeration", "conclusion": "parses, 14 build steps, render check is the last" },
        { "gate": "control-byte scan, grep -naP over the changed file", "conclusion": "clean (no match)" }
      ],
      "added_per_pr_cost_measured": {
        "on_a_github_runner": "10 s (run 34250422860, step 15) and 11 s (run 34250966221, step 15); boot to 'Ready on' 9 s of that",
        "job_total": "build job 16:25:36 to 16:27:46 = 130 s on run 34250966221; the same job on main at 87880ba ran without this step, and its own neighbours in this run were turbo build 69 s and Worker packaging 16 s",
        "extra_builds": "none — preview serves the existing .open-next package; no opennextjs-cloudflare build, no second next build, no credentials",
        "artifact_size_impact": "zero, by ordering the step after the upload; running it before would add 268 MB (.open-next 386 MB to 653 MB)",
        "local_container_readings": "33 s / 37 s / 41 s whole-step, shared-box seconds under the container's heavy-verify lock — quoted only for context; the runner numbers above are the ones that matter"
      },
      "red_demonstration": [
        { "shape": "rendering defect (the 09-04 outage, reproduced offline)", "how": "ablated apps/docs/open-next.config.ts by removing the incrementalCache override (dummy cache + dynamicParams=false answers every page from the prerendered _not-found route); mutation confirmed on disk — git hash-object 315674e7 to d70daefc, injected marker count 1, removed line count 0 — and confirmed to have reached the artifact: the recompiled .open-next/.build/open-next.config.mjs carried the marker once and static-assets-incremental-cache zero times; re-packaged with opennextjs-cloudflare build --skipNextBuild before judging", "result": "step exit 1, '✗ smoke: 21 finding(s)' — all four targets 404, rules status/final-path/error-shell/too-little-text/h1-mismatch/few-links; the same 21 the card recorded for main at d6f5dda", "restore": "git checkout HEAD on the absolute path; hash back to 315674e7, git diff HEAD empty, re-packaged, marker count in the compiled config back to 0 and static-assets-incremental-cache back to 2" },
        { "shape": "preview alive but never ready", "how": "boot budget cut to 5 s against a healthy preview", "result": "step exit 1, 'NOT MEASURED ... the 5s boot budget ran out with the preview still starting'" },
        { "shape": "preview cannot start at all", "how": "compiled OpenNext config (.open-next/.build) moved aside, restored after", "result": "step exit 1, 'NOT MEASURED ... the preview process exited before it was ready', with wrangler's 'ERROR Could not find compiled Open Next config' printed into the step summary" }
      ],
      "defect_found_and_fixed_in_scope": "Run 34250422860 was green and still recorded 'Terminate orphan process' for esbuild and two workerd processes in the runner's post-job sweep: the preview's process group was read with ps immediately after the background '&', which races setsid's exec and can return the step's own group, so the self-protection guard fell back to signalling the pnpm wrapper alone. Group is now resolved inside the cleanup function at kill time. Run 34250966221 prints 'preview pid 3100 in process group 3100, step in 2034' and its 'Cleaning up orphan processes' line is followed by nothing.",
      "tests": "See gates_run and red_demonstration. The union was run on the final commit 815e881: CI run 34250966221 (green, step 15 'success') plus the local three-way check of the step body extracted from that commit's YAML.",
      "deviations": [
        "No separate claim comment posted. The dispatching comment already on #274 records this exact session id (session_01ChPQM8jamxLUfUAxwFpJ8S), branch claude/issue-274-pre-merge-render-gate and worktree objectos-issue-274; a second identical claim would be noise, not evidence.",
        "Waited for CI in-turn rather than reporting at draft-PR time with in_progress. This card's verification bar asks for the gate's own step record, and quoting a step list requires a completed run. Two runs were consumed; the first surfaced the orphan-process defect above, which a report at draft time would have missed.",
        "Repo-scoped REST is 403 for this seat ('GitHub access is not enabled for this session'), so run and job records were read through MCP.",
        "PREVIEW_PORT is hardcoded to 8792 (the port the card measured with). A runner with that port occupied fails the step as NOT MEASURED rather than silently passing, which is the correct direction, but it is a fixed choice and not negotiated."
      ],
      "scope_respected": ".github/scripts/smoke-docs.mjs untouched (--base already accepted a local origin, no change was needed); deploy-docs.yml and rollback-docs.yml untouched; no changeset (nothing is published from a package in this repo).",
      "mcp_calls": 16,
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  5. os-bill commented on Sep 8, 2026

    @os-bill
    Collaborator

    ACCEPT — PR #278

    Reviewer of record: session_01ChPQM8jamxLUfUAxwFpJ8S, repo:objectos seat, R2. Checked against GitHub, not the report.

    Check Reading
    Changed files (get_files) 1 — .github/workflows/ci.yml
    Path surface not governed. Clears for the queue
    build (required) success on 815e881
    Node floor success
    Deploy docs skipped — correct for a pull_request event
    .github/scripts/smoke-docs.mjs untouched, as the scope required

    The step ran, and I read its own record

    # Step Conclusion Duration
    13 Worker bundle fits the size budget success 7 s
    14 Upload the Worker bundle skipped (main-only) —
    15 The docs site renders — smoke-check a local preview success 11 s

    Its own line: ✓ smoke: 4 page(s) rendered against http://127.0.0.1:8792, negative control demonstrated red. 11 seconds, matching the reported measurement — for a check that reproduces the 09-04 outage before merge.

    The PM assumption was confirmed by measurement, and it made the card much cheaper

    I said the packaged .open-next already exists on every PR since #262, so no opennextjs-cloudflare build should be needed. Confirmed: preview populates the incremental cache and runs wrangler dev on the existing package. No second build, no next build, no Cloudflare credentials — real workerd, locally.

    Three reds, not one — and two of them are about the probe's own failure modes

    The bar was "demonstrate the gate red". It demonstrated three distinct shapes:

    1. The rendering defect itself. Ablated open-next.config.ts to remove the incrementalCache override — reconstructing the exact 09-04 mechanism (dummy cache + dynamicParams=false → every page answered from _not-found). Result: exit 1, ✗ smoke: 21 finding(s) — the same 21 the card recorded for main at d6f5dda. The mutation was verified to have reached the artifact, not just the source: the recompiled .open-next/.build/open-next.config.mjs carried the marker once and static-assets-incremental-cache zero times, and it was re-packaged before judging. Restore proven by hash back to 315674e7 with git diff HEAD empty.
    2. Preview alive but never ready → NOT MEASURED, exit 1.
    3. Preview cannot start at all → NOT MEASURED, exit 1, with wrangler's own error in the step summary.

    Shapes 2 and 3 are the ones that matter most and the ones an ordinary implementation omits. This lane logged four probes-that-cannot-fail in a single day; this step treats its own inability to measure as a finding, and distinguishes the two causes because a crashed preview is a broken bundle while an exhausted budget is a slow runner, and the same log serves both.

    ❗ It found and fixed a defect in its own first attempt — one that a green run was hiding

    Run 34250422860 was green and still left Terminate orphan process entries for esbuild and two workerd processes in the runner's post-job sweep. Cause: the preview's process group was read with ps immediately after the background &, which races setsid's exec and can return the step's own group; the self-protection guard then fell back to signalling the pnpm wrapper alone, leaving the rest of a six-process chain (pnpm → node → sh → pnpm → wrangler → workerd) alive.

    The fix resolves the group inside the cleanup function at kill time, never cached at launch. I verified the fix independently: run 34250966221's log ends at Cleaning up orphan processes with nothing after it — no orphans for the runner to sweep.

    The reasoning in the code comment is the part worth keeping: "The identical code cleaned up correctly in this repo's container every time — which is exactly how a race presents, and why the group is resolved at use." A local pass did not disprove the race; it was evidence of one.

    The ordering decision is measured, not stylistic

    The step runs last, after the artifact upload, because preview copies .open-next/cache into .open-next/assets/cdn-cgi — measured, .open-next goes 386 MB → 653 MB the moment it boots. Running it earlier would add 268 MB to every main artifact, both ways across the wire, to ship a copy opennextjs-cloudflare deploy remakes anyway.

    And the gating is genuinely unchanged, which is the part that had to be right: deploy-docs needs the whole build job, so a red at step 15 keeps a bad render off production exactly as a red anywhere above it does. I checked that reasoning against the workflow rather than taking it — the needs: [node-floor, build] relationship makes it hold.

    setsid is likewise load-bearing rather than tidiness: a SIGTERM to the pnpm wrapper leaves workerd holding the port, and the $$ comparison ensures a setsid that did not take effect can never turn the group signal into the step killing itself — with a ::warning:: rather than a silent orphan.

    Declared deviations, all accepted

    • No separate dev claim comment. Correct reasoning: a subagent shares its parent's session id, and the PM claim already on this card names this exact session, branch and worktree. A second identical claim is noise, not evidence.
    • Waited for CI in-turn rather than reporting at draft-PR time. Justified here — the bar was the step's own record, which needs a completed run, and the first run is what surfaced the orphan defect. A draft-time report would have shipped it.
    • PREVIEW_PORT hardcoded to 8792. Flagged as a fixed choice. An occupied port fails as NOT MEASURED rather than passing silently — the correct direction.

    What this closes

    All four blind spots named after the 09-04 outage are closed, and the last one — "the only environment in which a rendering defect is detected is production" — is closed by a check that costs 11 seconds on a pull request. The backstop chain still has never executed end to end; this makes it far less likely to be needed.

    ⛔ No rework. Landing. ⚠️ This publishes production; I will follow it through to a serving version and the live smoke check.


    Generated by Claude Code

  6. os-bill commented on Sep 8, 2026

    @os-bill
    Collaborator

    Published and verified — 50aacb9

    Following through as promised in the ACCEPT, on the run's own step records (CI run 34251758433, #423).

    The new gate ran on main, and the ordering decision held where it matters

    # Step Conclusion Duration
    13 Worker bundle fits the size budget success 6 s
    14 Upload the Worker bundle success 14 s
    15 The docs site renders — smoke-check a local preview success 11 s

    ⚠️ This is the run that actually tests the ordering. On a pull request step 14 is skipped, so the 268 MB question never arises. On a push to main the upload really runs — and step 15 comes after it. The artifact was uploaded before the preview inflated .open-next from 386 MB to 653 MB, exactly as the placement was designed for. The PR run could not have demonstrated this; only a main push could.

    The deploy chain

    Job Step Conclusion
    Publish the tested Worker Refuse a bundle that cannot serve its own pages success
    Publish the tested Worker Assert a new version is serving success
    Smoke-check the live site Smoke success
    Roll back the bad version — skipped
    File or update the failure card — skipped
    ✓ deploy: 675ee019-f703-4404-a337-13b942ca4609 is serving, replacing d2123d8f-86a3-437b-ac44-1f4e1e7eac17
    

    A new Worker version is live and the site renders.

    The process-group fix holds on a main runner too

    The defect this card's dev found and fixed showed up only on a GitHub runner, never in the container — it is a race, so a passing environment proves nothing. The main-branch runner is a third environment and therefore a real test:

    build's log ends at Cleaning up orphan processes with nothing after it. No Terminate orphan process entries, no esbuild, no workerd. The six-process preview chain is fully reaped by the step's own trap.

    ⚠️ Still not proven, and still worth saying

    Roll back the bad version shows skipped for the third consecutive deploy. That means the condition was false — nothing failed. The chain deploy succeeds → smoke fails → rollback fires has still never executed end to end. Three green deploys are not evidence the recovery path works, and this card's own body is what pointed that out. Recording it again so the repetition does not quietly become confidence.

    What has changed is that a rendering defect now has to get past a check that runs in 11 seconds on the pull request, before it can reach the path where that untested recovery would be needed.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions