Skip to content

fix(ci): make scaffold-e2e's three boot-and-probe blocks assert on their own server - #9808

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-9779-scaffold-e2e-ports
Aug 19, 2026
Merged

fix(ci): make scaffold-e2e's three boot-and-probe blocks assert on their own server#9808
os-steve merged 2 commits into
mainfrom
claude/issue-9779-scaffold-e2e-ports

Conversation

@os-steve

@os-steveos-steve commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Fixes#9779

H1 first, because it decides the card: os start REFUSES. Measured.

The card's premise held. packages/cli/src/commands/serve.ts gates the port
auto-shift on flags.dev || NODE_ENV === 'development', and start.ts spawns
serve with neither — it forces NODE_ENV=production when the caller has not
set it, and the workflow sets no env: anywhere. Run on this checkout, holding
the port first (lsof/curl; ss and netstat are absent from the container):

$ os start --port 38200 # a neighbour already answering on 38200
✗ Port 38200 is already in use.
ObjectStack does not auto-select a different port in production mode:
a drifted port silently breaks reverse-proxy, OAuth callback, and CORS config.
$ echo $?
1
$ os start --port 38500 # control: nothing on 38500
✓ Server is ready → http://localhost:38500/
$ curl -fsS http://localhost:38500/api/v1/health
{"success":true,"data":{"status":"ok","timestamp":"...","version":"1.0.0","uptime":5.41}}

So this is a third failure mode, not either sibling's:

cardwhat the CLI does on a busy portwhat closes it
#9578gen-sdui-manifest.shvite auto-increments — my server came up elsewhere--strictPort + own-session liveness
#9647publish-smoke.shobjectstack dev auto-shifts — liveness was present, green and blindread the port it really bound
this oneos start refuses and dies — binds 8080 or nothingneither of the above

There is no shifted port to read back, and no --strictPort to ask for: the CLI
already behaves as though it had one. Copying either sibling here would have
produced machinery that pins nothing.

H2 — the liveness check is real, and measurably NOT sufficient on its own

Running the pre-fix block verbatim, a neighbour up first:

NEIGHBOUR_HEALTH=NEIGHBOUR-RUN-A
LOOP_OK=1 LOOP_ITERATIONS=1 LOOP_SECONDS=0
READY_ANSWERED_BY=NEIGHBOUR-RUN-A
OUR_PID_ALIVE=yes KILL_RC=0

The loop took its 200 on the first probe after 0s, asserted
/api/v1/ready against the neighbour's app, and kill "$SERVER_PID" succeeded
against our own os start — still booting, not yet dead. Exit 0, on an app the
job never started.

That last line is the part that falsifies the obvious repair. Through the entire
window that decides the run, our own process is genuinely alive, so adding
kill -0 "$SERVER_PID" to that loop changes nothing about this outcome. The
one question that separates the two worlds is whether something was already
answering the exact URL the loop accepts as proof.

Hence two guards per block, in this order, neither sufficient alone:

  1. a pre-flight refusing to boot when that URL already answers;
  2. kill -0 "$SERVER_PID" on every iteration — which is what the loop was
    missing for the case where our server dies for any reason at all (busy port,
    unreadable artifact, boot failure). Before: the full 60s, then only "server
    never became healthy". After: about 2s, naming the process and dumping its log.

Deliberately still open, and said out loud in the file: a neighbour arriving
after the pre-flight and before our own bind. Closing that needs an
affirmative "our server bound" signal — the runtime state file serve.ts
publishes under OS_HOME — and that is what to reach for if this workflow ever
moves onto a runner it shares. At today's grade it would be machinery pinning a
race that cannot occur.

H3 — runs-on is the fact that grades it, and it says GitHub-hosted only

Every runs-on: in the repo, all 26 workflows, is ubuntu-latest. No
self-hosted labels, no job-level container:, and the one reusable-workflow call
(release.yml to docker-publish.yml) lands on ubuntu-latest too. One fresh
VM per job, so no two jobs of this workflow — or of any other — can share 8080 or
18080.

So the ports stay fixed. Per-run ports would trade a readable literal for
machinery that buys nothing on the runner this actually runs on. What the guards
buy is the shared-namespace replay: a self-hosted runner, or a developer running
the block by hand in an agent dispatch container.

The docker leg is a different animal, and it has no wrong-answer mode

Both of its fixed things collide loudly at docker run time and neither
relocates: a duplicate --name e2e is refused by name, and a taken
-p 18080:8080 fails to bind rather than publishing elsewhere. Under the step's
bash -e either aborts before the loop is reached. So a docker run that
succeeded is proof that 18080 is ours — there is no silent-wrong-answer mode for
a per-run port or name to remove, and both stay.

Honest limit: that is docker's documented behaviour, not measured here — this
container ships the docker CLI with no daemon behind it
(dial unix /var/run/docker.sock: no such file or directory). It is recorded as
read, and deliberately not asserted in the test, because asserting it against our
own docker stub would only assert the stub.

What that leg did share with the other two is the missing question, so it gains
the container-liveness check: a container that started and then died at second 3
used to cost the full 60s and report "container never became healthy" instead of
"it is not running, here is why".

H4 — the workflow sweep, finished

.github/workflows/** swept whole, with each hit's runs-on:

filehitruns-onverdict
scaffold-e2e.yml--port 8080 x2, -p 18080:8080ubuntu-latestthis card
ci.yml5432:5432, 3306:3306 + the two 127.0.0.1 URLs that name themubuntu-latestGitHub service containers, allocated per job by the runner — not this shape, as the card already noted

Unqualified /tmp paths used as per-run state: zero across all 26 workflows.
Every one uses $RUNNER_TEMP or $GITHUB_WORKSPACE. No other workflow boots a
server and probes it — the only other boot-and-probe shapes in the repo live in
scripts/, and both were closed by the sibling PRs.

Tests — executed, not grepped

packages/create-objectstack/src/scaffold-e2e-boot-probe.test.ts extracts the
three real run: scripts out of the workflow and executes them under
bash -e (GitHub's default shell for run: on Linux), with npx and docker
replaced by stubs encoding the CLI behaviour measured above. A grep for kill -0
passes against a check placed after the loop's break; a grep for the pre-flight
passes against a version that mentions it in a comment.

The port literal — and only the port literal — is rewritten to a per-run free
port before each run, so this test cannot collide with a concurrent agent, which
would be a poor look in this file of all files.

Reverse verification, from the committed fix, with the workflow alone restored to
origin/main (git checkout origin/main -- .github/workflows/scaffold-e2e.yml):

 Tests 5 failed | 3 passed (8)
× refuses a neighbour already answering the URL its loop accepts as proof 316ms
AssertionError: expected +0 not to be +0 ← the pre-fix block exits 0
× fails fast, and says why, when the server it started exits 60280ms
expected '::error::server never became healthy…' to contain 'exited before becoming healthy'
× refuses a neighbour … (registry-canary) 310ms
× fails fast … (registry-canary) 60329ms
× stops polling once its own container has exited 60419ms
expected '20853\n::error::container never becam…' to contain 'no longer running'

The direction is the plain one — red — and the numbers are the card: exit 0 in
310ms
against the neighbour, and 60.3s to say nothing useful when our own
server died. The 3 that stay green are the vacuity guards (each block still
accepts the server it booted itself, and still prints "iam":"OURS" from
/api/v1/ready, proving the guard is not simply "always no"). Restored
afterwards; the tree is clean at the commit below.

Gates run locally, all re-run at e1a2514e2 (the final commit)

create-objectstack test 7 files, 81 tests passed
create-objectstack typecheck tsc --noEmit, clean
check:cross-package-test-inputs OK — 12 packages read outside themselves, all declared
check:nul-bytes rc=0 check:node-version rc=0
check:required-contexts rc=0 check:shard-attestation rc=0
check:workflow-status-functions rc=0 check:query-options-erasure rc=0
check:type-check-coverage rc=0 check:engine-double-contract rc=0
check:where-matcher rc=0 check:affected-docs 242 cases pass
check:type-check-debt OK — 33 ledger entries re-measured in 235.9s,
1926 raw tsc errors, none above its recorded number
(run on a fully built workspace, 70/70 turbo tasks)

Derived with node scripts/pm/dispatch-gates.mjs over the four changed paths
rather than recalled.

main moved under this branch mid-verification and conflicted in
scripts/check-cross-package-test-inputs.mjs — one hunk, both sides having
rewritten the create-objectstack rationale. Merged (not rebased), keeping
main's newer measured sync-template-versions.mjs paragraph and appending this
card's two blocks plus the widened glob list. Every number above was then
re-run on the merge commit
, including the full workspace rebuild the
type-check-debt ratchet requires; the earlier readings at 25fc98368 were
identical but describe a tree that is no longer head.

Also in this diff

The new test reads .github/workflows/scaffold-e2e.yml, so
check:cross-package-test-inputs demanded a declaration; turbo.json carries the
matching glob so the cache hash moves with the workflow. Three further paths are
declared because the test's header names them in prose
(packages/cli/src/commands/serve.ts and the two sibling scripts) — the settled
trade recorded three times in that file: a mention forces a declaration, and
declaring is cheaper than rewording prose to dodge a scanner. serve.ts earns it
on the merits too: its port-shift gate is the single fact that decides which fix
these blocks need.

Nothing here publishes — a workflow, a test, a gate declaration — so
skip-changeset rather than a changeset.

Generated by Claude Code


Generated by Claude Code

…eir own server (#9779)
The three wait loops asked one question — does the port answer 200? — and
`SERVER_PID` was captured but never checked, so a neighbouring server on the
same port satisfied the loop outright.
Measured on this checkout, with a neighbour holding the port and the previous
block run verbatim:
LOOP_OK=1 LOOP_ITERATIONS=1 LOOP_SECONDS=0
READY_ANSWERED_BY=NEIGHBOUR-RUN-A
OUR_PID_ALIVE=yes KILL_RC=0
Exit 0, on an app the job never started.
Neither sibling fix transfers. `os start` is a production boot: serve.ts gates
the port auto-shift on `flags.dev || NODE_ENV === 'development'` and start.ts
spawns `serve` with neither, so the server binds the requested port or exits 1
("Port N is already in use", measured). There is no shifted port to read back
and no `--strictPort` to ask for. A `SERVER_PID` liveness check alone does not
close it either: through the window that decides the run our own process is
genuinely alive.
So two guards per block, in order: refuse to boot when something already
answers the exact URL the loop accepts as proof, then require our own process
to still be alive on every iteration. The docker leg keeps its fixed name and
host port — both collisions are refused at `docker run` time, so that leg has
no wrong-answer mode — and gains the container-liveness check it was missing.
Ports stay fixed: every `runs-on:` in this repo is `ubuntu-latest`, one VM per
job, so per-run ports would buy nothing on the runner this actually runs on.
The new test executes the real `run:` scripts extracted from the workflow under
`bash -e` with stubs encoding the measured CLI behaviour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
Conflict in scripts/check-cross-package-test-inputs.mjs, one hunk: both sides
rewrote the `create-objectstack` rationale. Kept main's newer, measured
sync-template-versions.mjs paragraph (#9763 supersedes what this branch had
said about it) and appended this card's two blocks plus the widened glob list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 19, 2026
@claude

claudeBot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

✅ PM ACCEPT — #9779 / PR #9808

Verified independently: 4 files +617/-2, zero governed-surface hits, no non-green gates (six still running).


⭐ H1 — a THIRD failure mode, exactly as ruling 1 warned, and you established it before choosing anything

os start --port N on a held port takes the LOUD non-dev refusal branch and exits 1 (✗ Port N is already in use.), never auto-shifting — start.tsforces NODE_ENV=production and spawns servewithout --dev, and the workflow sets no env:

Plus the control on a free port booting healthy, so the refusal is a real branch rather than a broken invocation.

no shifted port to read back (#9647's fix) and no --strictPort to ask for (#9578's), because the CLI already behaves as if it had one. Three sibling cards, three genuinely different mechanisms. Ruling 1 said establish the failure mode before choosing a fix, and this is the third consecutive dev to prove the copies do not transfer.

⭐ And the fix the card asked for is measurably insufficient on its own

This is the finding, and I would have accepted the liveness check alone without it:

replaying the pre-fix block with a neighbour up, the loop took its 200 on iteration 1 after 0s, asserted /api/v1/ready against the NEIGHBOUR, and kill $SERVER_PID succeeded — because our own process was still booting and alive

LOOP_OK=1 LOOP_ITERATIONS=1 LOOP_SECONDS=0
READY_ANSWERED_BY=NEIGHBOUR-RUN-A OUR_PID_ALIVE=yes KILL_RC=0

A fourth variant of the trap: our process is alive, and it is simply not the one answering. kill -0 returns 0 and certifies nothing. So a liveness-only fix would have shipped a green check over the exact scenario it was added for.

Hence two guards per os start block — a pre-flight refusing to boot when the exact URL the loop accepts already answers, plus kill -0 $SERVER_PIDevery iteration. The pre-flight is the one that actually closes it; the liveness check catches the different case where our process dies later.

H3 — measured, and it is what keeps this proportionate

All 26 workflows are ubuntu-latest, one VM per job ⇒ ports stay fixed. Ruling 2 said let the grade size the fix and do not gold-plate a workflow to match a script's fix; the runs-on sweep is the fact that licenses that, and the card had not checked it.

And ruling 3 discharged on its own terms: the docker leg keeps--name e2e and host port 18080, because both collide loudly at docker run and that leg has no wrong-answer mode — it gains only the container-liveness check it was missing. Treating it separately, and concluding "less change", is the right outcome for a ruling that just said "treat it separately."

⭐ The test extracts the real workflow and runs it

extracts the three real run: scripts out of .github/workflows/scaffold-e2e.yml and executes them under bash -e with npx/docker stubs encoding the measured CLI behaviour

That is the only honest way to test a workflow's shell logic — a hand-copied transcription of the block would drift from the YAML the moment either changed. 8 tests.

Reverse verification, workflow alone restored to origin/main: 5 failed / 3 passed, and "the numbers ARE the card":

  • expected +0 not to be +0the pre-fix block exits 0 against the neighbour;
  • 60280msthe full 60s ceiling, reproduced.

⭐ And the three that stayed green are named as vacuity guards — each block still accepts the server it booted itself and still prints "iam":"OURS". Explaining why three did not move, rather than reporting 5/8 as if all should have, is the same discipline #9700's dev showed on its three negative cases.

⭐ The non-measurement is handled better than a measurement would have been

docker's refusal of a duplicate --name and a taken -p host port: NOT measured — this container ships the docker CLI with no daemon (dial unix /var/run/docker.sock: no such file or directory), and asserting it against our own stub would only assert the stub

A stub-vs-stub test would have produced a green that meant nothing — the exact vacuity this lane has been cataloguing all day — and you identified that the available test was circular and declined it. Stated in both the PR body and the workflow comment, so the limitation sits where the next reader will hit it rather than in a report nobody re-reads.

Also correct: main moved mid-verification and conflicted; merged not rebased, and everything re-run including the full rebuild the type-check-debt ratchet needs (33 ledger entries re-measured in 218.7s, 1926 raw errors, none above recorded).

Verdict: ACCEPT. Arming once the six running gates converge.


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review August 19, 2026 03:07
@os-steve
os-steve added this pull request to the merge queueAug 19, 2026
Merged via the queue into main with commit 526a436Aug 19, 2026
34 checks passed
@os-steve
os-steve deleted the claude/issue-9779-scaffold-e2e-ports branch August 19, 2026 03:19
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cdsize/lskip-changesetPR has no user-facing published change; bypasses the changeset gatetests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

scaffold-e2e.yml hardcodes port 8080 in three boot-and-probe blocks whose wait loop accepts whatever answers

2 participants

@os-steve@claude