Skip to content

feat: add webjs ci, a local CI runner the scaffold workflow reuses - #1472

Open
vivek7405 wants to merge 6 commits into
mainfrom
feat/local-ci
Open

feat: add webjs ci, a local CI runner the scaffold workflow reuses#1472
vivek7405 wants to merge 6 commits into
mainfrom
feat/local-ci

Conversation

@vivek7405

@vivek7405 vivek7405 commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Closes #1471

webjs ci runs an app's declared step list locally with the Rails 8.1 bin/ci experience: a timed result line per step, a failure list and one total line, --fail-fast, --only <title>, --json for an agent loop, and an opt-in --signoff that posts a green commit status through gh signoff. The list lives in package.json under webjs.ci (a string, a { title, run, env } command, or a { title, steps, parallel } group whose steps run N at a time with each step's output replayed whole). Every scaffolded app ships a default list plus a ci script, the scaffold's GitHub workflow collapses to one job that runs the same list through npm run ci so the two cannot drift, webjs create --skip-ci omits that workflow, and the monorepo root and its three in-repo apps declare their own lists.

Research record: #1470.

What changed

  • Config key webjs.ci in the schema (draft-07 definitions for the recursive step shape, a nested group cannot declare parallel), the WebjsConfig type, KNOWN_KEYS plus a nested guard, and the type fixture. The pure reader packages/cli/lib/ci-config.js normalizes the shorthand and reports every malformed entry by JSON path, because the boot validator never follows a $ref and a silently dropped step is a check that never ran.
  • Runner packages/cli/lib/ci-runner.js. Sequential steps inherit stdio and are not detached (Ctrl-C reaches them natively). Captured steps are detached with stdin ignored, buffered in arrival order, resolved on close with a bounded grace after exit. FORCE_COLOR reaches captured children only when the parent stdout is a TTY. Under GitHub Actions each step is a ::group::, a failure an ::error:: annotation, and a step table lands in $GITHUB_STEP_SUMMARY, so one job still names the layer that broke. Pure of process.exit, console, and the clock.
  • Command webjs ci. The predicate is the config, not an app/ directory (unlike webjs check), so a workspace root is a legitimate target. Nothing declared is exit 1 naming the members that declare one; a malformed block is exit 1 with every problem; --json puts one document on stdout and the human report on stderr; the exit code goes through exitCode so a non-TTY stdout is never truncated.
  • Scaffold. Default list (Setup, then a Checks group two at a time: check, doctor, typecheck, npm audit --audit-level=high or bun audit on Bun, and a sequential Tests sub-group of server, browser, e2e), a ci script, a one-job workflow with a read-only token, a timeout, and the concurrency cancel, --skip-ci, and the agent rule files, playbooks, PR template, and pre-commit comment now name npm run ci as the pre-push gate. The pre-commit hook itself is unchanged (Lighten pre-commit to convention-check only; move test gate to CI #174).
  • Monorepo. The root declares a list mirroring the GitHub jobs behind npm run ci (the jobs themselves stay the required checks), gallery / examples/blog / website each declare a shorter one, and test/repo-health/in-repo-ci-blocks.test.mjs pins all four.
  • Docs on every surface the doc-sync map names (see the table below).

Deliberately excluded

  • A doctor advisory for a missing webjs.ci, gh signoff fail posting, MCP exposure.
  • Converting the framework's own GitHub jobs to npm run ci (they are the required merge checks).
  • A PTY for captured children (a native dependency, which a buildless framework will not take on); node --test under a pipe reports as TAP, documented.

Test plan

  • Unit: packages/cli/test/ci-config (7), packages/cli/test/ci-runner (12, scripted fake child, fake clock, fake timers, pool cap, replay atomicity, exit-then-data, grace, interrupt, Actions mode), packages/server/test/config (schema lockstep + nested guard), test/types/webjs-config.test-d.ts with three @ts-expect-error counterfactuals (type-checked against the branch copy of core via a paths map; the in-tree run resolves the primary checkout through the linked node_modules and is the known worktree artifact)
  • CLI end to end: test/cli/ci.test.mjs (9: refusals, green, red, fail-fast, --json, --only, Actions groups + step summary, help)
  • Bun parity: test/bun/ci-runner.mjs on Node and on Bun 1.3.14 (real children, captured stderr, a detached sleep reaped on interrupt); the Bun matrix filtered to ci-runner and webjs-config reports no genuine failures
  • Drift guards: the prose hook's CLI list, help, check-target, published-package descriptions (96 pass)
  • Scaffold: test/scaffolds/* (61 + the --skip-ci counterfactual for the library option and the CLI flag), runtime-rewrite (npm run ci becomes bun run ci, npm ci still becomes bun install); a full-stack app generated WITH install runs npm run ci end to end green (Setup, Conventions, Health, Types, audit, server, browser, e2e) once Playwright Chromium is present, which is the workflow's own install step
  • Monorepo dogfood: root npm run ci -- --only Conventions green (8 steps, three at a time, 2.8s); gallery, examples/blog, website each green on their own list through the branch CLI (the website run after the docs edits, which covers the docs-links and llms guards)
  • Manual TTY: the Checks group under a pseudo-terminal renders and clears the progress line, colours the captured tools, and replays each step whole
  • npm test, full Node suite. Eight failures, all accounted for: the five that always fail in a linked worktree (listener, listener-overhead, three elision assertions), the config type fixture (bare specifier resolving into the primary, verified separately as above), and two #1451 no-any guards that parse tsc's plain output while this shell exports FORCE_COLOR=3; that file passes 4 of 4 with the variable unset

Two things a reviewer will notice in a generated app before the next release: the published @webjsdev/server schema does not know the ci key yet, so a scaffolded app booted against the published packages prints one boot warning (unknown key "ci") that disappears on release, and npm run ci in that app must be run through this branch's CLI until the CLI ships.

Definition of done

Surface Status
Tests Updated, every layer above
AGENTS.md Updated (CLI reference, create --skip-ci, the webjs block bullet, code-workflow items 1 and 4)
.agents/skills/webjs/ Updated (references/built-ins.md Local CI section, references/testing.md one-command section, SKILL.md Testing Defaults)
packages/cli/AGENTS.md, packages/server/AGENTS.md Updated (file inventory + command row; reader inventory)
website/app/docs/ Updated (configuration CLI Options entry + key count, testing section, deployment checklist)
README.md, packages/cli/README.md Updated
framework-dev.md Updated (the monorepo's own local CI)
Scaffold templates Updated (workflow, pre-commit comment, workflow.md, both playbooks, PR template) plus create.js and the CLI help
Marketing website/ N/A because no landing claim changes; the docs pages carry it
Changelog Automatic from the feat: title
examples/blog/CONVENTIONS.md N/A because it does not describe the test gate

Local CI (#1471) needs a step list an app declares once and every tool
can read without importing app code, so it lives in the package.json
`webjs` block next to the #550 dev/start orchestration. This lands the
key in the three-surface lockstep (the JSON Schema with recursive
definitions for a step, the WebjsConfig type, KNOWN_KEYS plus a nested
guard for the recursive shape) and the pure CLI reader that normalizes
the shorthand and reports every malformed entry with its JSON path.

The reader validates the shapes itself because the boot validator only
checks top-level key membership and never follows a $ref, and a step
that is silently dropped is a check that never ran, the exact false
green local CI exists to prevent. A group nested inside a parallel
group takes one slot and runs sequentially, so a `parallel` on it is
reported rather than honoured, the same rule Rails' runner applies.

The runner and the `ci` command follow in the next commits.
@vivek7405 vivek7405 self-assigned this Sep 10, 2026
The runner behind the ci command (#1471), modeled on Rails 8.1's
ActiveSupport::ContinuousIntegration: a heading and a timed result line
per step, a failure list plus one total line, fail-fast, and parallel
groups whose steps run on N slots with output captured and replayed
whole so nothing interleaves, a nested group taking one slot.

Two spawn shapes on purpose. A sequential step inherits stdio and is
not detached, so it owns the terminal and Ctrl-C reaches it natively,
the split webjs dev already makes for before-steps versus watchers. A
captured step is detached (its own process group, reaped on
interrupt), has stdin ignored so a TTY-reading tool cannot stop on
SIGTTIN and hang the pool, and resolves on close rather than exit,
with a bounded grace so a leaked grandchild holding the pipe cannot
hang the run. FORCE_COLOR reaches captured children only when the
parent's stdout is a TTY; Node has no PTY without a native dependency.

Under GitHub Actions each step is folded into a log group and a failure
is annotated, so one cloud job running the whole list still names the
layer that broke, which is what the per-layer jobs used to buy.

Pure of process.exit, console, and the clock, so the slot cap, the
fail-fast cutoff, replay atomicity, exit-then-data ordering, and the
grace path are all proven with a scripted fake child.
The local CI command (#1471): run the step list package.json declares
under webjs.ci, with -f/--fail-fast, --only <title>, --json, and an
opt-in --signoff that posts a green commit status through gh signoff
after a green run, the Rails 8.1 bin/ci posture.

The predicate is the config, not an app/ directory, unlike webjs check,
because a workspace root is a legitimate target and this monorepo
declares its own list. Nothing declared is exit 1 rather than 0, since
a run of zero steps would read as green; the refusal names the
workspace members that do declare one. A malformed block refuses with
every problem's JSON path and runs nothing.

Under --json stdout carries exactly one document and the human report
moves to stderr; the exit code is set through exitCode so a non-TTY
stdout is never truncated. .env is loaded before the steps, like dev
and start, so a local db migrate step sees DATABASE_URL while a CI
runner's explicit env still wins.

The prose hook's CLI subcommand list gains ci so its drift test keeps
passing, and the end-to-end CLI test and the Node + Bun proof script
cover the exit codes, the output shape, the env every child sees,
fail-fast, --only, the refusals, the GitHub Actions log groups and
step summary, and a real interrupt reaping a detached sleep.
Every new app now declares its CI once, in package.json under webjs.ci
(#1471): a Setup step, then a Checks group running two at a time
(webjs check, webjs doctor, webjs typecheck, a dependency audit) with a
sequential Tests sub-group (the server, browser, and e2e layers), plus
a `ci` script. The Tests group stays sequential because the server and
e2e layers share one SQLite file. Every step is a bare `webjs ...`
command, the same bar the before-steps meet, and a Bun app audits with
bun audit.

The generated GitHub workflow collapses from four hand-restated jobs to
one job that prepares the runner and runs `npm run ci`, so the cloud
and local lists cannot drift, which is the Rails guide's rule for every
provider. The cloud practices stay: a read-only token, a bounded job, a
concurrency cancel, and, through the runner's Actions mode, a log group
per step, an annotation per failure, and a step table in the job
summary, so a failure still names its layer. A team wanting per-layer
required checks runs `webjs ci --only` in a matrix, as the workflow
comment says.

`webjs create --skip-ci` omits the workflow and nothing else (rails new
parity); the local list always ships. The pre-commit hook is unchanged
per #174: `npm run ci` is the pre-push gate, and the scaffold's agent
rule files, playbooks, and PR template now say so.
The framework dogfoods its own local CI (#1471). The root package.json
declares a webjs.ci list mirroring the GitHub jobs (setup, the
per-app check and doctor plus the two source invariants three at a
time, the root test suite, the in-repo app typechecks and suites, the
browser suite, the blog e2e, the Bun matrix) behind `npm run ci`, and
gallery, examples/blog, and website each declare a shorter list behind
their own `ci` script.

The root script and every root step invoke this checkout's CLI by path
rather than a hoisted bin, because in a linked worktree
node_modules/.bin resolves into the primary checkout. The app lists go
through their npm scripts because the website's pretest hook copies the
ui registry, which a bare test invocation would skip. A repo-health
test parses all four blocks through the same reader the command uses
and checks that every npm script a step names exists in the package it
targets, so a stale step fails here rather than at run time with a
message that never mentions the block.

The GitHub workflow itself is unchanged: its jobs are the required
merge checks.
The ci command and the webjs.ci config key (#1471) on each surface the
doc-sync map names: the AGENTS.md CLI reference, config-block bullet,
and code-workflow items; the agent skill (a Local CI section in
built-ins.md, a one-command section in testing.md, a Testing Defaults
bullet in SKILL.md); the docs site (a webjs ci entry under CLI Options
on the configuration page with the step shapes, the flags, the Actions
surfaces, and the signoff merge gate, a webjs ci command section on the
testing page, a deployment-checklist item, and the known-key count);
the root and CLI READMEs; and framework-dev.md for the monorepo's own
list.
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Decisions carried from the plan, and two that moved during the build

Settled in #1470 and kept: JSON in the webjs block rather than a ci.ts DSL (the #550 precedent, and no tool should load app code to learn the step list), one cloud job consuming the same list (the Rails guide's rule for every provider), the pre-commit hook untouched (#174), signoff opt-in.

Two calls moved while building.

webjs ci does not refuse at a workspace root the way webjs check does. The plan reused findCheckTarget; the monorepo running its own local CI made the app/ predicate wrong, so the predicate is the webjs.ci block itself, and a missing block is the refusal, naming the workspace members that declare one. The root list mirrors the GitHub jobs and invokes this checkout's CLI by path, because a linked worktree's hoisted bin resolves into the primary.

The per-layer status checks the four-job workflow used to provide are replaced, not dropped. The runner detects GITHUB_ACTIONS and folds each step into a log group, annotates a failure with ::error title=<step>::, and appends a step table to the job summary, so the single job still names the layer that broke. The workflow also gains permissions: contents: read and timeout-minutes, which the old one lacked.

Two environmental findings worth knowing when reading a local run. A shell exporting FORCE_COLOR (this one had 3) makes child tools print a NO_COLOR is ignored warning and switches tsc to its pretty output, which is why two #1451 guards red under npm test here and pass with the variable unset; the runner itself only sets FORCE_COLOR for a captured child when the parent stdout is a TTY. And the browser layer needs a Playwright Chromium on the machine, which the workflow installs for itself; the scaffold docs say so.

@vivek7405
vivek7405 marked this pull request as ready for review September 10, 2026 13:06
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: add webjs ci, a local CI runner the scaffold workflow reuses

1 participant