Skip to content

feat: run every CI check locally from the monorepo root - #1475

Merged
vivek7405 merged 11 commits into
mainfrom
feat/local-ci-gate
Sep 10, 2026
Merged

feat: run every CI check locally from the monorepo root#1475
vivek7405 merged 11 commits into
mainfrom
feat/local-ci-gate

Conversation

@vivek7405

@vivek7405 vivek7405 commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Closes #1474

The monorepo's local CI becomes complete, the Rails 8.1 posture the scaffold already ships: the root webjs.ci list now runs everything the GitHub workflow runs, step for step (both e2e suites, the Bun proofs and matrix, the Postgres round-trip against a throwaway container, the Docker image build), npm run ci -- --signoff posts a green signoff status on the pushed head after a green run, and scripts/protect-main.sh becomes the switch that would make that status required (the CODEOWNER review, the signoff ruleset, and the six Actions checks during a transition, with --local-only as the final state). The switch is not run by this PR and is not planned for now: main keeps requiring the six Actions checks, the workflow's jobs are byte-identical to main, and contributing keeps running GitHub CI exactly as today. The docs describe that state.

Research: #1470. Runner and the first root list: #1471 (PR #1472), which this branch stacks on until it merges.

What changed

  • The root list (package.json webjs.ci): Setup, then one Gate group three slots wide holding the Node e2e group (the blog in a real browser, the browser-test harness, dev overlay nav, seed observability, morph, the form-submission run against a live website dev server, the two ui e2e), the Bun group (all 31 proof scripts in workflow order, then the matrix), the blog e2e served on Bun, the browser suite, the root test suite, the in-repo app typechecks and suites with the website boot-check, the Conventions group, and the Postgres round-trip; then the Docker image build as its own top-level step so --only Gate skips it on a routine run. 63 command steps.
  • scripts/ci-e2e-website.sh boots the website in its own process group with the log in a file and reaps the group on exit, the shape the CI step arrived at after a background child once held it open for ten minutes.
  • scripts/ci-postgres.sh runs the round-trip against a throwaway postgres:16 container, installs the pg driver --no-save into the repo's node_modules exactly as the CI job does (the shared lock stays without it), and refuses when node_modules is a symlink so it can never write through a linked worktree into the primary (fix: npm install in a linked worktree corrupts the shared node_modules #1442). A link under test/pg/ was tried first and satisfied the test's own import but not drizzle-orm/node-postgres, which resolves pg from inside node_modules.
  • WEBJS_DOCKER names the command that reaches a Docker daemon for both Docker steps (sudo -n docker, podman); the default needs the user in the docker group.
  • The gate in scripts/protect-main.sh: the review, gh signoff install (a repository ruleset requiring the signoff status, idempotent, the extension installed if missing), and the six Actions job names during the transition; --local-only drops the Actions contexts.
  • test/cli/check-target.test.mjs cross-checks the workspace app list against the webjs check (<app>) steps of the root list instead of the workflow's loop, so it survives the workflow's removal.
  • Two defects the one-tree gate surfaced, both fixed here: scripts/build-framework-dist.js removed packages/core/dist before rewriting it, so two Bun proofs rebuilding it in one slot made four unrelated unit tests in another slot fail with ERR_MODULE_NOT_FOUND (it now stages and renames each bundle into place, so the directory never disappears); and the Ctrl-C test in test/cli/ci.test.mjs signalled after a fixed delay that a loaded machine outran (it now waits for the child process itself). CI never saw either because each job has its own tree.
  • Docs: framework-dev.md (the complete list, the gate, push-then-sign-off, the transition, the local prerequisites, npm run -s ci -- --json for one JSON document), AGENTS.md (the code-workflow item and the gh pr checks carve-out), the start-work skill's merge section (read the gate with gh signoff status), and the workflow header (its role as the cloud second opinion, job names kept in sync with the gate script).

The switch (not applied by this PR, and deferred)

  1. This PR lands the complete list, the switch script, and the docs.
  2. When the maintainer decides to flip: bash scripts/protect-main.sh requires signoff while the six Actions checks stay required.
  3. bash scripts/protect-main.sh --local-only drops the Actions contexts; the workflow becomes a non-required second opinion, then goes.

Neither script invocation runs on this PR. The decision (2026-09-11) is to merge without flipping: GitHub Actions stays the required gate and npm run ci is the pre-push loop, with --signoff available.

Measured

Complete list, 63 command steps, on a 24-core machine from a real install: 7m06s, every step green, signed off on 0bbe37e (gh signoff status reads ✓); the run before the review round was 6m57s on 69b79f2. The wall-clock is the Node e2e group; the Bun group and the Bun-served blog e2e run beside it in the other two slots. Six slots overloaded the box on #1472 (Firefox timed out launching a page), so three is what ships.

Test plan

  • Full list green from a real install, 63/63 in 7m06s, and --signoff posted the signoff status on the pushed head 0bbe37e (after the review round; 69b79f2 before it)
  • scripts/ci-postgres.sh green through WEBJS_DOCKER='sudo -n docker' (2.7s with the image warm); the Docker image build green the same way (webjs-monorepo:ci, 523 MB)
  • The signoff gate proven both ways beforehand: a red partial run prints the do-not-merge heading and posts nothing; a dirty tree is refused by gh signoff itself; a green partial run from a clean pushed tree posts signoff (retracted afterwards with gh signoff fail, since a partial run must not stand)
  • test/cli/check-target.test.mjs re-pointed and green, test/repo-health/in-repo-ci-blocks.test.mjs green, test/packaging/build-dist.test.js green against the atomic build, test/cli/ci.test.mjs green with the child wait

Review round

The review on this PR posted six findings; all six are fixed in f9a6691a, 8469706e, e391e9ee, and 4dc319b2 (GH_REPO for the signoff install, the TCP readiness probe, the website port pinned and a squatter refused, per-pid sleep lengths in the interrupt test, the staging dir dropped on a failing build, the Postgres prose corrected), answered in their threads, and the threads resolved. Re-running the gate on that head then found one more one-tree collision, fixed in 0bbe37e8: every web-test-runner suite (the root browser suite, the website's, the gallery's) binds port 8000, so they now share one sequential Gate slot.

Definition of done

Surface Status
Tests Updated: the check-target cross-check re-pointed; the interrupt test made load-safe; the block guard covers the new steps
AGENTS.md Updated (code-workflow item 4, the gh pr checks carve-out; Actions named as the gate that stands, the signoff as the switch)
framework-dev.md Updated (the Local CI section: the complete list, what main requires today, the switch and how to reverse it)
.claude/skills/webjs-start-work/SKILL.md Updated (merge section: Actions required, npm run ci before pushing, the switch is the maintainer's)
.github/workflows/ci.yml Updated (header only: the required gate, the local twin, the unrun switch; job names in sync with the script); jobs byte-identical to main
scripts/protect-main.sh, scripts/build-framework-dist.js Updated (the whole gate with --local-only; the atomic build)
.agents/skills/webjs/, docs site, README, scaffold N/A because nothing here changes what an app or the CLI does; the app-facing local CI surface shipped in #1472
Changelog N/A because no published package changes (feat: title kept for the squash subject)

@vivek7405 vivek7405 self-assigned this Sep 10, 2026
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Decisions: how the list reaches parity, and what the gate refuses

Three calls worth recording.

The pg driver stays out of the shared lock. The Postgres job installs it ad hoc with --no-save for that reason, and the local step keeps the posture: scripts/ci-postgres.sh installs it into a scratch prefix and links it under test/pg/node_modules, which is where Node's bare-specifier walk from the test file looks first. The test imports pg as an ES module, so NODE_PATH would not have done, and an install through a linked worktree's node_modules is the #1442 trap, so the scratch prefix is the only shape that works everywhere.

The Gate stays at three slots and the Docker build sits outside it. Six slots overloaded a 24-core machine (Firefox could not launch a web-test-runner page inside its 30s start timeout), three were clean at the same wall-clock, and the blog e2e is the floor either way. The image build is its own top-level step so a routine --only Gate skips it while a signoff run includes it.

The signoff refuses what it should. gh signoff declines a tree with uncommitted changes and a head that is not pushed, so the rule is push first, then sign off, and a later push carries no signoff until the list runs on it again. The script keeps the six Actions job names required during the transition and --local-only is the final state; I am not applying either until #1472 has merged, because requiring signoff now would block that merge.

Two machine prerequisites the docs now name: the Playwright browsers, and a user in the docker group for the Postgres container and the image build.

@vivek7405
vivek7405 marked this pull request as ready for review September 10, 2026 18:56

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The gate's shape is right (branch protection plus a ruleset, --signoff only after a green list, push-then-sign-off), and the atomic dist build is a real fix for a real race. One thing blocks the plan as written: gh signoff install --repo is rejected by the extension, so scripts/protect-main.sh never reaches the ruleset and the transition step would silently leave main on the Actions checks alone (or, with --local-only, on nothing). Inline comment has the one-token fix.

The rest are local-run hazards the CI-shaped steps did not have on a fresh runner: the Postgres readiness probe passing on the init-phase socket server, the website e2e trusting whatever already answers on 5001, the interrupt test's pgrep matching the other slot's copy of itself, and a failed dist build leaving an un-ignored staging dir. Plus one doc sentence in framework-dev.md that describes the test/pg/node_modules link the PR body says did not work.

Two notes with no line to hang them on. scripts/ci-e2e-website.sh and the interrupt test need setsid and pgrep, which macOS does not ship by default; worth a word in the prerequisites list. And scripts/ci-postgres.sh runs npm install into the shared root node_modules from inside a parallel Gate slot while the other slots import from that tree; the diff is additive so I could not construct a failure, but a Setup step would take the question off the table.

Comment thread scripts/protect-main.sh Outdated
Comment thread framework-dev.md Outdated
Comment thread scripts/ci-postgres.sh Outdated
Comment thread test/cli/ci.test.mjs Outdated
Comment thread scripts/ci-e2e-website.sh Outdated
Comment thread scripts/build-framework-dist.js
The root webjs.ci list mirrored the Actions jobs by shape, not by
content (#1474). It now runs the same checks step for step: the Node
e2e job's eight runs (the blog in a real browser, the browser-test
harness, dev overlay nav, seed observability, morph, the form
submission run that needs a live website dev server, the two ui e2e),
the blog e2e served on Bun, all 31 Bun proof scripts ahead of the
matrix, the Postgres round-trip, and the Docker image build.

Two steps needed scripts. scripts/ci-e2e-website.sh boots the website
in its own process group with the log in a file and reaps the group on
exit, the shape the CI step arrived at after a background child once
held the step open for ten minutes. scripts/ci-postgres.sh runs the
round-trip against a throwaway postgres:16 container and installs the
pg driver into a scratch prefix linked under test/pg/node_modules,
which keeps the shared lock without it (the CI job's own posture) and
never installs through a linked worktree's node_modules (#1442). The
test imports pg as an ES module, so NODE_PATH would not have done.

The Gate stays at three slots, longest first. The blog e2e picks a
random free port, so the Node and Bun runs share the Gate. The Docker
build is its own top-level step so a routine `--only Gate` skips it
while a signoff run includes it.
scripts/protect-main.sh now declares the whole merge gate (#1474): the
CODEOWNER review, the `signoff` commit status that
`npm run ci -- --signoff` posts after a green local run (installed as a
repository ruleset through gh signoff install, idempotent), and, during
the transition, the six Actions job names. `--local-only` is the final
state with no Actions context required, so the workflow can be demoted
to a second opinion or deleted once a few PRs have merged on signoff.

test/cli/check-target.test.mjs used to cross-check the workspace app
list against the workflow's `webjs check` loop; it now reads the
`webjs check (<app>)` steps of the root webjs.ci list, so it survives
the workflow's removal and pins that every in-repo app is checked
locally.
…econd opinion

framework-dev.md's Local CI section now describes the complete root
list, the signoff gate and the push-then-sign-off rule, the transition
to --local-only, and the local prerequisites (Bun, Docker, the
Playwright browsers, gh with the signoff extension, no FORCE_COLOR in
the shell). AGENTS.md's code-workflow item and its gh porcelain
carve-out, and the start-work skill's merge section, say the gate is a
signed-off local run read with gh signoff status, with gh pr checks
kept for the Actions half while it is still required. The workflow's
header states its new role: the cloud second opinion, informational
once --local-only is applied, its job names kept in sync with the gate
script.
…the daemon

The first local Postgres step linked a scratch-installed pg under
test/pg/node_modules, which satisfied the test's own import but not
drizzle-orm/node-postgres, whose session.js resolves pg from inside the
repo's node_modules (measured: ERR_MODULE_NOT_FOUND from that file). The
step now takes the CI job's exact posture, `npm install --no-save` into
the repo's node_modules, and refuses when node_modules is a symlink so
it can never write through a linked worktree into the primary (#1442).
That is where the "sign off from a real install" rule bites, and the
message says so.

Both Docker steps read WEBJS_DOCKER for the command that reaches a
daemon (`sudo -n docker`, `podman`); the default `docker` needs the user
in the docker group, which framework-dev.md now names. The JSON report
note says `npm run -s ci -- --json`, since npm's own run banner
otherwise precedes the document.
…terrupt test

Running the whole gate in one tree surfaced a race the per-job CI
never could: scripts/build-framework-dist.js removed packages/core/dist
before writing it, and two Bun proofs rebuild that directory while
other slots of the same run import @webjsdev/core/dist/webjs-core.js,
so four unrelated unit tests red with ERR_MODULE_NOT_FOUND (#1474).
The build now goes into a staging sibling and each bundle is renamed
into place, then stale files are pruned, so dist never disappears and
a concurrent reader sees the old bundle or the new one.

The Ctrl-C test in test/cli/ci.test.mjs sent its first signal after a
fixed 700ms, which on a loaded machine arrived before the bin had
spawned its child, leaving nothing to interrupt. It now waits for the
child process itself (pgrep on a unique sleep length) before signalling.
gh signoff install accepts only --branch; any other dashed option is an
unknown-option failure. Under set -e the script stopped there, after the
branch-protection PUT had landed and before the signoff ruleset existed,
so a transition run would have kept main on the Actions checks alone and
a --local-only run would have left it with no status requirement at all.
The extension resolves the repository through gh api repos/:owner/:repo,
which GH_REPO overrides.

Claude-Session: https://claude.ai/code/session_0125BCU48L9AmmM1JbBKeyQe
…ever sees

The Postgres readiness probe now goes over TCP inside the container. The
image's entrypoint runs a temporary init-phase server on the unix socket
only, so a socket probe read ready before the real server listened on the
port the test connects to, and every run is a fresh container so the
window is there warm or not.

The website e2e step refuses when something already answers on :5001
(the step would otherwise test that server while its own died with
EADDRINUSE) and pins PORT=5001, since the website dev script honours an
exported PORT and the probe would wait out its two minutes.

framework-dev.md described the pg driver as linked under
test/pg/node_modules, the approach that was tried and did not satisfy
drizzle's resolution; it now describes the --no-save install the script
does, and the prerequisites name setsid and pgrep.

Claude-Session: https://claude.ai/code/session_0125BCU48L9AmmM1JbBKeyQe
A build that threw left packages/core/dist.building-<pid> behind, and the
.gitignore pattern is the bare dist, which matches only a path component
named exactly that, so the leftover showed up untracked and would ride a
git add -A. The build-and-move now runs inside try/finally.

Claude-Session: https://claude.ai/code/session_0125BCU48L9AmmM1JbBKeyQe
This file runs twice in one local CI run (npm test in one Gate slot, the
Bun matrix in another), and pgrep -f on a fixed length matched the other
run's child, firing SIGINT before this run had spawned anything, the
same failure the wait was added to fix.

Claude-Session: https://claude.ai/code/session_0125BCU48L9AmmM1JbBKeyQe
The root browser suite and the website and gallery suites each start a
web-test-runner on its default port 8000. With the root suite in one
Gate slot and the in-repo app suites in another, the two overlapped on
a signoff run and the later one fetched its test modules from the
wrong server (every test failed with "Failed to fetch dynamically
imported module ... localhost:8000"). Two earlier runs missed it only
by timing. The root browser suite and the in-repo app group now share
one sequential slot, which costs no wall-clock because the e2e group
is the floor, and framework-dev.md names the rule.
The workflow header, framework-dev.md, AGENTS.md and the start-work skill
said local CI is the merge gate and a PR must not merge without a signed
off head. The decision is to merge the runner and the complete list
without running scripts/protect-main.sh, so main keeps requiring the six
Actions checks and nothing requires the signoff status. An agent reading
the old wording would look for a gate that does not exist. The docs now
say what main requires today, what the signoff status is, and that the
script is the switch that has not been run.
@vivek7405 vivek7405 changed the title feat: make local CI the merge gate for the monorepo feat: run every CI check locally from the monorepo root Sep 10, 2026
@vivek7405
vivek7405 merged commit bf201e7 into main Sep 10, 2026
10 checks passed
@vivek7405
vivek7405 deleted the feat/local-ci-gate branch September 10, 2026 20:52
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.

feat: run every CI check locally from the monorepo root, with an unrun switch to make it the gate

1 participant