Skip to content

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

Description

@vivek7405

Problem

Rails 8.1 made local CI first-class: bin/ci runs a declared step list on the developer's machine with timed steps, ✅/❌ result lines, a failure summary, --fail-fast, parallel groups whose output is captured and replayed whole, and an optional gh signoff. The generated GitHub workflow is the optional half (rails new --skip-ci drops it), and the guide tells every other provider to point the pipeline at bin/ci.

WebJs is the inverse today. The scaffold ships a four-job GitHub workflow (packages/cli/templates/.github/workflows/ci.yml) and no local aggregate. A developer or an agent runs check, doctor, typecheck, test:server, test:browser, and the e2e variant as six separate commands, nothing times them or produces one verdict, and the workflow restates the same list by hand so the two drift. The research record with the full Rails study and the decisions below is #1470.

Design / approach

Settled in #1470, not reopened here:

  • Config is JSON in the webjs block, not a ci.ts DSL. "webjs": { "ci": { "steps": [...] } }, the Unify webjs dev/start/db with npm-script behavior via a declarative tasks config #550 seam next to webjs.dev / webjs.start. A step is a string (shorthand for { title: s, run: s }), a { title, run, env? } command, or a { title, steps, parallel? } group (parallel is a slot count, default 1). A group inside a parallel group runs sequentially in one slot; a nested parallel is a config problem. env exists so the e2e opt-in does not depend on a POSIX VAR=1 cmd prefix.
  • webjs ci [--fail-fast|-f] [--only <title>]... [--json] [--signoff]. Every child gets CI=true plus the npm-style PATH. Output mirrors Rails (heading, ✅ <title> passed in 2.11s, ❌ ... failed in ..., total line, ↳ <title> failed summary), exit 1 on any failure. Runs wherever the nearest package.json declares webjs.ci (a workspace root included, so the monorepo itself runs local CI); a missing block is the error, with a hint naming workspace members that declare one. Under GitHub Actions it wraps each step in ::group:: / ::endgroup:: and emits ::error:: annotations for failed steps, so a failure still names its layer in the Actions log. --json prints one document { ok, seconds, steps: [{ title, run, group, ok, code, seconds, output? }] } (output only for failed steps) with the human output on stderr. --signoff runs gh signoff after a green run; off by default. With GITHUB_STEP_SUMMARY set, a markdown table of step results is appended.
  • Every new app ships a default webjs.ci block and a "ci": "webjs ci" script. Setup (webjs db migrate), then a Checks group parallel: 2 of webjs check, webjs doctor, webjs typecheck, a dependency audit (npm audit --audit-level=high, bun audit on a Bun scaffold), and a SEQUENTIAL Tests sub-group of webjs test --server, webjs test --browser, and webjs test --server with env: { WEBJS_E2E: '1' }. Tests stay sequential because the server and e2e layers share one SQLite file. Every default step is a bare webjs ... command, never npm run ..., the invariant test/scaffolds/scaffold-runtime.test.js:60-62 pins for before.
  • The scaffold workflow collapses to ONE job that runs npm run ci (setup-node, npm ci, Playwright Chromium, puppeteer-core, the CHROMIUM_PATH resolve, db:generate && db:migrate with DATABASE_URL: file:./ci.db, then npm run ci). The step list has one source. A team wanting per-layer required checks back uses a matrix over webjs ci --only "<group>"; the workflow comment says so.
  • webjs create --skip-ci omits only .github/workflows/ci.yml. The PR template stays (test/scaffolds/scaffold-agent-skills.test.js:72 expects it). The default keeps generating the workflow, because Move the test gate from pre-commit to CI (framework + scaffolded apps) #188's rationale (a gate a local --no-verify cannot skip) still holds.
  • The pre-commit hook is unchanged (Lighten pre-commit to convention-check only; move test gate to CI #174 decided a per-commit test run fights commit-per-logical-unit). webjs ci is a pre-push habit, not a commit hook.
  • The monorepo root gets a webjs.ci block mirroring its GitHub jobs (conventions, unit, browser, e2e, Bun matrix, dist, apps) and a ci script, documented in framework-dev.md, so the framework runs its own local CI. The three in-repo apps (gallery, examples/blog, website) get a ci script and a webjs.ci block using their npm scripts (the website's pretest hooks fire only through npm run). The framework's own .github/workflows/ci.yml jobs are NOT converted (they are the required checks).
  • Not in scope: a doctor advisory for a missing webjs.ci, gh signoff fail posting, MCP exposure.

Implementation notes (for the implementing agent)

Where to edit

New files

  • packages/cli/lib/ci-config.js: readCiConfig(appDir, readFile?) returning { steps, problems }, plus normalizeSteps(raw, path, inParallel) and selectSteps(steps, only) (an unknown --only title is a problem, not silence). Pure, injectable reader, same pattern as packages/cli/lib/app-tasks.js (copy its private readWebjsBlock helper). Problems carry the JSON path, e.g. webjs.ci.steps[1].steps[0].
  • packages/cli/lib/ci-runner.js: runCi(steps, cwd, opts) with opts = { spawn, write, isTTY, now, timers, failFast, captureAll, env } returning { ok, seconds, steps, interrupted } plus an interrupt() handle. Internals: a pure worker pool runPool(tasks, slots, runOne, { failFast, isInterrupted }) and runOne(step, ctx). Exported pure formatters: formatElapsed (Rails' 2.11s / 1m2.11s), formatHeading, formatResult, formatProgress, formatSummary, stepSummaryMarkdown. Pure of process.exit and console, same contract as packages/cli/lib/run-tasks.js L30-33.
  • Tests: packages/cli/test/ci-config/ci-config.test.mjs, packages/cli/test/ci-runner/ci-runner.test.mjs (fake spawn like packages/cli/test/run-tasks/run-tasks.test.mjs L14-29, extended with stdout / stderr emitters; fake now counter), test/cli/ci.test.mjs (real bin against a temp app, pattern test/cli/check-target.test.mjs L33-42), test/bun/ci-runner.mjs + test/bun/ci-runner.test.mjs (pattern test/bun/run-tasks.mjs; auto-discovered by scripts/run-bun-tests.js), test/repo-health/in-repo-ci-blocks.test.mjs.

Framework

  • packages/cli/lib/run-tasks.js L15: export envWithLocalBin (and killChildTree, which already has the ESRCH fallback).
  • packages/cli/lib/check-target.js L72-97: export workspaceApps(cwd) so the missing-config hint can name workspace members; no app-dir refusal for ci (the config, not app/, is the predicate).
  • packages/cli/bin/webjs.js: USAGE banner (L87-125), the HELP map (L137-281, entries are { usage, summary, options?, examples }), a case 'ci' beside case 'check' (L753). flag() is at L360. Load .env via loadAppEnv before the steps, mirroring dev / start (dogfood: webjs dev/start ignore PORT in .env (port read before loadEnvFile) #447), so a local webjs db migrate sees DATABASE_URL. Set process.exitCode instead of calling process.exit after the run. case 'create' (L1299-1359): add --skip-ci next to --no-install (L1346) and pass skipCi to scaffoldApp.
  • packages/core/src/webjs-config.d.ts: WebjsCiStep, WebjsCiGroup, WebjsCiConfig, and the member ci?: WebjsCiConfig inside interface WebjsConfig at TWO-space indent (the drift test's regex is ^ {2}(\w+)\??:).
  • packages/server/webjs-config.schema.json (draft-07, so definitions, not $defs): a ci property with description, additionalProperties: false, steps array whose items $ref #/definitions/ciStep, a oneOf of string (minLength 1) | command object (title, run, env with string values) | group object (title, parallel integer ≥ 1, steps of #/definitions/ciNestedStep, the same shape minus parallel). Every property needs a description (the drift test requires it).
  • packages/server/test/config/webjs-config-schema.test.js: add ci to KNOWN_KEYS (L50-71); add a nested guard for ci.steps modeled on the regenerate guard (L193-212).
  • packages/server/src/webjs-config-validate.js docblock L44-50 and website/app/docs/configuration/page.ts L167: the "17 keys" / "9 of the 18" counts move by one.
  • test/types/webjs-config.test-d.ts: a valid ci block in full, plus @ts-expect-error counterfactuals.
  • .claude/hooks/block-prose-punctuation.sh L309 webjs_cli= alternation: add ci. test/hooks/block-prose-punctuation.test.mjs L307 hard-fails otherwise. Check packages/cli/templates/.claude/hooks/block-prose-punctuation.sh for the same list.

Scaffold (invoke the scaffold-sync skill first)

  • packages/cli/lib/create.js: ci: 'webjs ci' after doctor in scripts (L423, the plain-tooling group, no bun --bun); the default webjs.ci block after doctor: { gate } (L570); opts.skipCi filters .github/workflows/ci.yml out of templateFiles (L667).
  • packages/cli/templates/.github/workflows/ci.yml: one job. Keep the exact 4-line setup-node block including cache: npm, because bunifyCi (packages/cli/lib/runtime-rewrite.js L176-190) matches it verbatim to inject setup-bun; npm run ci becomes bun run ci through its generic npm run rewrite.
  • Prose: packages/cli/templates/.hooks/pre-commit comment L10-13, templates/.agents/rules/workflow.md item 4 (L53-61), templates/partials/agents-playbook-fullstack.md L100-125 and agents-playbook-api.md L49-70 (the checklist and the Commands fence), templates/.github/pull_request_template.md test plan.
  • gallery/package.json, examples/blog/package.json, website/package.json: ci script + webjs.ci block.

Docs (invoke the doc-sync skill first; rows: new CLI command, new webjs.* key, scaffold convention)

  • AGENTS.md: the CLI reference fence (L568-584: a webjs ci line, --skip-ci on the create line), the "webjs" block bullet (L594), Code-workflow item 4.
  • .agents/skills/webjs/references/built-ins.md after the dev/start orchestration section (L207-218); references/testing.md next to ## App runners (L56); SKILL.md Testing Defaults (L265).
  • website/app/docs/configuration/page.ts: <h3>webjs ci</h3> in CLI Options (after L76); website/app/docs/testing/page.ts: <h2>webjs ci command</h2> after the webjs test section (L186-199); website/app/docs/deployment/page.ts checklist (L410-421).
  • README.md L128 and the Status paragraph; packages/cli/README.md Commands fence (L38-57) and templates list (L71); packages/cli/AGENTS.md command table (L179-184); packages/server/AGENTS.md reader inventory (L147-192).

Landmines

  • Four tests pin the four-job workflow by regex: test/scaffolds/scaffold-integration.test.js L284-290, test/scaffolds/scaffold-template-validation.test.js L141 (/^\s+- run: npm run doctor$/m), test/scaffolds/scaffold-runtime.test.js L112 (- run: bun run check), and packages/cli/test/runtime-rewrite/runtime-rewrite.test.mjs L125-152. Rewrite them to assert npm run ci / bun run ci in the workflow and the layers in pkg.webjs.ci.steps.
  • bunifyCi's .replaceAll('- run: npm ci', '- run: bun install') does not touch - run: npm run ci, but keep the two spellings apart in the file with a comment; scaffold-runtime.test.js L114 asserts no npm ci survives in the Bun flavour.
  • Captured children: spawn detached: true with stdio: ['ignore', 'pipe', 'pipe'] (a TTY-reading child stops on SIGTTIN otherwise), resolve on close not exit (data can arrive after exit), with a bounded grace after exit for a leaked grandchild holding the pipe. Sequential steps stay non-detached with inherited stdio so Ctrl-C reaches them natively, the same split webjs dev makes (run-tasks.js L74, bin/webjs.js L470-471). A child that exits by signal is interrupted, not failed.
  • Progress line: only on a TTY, only while a pool is active, unref() the interval, clear before every replay and before the group summary, and make every replayed buffer end in a newline. Never render it while an inherit-stdio step owns the terminal.
  • FORCE_COLOR=1 only on captured children and only when stdout is a TTY. Node has no PTY without a native dependency, which is disqualified. node --test under a pipe reports as TAP; document, do not work around.
  • GITHUB_STEP_SUMMARY: escape | in titles and commands.
  • The boot validator never follows $ref (webjs-config-validate.js L60-80 checks top-level membership only), so readCiConfig must validate the step shapes itself and the bin must refuse with exit 1 on any problem.
  • webjs check refuses at a workspace root; webjs ci must NOT, because the monorepo root is a legitimate ci target. The predicate is the webjs.ci block.
  • The audit step: keep it in the scaffold default only if a freshly generated app passes npm audit --audit-level=high (and bun audit on Bun 1.3.14). If a default install has a high finding, drop the step and say so in the PR.
  • Website steps must go through npm (pretest / pretest:browser run scripts/copy-registry.mjs); a webjs test step there reds the suite.
  • Never install inside a linked worktree (fix: npm install in a linked worktree corrupts the shared node_modules #1442). npm run worktree:link after cutting the worktree; delete every node_modules symlink before any real install.
  • Invariant 11: no em-dashes, no pause hyphens, WebJs capitalised in prose, webjs ci lowercase only as a command.

Invariants

  • No new dependency, no build step (AGENTS.md "Deliberately deferred"). packages/ stays plain .js + JSDoc.
  • The schema, the WebjsConfig type, the reader, and KNOWN_KEYS move in lockstep (packages/server/AGENTS.md L190-196).
  • The task's worktree, commit per logical unit, push after each, draft PR first, conventional feat: title, Closes #<this issue> in the body.

Test layers and doc surfaces

Unit (packages/cli/test/**, packages/server/test/config/**), spawn-based CLI (test/cli/**), type fixture (test/types/**), Bun parity (test/bun/ci-runner.*), scaffold (test/scaffolds/**, plus generating both templates on both runtimes with install: false, and one full-stack app WITH install running npm run ci end to end), repo-health (test/repo-health/**), hook drift (test/hooks/block-prose-punctuation.test.mjs). Docs as listed above; the changelog is generated from the feat: PR title.

Acceptance criteria

  • webjs ci in a scaffolded app runs every declared step, prints Rails-shaped output with per-step timing, a total line and a failure summary, and exits 1 on any failure; --fail-fast stops after the first failure; --only selects by title and reports an unknown title; --json emits exactly one JSON document on stdout
  • A parallel group never runs more than parallel steps at once, a nested group takes one slot sequentially, and captured output is replayed whole and never interleaved (counterfactual: two children emitting alternately)
  • webjs ci with no webjs.ci block exits 1 naming the workspace members that declare one (JSON: error.code = 'NO_CI_CONFIG'); npm run ci at the monorepo root runs the framework's own local CI
  • Under GITHUB_ACTIONS, each step's output is wrapped in ::group:: / ::endgroup:: and a failed step emits an ::error:: annotation
  • webjs.ci is in the schema, the WebjsConfig type, KNOWN_KEYS, and the type fixture, with the nested-parallel rule expressed in the schema and enforced by the reader
  • A fresh full-stack and api app (Node and Bun flavours) carries the default webjs.ci block, a ci script, and a single-job workflow running npm run ci / bun run ci; webjs create --skip-ci emits no workflow and still emits the PR template
  • A fresh full-stack app with dependencies installed passes npm run ci end to end
  • npm run ci passes in gallery, examples/blog, and website
  • A counterfactual proves each new test fires (fail-fast, pool cap, replay, exit-then-data, signal-as-interrupted, root refusal)
  • Tests cover the new behaviour at every layer it touches, including test/bun/ci-runner.* on both runtimes
  • Every doc surface above is updated on the same PR

Research record: #1470.

Activity

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

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

  • Status
    In progress

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions