diff --git a/README.md b/README.md index 83a841121..3426b7824 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ Run these from the repository root. `pnpm examples:check` validates and builds e ## Development -`pnpm check` runs the local delivery gate (build, unit and integration tests, lint, typecheck); `pnpm check:release` adds the packaging gates. Versioning goes through Changesets. Native Claude/Codex host smokes are opt-in and intentionally skipped in CI. The Workbench architecture and the optional RSC runtime are documented in [docs/architecture/rsc-runtime-workbench.md](docs/architecture/rsc-runtime-workbench.md). +`pnpm check` runs the local delivery gate (build, unit and integration tests, lint, typecheck); `pnpm check:release` adds the packaging gates. `pnpm check:local-ci` mirrors the full hosted CI gate — the three-Node verify matrix plus the examples, release, and micro-eval jobs — in parallel local worktrees, and is the merge gate for the local-merge workflow described in [docs/local-ci.md](docs/local-ci.md). Versioning goes through Changesets. Native Claude/Codex host smokes are opt-in and intentionally skipped in CI. The Workbench architecture and the optional RSC runtime are documented in [docs/architecture/rsc-runtime-workbench.md](docs/architecture/rsc-runtime-workbench.md). ## Status diff --git a/docs/local-ci.md b/docs/local-ci.md new file mode 100644 index 000000000..0177c4761 --- /dev/null +++ b/docs/local-ci.md @@ -0,0 +1,96 @@ +# Local CI gate + +`pnpm check:local-ci` proves what the hosted CI gate proves, on the +development machine, in one command — including the full Node matrix. It +exists because a hosted Verify leg takes ~13–16 minutes while a many-core +development machine can run all three legs plus the release gates +concurrently in less wall time. The local-merge workflow it enables: + +1. Run `pnpm check:local-ci` on the branch's HEAD commit. +2. If the gate is green, the branch is mergeable — merge it. +3. Hosted CI still runs on the merged commit (push to `main`) and stays the + asynchronous post-merge safety net; if it disagrees with the local run, + the hosted result wins and the merge gets a follow-up fix. + +For quick iteration, `pnpm check:local-ci --current-node-only` runs a single +Verify-equivalent leg on whatever Node is currently active, with the repo's +normal local worker derivation. It skips the Node matrix and the +examples/release/micro-eval gates, so it is a fast signal, not a merge gate. + +## What it runs + +Every leg is an isolated git worktree pinned to the HEAD commit (uncommitted +changes are not covered — the runner warns), with its own `node_modules`. +Legs live under `.worktrees/local-ci/` (gitignored), are reused across runs +for warm caches, and can be recreated with `--fresh`. + +| Local leg | Node | Steps | Mirrors hosted job | +| --- | --- | --- | --- | +| `verify-node22` | 22.19.x | `install`, `playwright install chrome`, `build`, `lint:package`, `typecheck`, `lint`, `test:unit`, `test:integration` | `verify (22.19.0)` | +| `verify-node24` | 24.x | same | `verify (24)` | +| `verify-node26` | 26.x | same | `verify (26)` | +| `gates-node22` | 22.19.x | `install`, `examples:check`, `check:release`, `eval:spot` | `examples-check`, `release-gates`, `rsc-runtime-micro-eval` | + +The three hosted Node-22.19 jobs fold into one `gates-node22` worktree +because each of their entry scripts starts from `pnpm build` in a fresh +install, which one worktree provides just as well as three. + +All four legs run concurrently. The summary table (leg × step × status × +duration × test census) is printed and written to +`.worktrees/local-ci/summary.md` (plus `summary.json`); per-step logs land in +`.worktrees/local-ci/logs/`. The command exits non-zero if any step fails. + +## Node provisioning + +The runner introduces no new tooling. For each hosted runtime line +(22.19.x, 24.x, 26.x) it resolves a Node binary from, in order: + +1. `AGENT_BUNDLE_LOCAL_CI_NODE_22` / `_24` / `_26` — a Node binary or bin + directory, for machines with bespoke layouts; +2. `mise where node@`; +3. `~/.nvm/versions/node/*`; +4. the current process's Node, if it matches the line. + +Every resolved binary is version-checked against the hosted line before use. +If a line is missing, the runner fails with the exact install command (e.g. +`mise install node@22.19`). pnpm itself is pinned by reusing the entrypoint +that launched the runner, executed on each leg's own Node, so `pnpm`, its +lifecycle children, and `pnpm exec node` all agree on the leg's runtime — +`node_modules` trees (native modules such as the rspack bindings) are never +shared across Node ABIs, while the content-addressed pnpm store is shared +safely. + +## Parallelism and time budgets + +The integration pool derives workers from cores +(`rstest.integration.config.ts`), tuned for a leg that owns the machine. The +runner instead slices the machine: with N concurrent Verify legs each leg +gets `min(4, cores / (2 N))` integration workers +(`AGENT_BUNDLE_INTEGRATION_MAX_WORKERS`) and `cores / N` unit workers +(`--pool.maxWorkers`), and full runs pin `AGENT_BUNDLE_TEST_TIME_SCALE=4` — +the same polling-budget scale hosted CI uses — because four legs sharing a +machine is exactly the contention that scale exists for. Exporting +`AGENT_BUNDLE_TEST_TIME_SCALE` yourself (e.g. when the machine is also +running other heavy work) overrides the default; the integration config +never lets it drop below what its own pool shape requires. + +## What is deliberately not covered + +- **dependency-review** runs as a GitHub-side action against the GitHub + advisory database on the PR diff; it has no local equivalent and stays a + hosted-only, PR-time check. +- **package-preview** (pkg.pr.new) and the **release publish** workflow are + publish-side effects, not checks; nothing about them gates a merge. +- **native-host-smoke** needs signed-in Claude/Codex CLIs and is opt-in even + on hosted CI. +- **Environment skew**: hosted runners are `ubuntu-latest` with the exact + glibc/OS package set `--with-deps` installs, and hosted Verify installs + branded Chrome fresh. A green local run on a different distro, glibc, or + Chrome build is strong but not identical evidence — this is the main + reason hosted CI remains the post-merge safety net. The local `browsers` + step only validates/installs the browser itself (`playwright install + chrome`); the OS dependencies (`--with-deps`) are one-time machine setup + and may need root. +- **Job isolation**: hosted gives every job a fresh VM; local legs reuse + worktrees for speed. `--fresh` restores cold-start fidelity when staleness + is suspected. diff --git a/package.json b/package.json index f3ef963bd..e6ee0ab5b 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "lint": "rslint .", "typecheck": "tsc --noEmit && tsc --project packages/workbench/tsconfig.json && tsc --project packages/create-agent-bundle/tsconfig.json", "check": "pnpm build && pnpm test:unit && pnpm test:integration:run && pnpm lint && pnpm typecheck", + "check:local-ci": "node scripts/local-ci.mjs", "docs:runtime-topology": "node scripts/rsc-runtime-topology.mjs --root . --output docs/architecture/rsc-runtime-workbench.md", "eval:spot": "pnpm build && pnpm --filter @agent-bundle/rsc-agent-runtime-demo build && pnpm --filter @agent-bundle/rsc-agent-runtime-demo exec rstest run tests/micro-eval.spot.test.ts --config rstest.config.ts", "check:runtime-topology": "node scripts/rsc-runtime-topology.mjs --root . --output docs/architecture/rsc-runtime-workbench.md --check", diff --git a/rstest.integration.config.ts b/rstest.integration.config.ts index 2180d36f0..d5b3d89a2 100644 --- a/rstest.integration.config.ts +++ b/rstest.integration.config.ts @@ -23,6 +23,20 @@ const maxWorkers = Number.isSafeInteger(overrideWorkers) && overrideWorkers >= 1 ? 1 : Math.max(1, Math.min(4, Math.floor(availableParallelism() / 2))); +/** + * Polling budgets scale with contention. A multi-worker pool needs at least + * 2 (see the env comment below); an externally set + * AGENT_BUNDLE_TEST_TIME_SCALE raises it further when the machine is shared — + * scripts/local-ci.mjs passes 4 (hosted CI's own scale) because it runs + * three Node legs plus the release gates concurrently. The external value + * never lowers the scale below what the pool shape requires. + */ +const externalTimeScale = Number(process.env['AGENT_BUNDLE_TEST_TIME_SCALE'] ?? ''); +const poolTimeScale = maxWorkers > 1 ? 2 : 1; +const timeScale = Number.isSafeInteger(externalTimeScale) && externalTimeScale >= 1 + ? Math.max(externalTimeScale, poolTimeScale) + : poolTimeScale; + /** * Build- and process-running tests that only read workspace-shared artifacts; * files that WRITE shared locations (root builds, `npm pack`) run through the @@ -36,7 +50,7 @@ export default defineConfig({ // parallel runs double the polling budgets (see tests/support/time-scale.ts) // and raise the 5s default test timeout, which real in-process builds can // exceed when workers share the machine. Explicit per-test timeouts win. - env: { AGENT_BUNDLE_TEST_TIME_SCALE: maxWorkers > 1 ? '2' : '1' }, + env: { AGENT_BUNDLE_TEST_TIME_SCALE: String(timeScale) }, testTimeout: 30_000, // isolate: false would cut Playwright startup cost, but the log pipeline // suites rely on per-file module isolation (verified: logs-real.e2e fails diff --git a/scripts/local-ci.mjs b/scripts/local-ci.mjs new file mode 100644 index 000000000..04f264ca6 --- /dev/null +++ b/scripts/local-ci.mjs @@ -0,0 +1,432 @@ +/** + * Local CI gate — one command that proves what the hosted CI gate jobs prove + * (.github/workflows/ci.yml), on the development machine, across the same + * three Node.js majors, in parallel. `pnpm check:local-ci` runs everything; + * `pnpm check:local-ci --current-node-only` runs one Verify leg on the + * current Node for quick iteration. docs/local-ci.md is the workflow + * contract (including what is deliberately NOT covered); keep it in sync. + * + * Hosted job → local leg mapping: + * - verify (Node 22.19 / 24 / 26) → legs `verify-node22|24|26`: install, + * playwright chrome, build, lint:package, typecheck, lint, test:unit, + * test:integration — the same package scripts in the same order. + * - examples-check + release-gates + rsc-runtime-micro-eval (all Node 22.19) + * → leg `gates-node22`: examples:check, check:release, eval:spot run + * sequentially in one worktree. Each of those scripts starts from + * `pnpm build`, so folding three hosted checkouts into one worktree keeps + * every artifact expectation they have. + * - dependency-review (GitHub-side action), package-preview and the release + * publish (publish-side), native-host-smoke (opt-in, needs signed-in host + * CLIs) are intentionally not mirrored — see docs/local-ci.md. + * + * Isolation model: every leg gets its own git worktree pinned to HEAD with + * its own node_modules — node_modules trees with native modules must never + * be shared across Node ABIs. The shared pnpm store is content-addressed + * (and side-effects caches are keyed by engine), so concurrent per-leg + * installs stay cheap. Legs live under .worktrees/local-ci/ (gitignored) and + * are reused across runs for warm caches; `--fresh` recreates them. + */ +import { spawn } from 'node:child_process'; +import { execFile as executeFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { chmod, mkdir, readdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { availableParallelism, homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const execFile = promisify(executeFile); +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +const usage = `Usage: pnpm check:local-ci [--current-node-only] [--fresh] + --current-node-only Run a single Verify leg on the Node currently on PATH + (fast iteration path; skips the Node matrix and the + examples/release/micro-eval gates). + --fresh Recreate the per-leg worktrees from scratch (drops + their node_modules and build caches).`; + +const cliArguments = new Set(process.argv.slice(2)); +const currentNodeOnly = cliArguments.delete('--current-node-only'); +const freshLegs = cliArguments.delete('--fresh'); +if (cliArguments.delete('--help') || cliArguments.delete('-h')) { + console.log(usage); + process.exit(0); +} +if (cliArguments.size > 0) { + console.error(`Unknown arguments: ${[...cliArguments].join(' ')}\n${usage}`); + process.exit(2); +} + +const legsRoot = process.env.AGENT_BUNDLE_LOCAL_CI_DIR ?? join(repositoryRoot, '.worktrees', 'local-ci'); +const logsRoot = join(legsRoot, 'logs'); + +const execGit = (args) => execFile('git', args, { cwd: repositoryRoot }); + +/** The hosted Verify matrix. `spec` feeds version-manager lookups; `matches` pins the leg to the hosted runtime line. */ +const hostedNodeLines = [ + { key: '22', spec: '22.19', hostedRuntime: '22.19.0', matches: (v) => v.major === 22 && v.minor === 19 }, + { key: '24', spec: '24', hostedRuntime: '24', matches: (v) => v.major === 24 }, + { key: '26', spec: '26', hostedRuntime: '26', matches: (v) => v.major === 26 }, +]; + +const parseNodeVersion = (raw) => { + const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(raw.trim()); + if (match === null) throw new Error(`Unparseable Node version: ${raw}`); + return { major: Number(match[1]), minor: Number(match[2]), text: raw.trim().replace(/^v/, 'v') }; +}; + +const nodeBinaryVersion = async (binary) => parseNodeVersion((await execFile(binary, ['--version'])).stdout); + +/** + * Resolve a Node binary for a hosted runtime line without introducing new + * tooling: an explicit override, then mise, then nvm, then the current + * process. Anything found is version-checked against the hosted line. + */ +const resolveNodeBinary = async (line) => { + const candidates = []; + const override = process.env[`AGENT_BUNDLE_LOCAL_CI_NODE_${line.key}`]; + if (override !== undefined && override.length > 0) { + candidates.push(existsSync(join(override, 'node')) ? join(override, 'node') : override); + } + try { + const { stdout } = await execFile('mise', ['where', `node@${line.spec}`]); + candidates.push(join(stdout.trim(), 'bin', 'node')); + } catch { + // mise is absent or has no matching install; fall through. + } + try { + const nvmVersions = await readdir(join(homedir(), '.nvm', 'versions', 'node')); + const matching = nvmVersions + .map((name) => { + try { + return parseNodeVersion(name); + } catch { + return undefined; + } + }) + .filter((version) => version !== undefined && line.matches(version)) + .sort((a, b) => b.major - a.major || b.minor - a.minor); + if (matching.length > 0) candidates.push(join(homedir(), '.nvm', 'versions', 'node', matching[0].text, 'bin', 'node')); + } catch { + // No nvm directory; fall through. + } + candidates.push(process.execPath); + for (const candidate of candidates) { + if (!existsSync(candidate)) continue; + try { + const version = await nodeBinaryVersion(candidate); + if (line.matches(version)) return { binary: candidate, version }; + } catch { + // Candidate is not a runnable Node binary; keep looking. + } + } + throw new Error( + `No Node ${line.spec}.x found for the ${line.hostedRuntime} leg. Install one non-interactively ` + + `(e.g. \`mise install node@${line.spec}\` or \`nvm install ${line.spec}\`) or point ` + + `AGENT_BUNDLE_LOCAL_CI_NODE_${line.key} at a Node binary or bin directory.`, + ); +}; + +/** + * The pnpm CLI entrypoint, pinned to the pnpm this run was launched with: + * npm_execpath when launched through `pnpm check:local-ci`, otherwise the + * `pnpm` on PATH (a corepack shim is a Node script, which works the same + * way). Each leg runs this entrypoint ON ITS OWN Node via a wrapper, so + * pnpm and every lifecycle child agree on the leg's runtime. + */ +const resolvePnpmEntrypoint = async () => { + const fromEnvironment = process.env.npm_execpath; + if (fromEnvironment !== undefined && /pnpm/i.test(fromEnvironment) && existsSync(fromEnvironment)) return fromEnvironment; + const { stdout } = await execFile('/bin/sh', ['-c', 'command -v pnpm']); + const onPath = stdout.trim(); + if (onPath.length === 0) throw new Error('pnpm was not found on PATH.'); + return onPath; +}; + +const scrubbedExactKeys = new Set(['CI', 'NODE', 'NODE_ENV', 'NODE_OPTIONS', 'NODE_PATH', 'INIT_CWD']); +const scrubbedPrefixPattern = /^(npm_|PNPM_|COREPACK_|GITHUB_|AGENT_BUNDLE_)/i; + +/** + * A leg environment starts from the caller's environment minus everything a + * package-manager parent or CI shell would leak in (npm_, PNPM_, CI, ...), + * with PATH rebuilt so the leg's Node wins and no workspace .bin leaks in. + */ +const buildLegEnvironment = (syntheticBinDirectory, overrides) => { + const environment = {}; + for (const [key, value] of Object.entries(process.env)) { + if (scrubbedExactKeys.has(key) || scrubbedPrefixPattern.test(key)) continue; + environment[key] = value; + } + const retainedPath = (process.env.PATH ?? '') + .split(':') + .filter((entry) => entry.length > 0 && !entry.includes('/node_modules/.bin')); + environment.PATH = [syntheticBinDirectory, ...retainedPath].join(':'); + environment.COREPACK_ENABLE_DOWNLOAD_PROMPT = '0'; + environment.COREPACK_ENABLE_AUTO_PIN = '0'; + for (const [key, value] of Object.entries(overrides)) { + if (value !== undefined) environment[key] = value; + } + return environment; +}; + +/** + * node symlink, npm/npx exec wrappers, and a pnpm wrapper that pins pnpm to + * the leg's Node. npm and npx must be exec wrappers to their real paths (not + * symlinks): npm's shell launcher resolves its Node sibling relative to $0 + * without following symlinks, so a symlinked npm looks for node next to the + * symlink and finds nothing. + */ +const createSyntheticBinDirectory = async (leg, pnpmEntrypoint) => { + const directory = join(legsRoot, '.bin', leg.name); + await rm(directory, { recursive: true, force: true }); + await mkdir(directory, { recursive: true }); + const nodeBinDirectory = dirname(leg.nodeBinary); + await symlink(leg.nodeBinary, join(directory, 'node')); + const writeExecWrapper = async (name, commandLine) => { + const wrapper = join(directory, name); + await writeFile(wrapper, `#!/bin/sh\nexec ${commandLine} "$@"\n`); + await chmod(wrapper, 0o755); + }; + for (const tool of ['npm', 'npx']) { + const source = join(nodeBinDirectory, tool); + if (existsSync(source)) await writeExecWrapper(tool, `"${source}"`); + } + await writeExecWrapper('pnpm', `"${leg.nodeBinary}" "${pnpmEntrypoint}"`); + return directory; +}; + +const ensureLegWorktree = async (legDirectory, sha) => { + if (freshLegs) { + await execGit(['worktree', 'remove', '--force', legDirectory]).catch(() => {}); + await rm(legDirectory, { recursive: true, force: true }); + } + try { + await execFile('git', ['rev-parse', '--is-inside-work-tree'], { cwd: legDirectory }); + await execFile('git', ['reset', '--hard', sha], { cwd: legDirectory }); + return; + } catch { + // Not an existing worktree: (re)create it. + } + await rm(legDirectory, { recursive: true, force: true }); + await execGit(['worktree', 'prune']); + await execGit(['worktree', 'add', '--detach', legDirectory, sha]); +}; + +const formatDuration = (milliseconds) => { + const totalSeconds = Math.round(milliseconds / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return minutes > 0 ? `${minutes}m${String(seconds).padStart(2, '0')}s` : `${seconds}s`; +}; + +/** + * Test census for the summary table, from the repo reporter's JSON counts + * block (last one in the step log; a step like examples:check aggregates + * several suites and is labelled as such). + */ +const extractTestCensus = (logText) => { + const blocks = [ + ...logText.matchAll( + /"testFiles":\s*(\d+)[\s\S]{0,200}?"tests":\s*(\d+),\s*"failedTests":\s*(\d+),\s*"passedTests":\s*(\d+),\s*"skippedTests":\s*(\d+)/g, + ), + ]; + if (blocks.length === 0) return undefined; + const [, files, tests, , passed, skipped] = blocks[blocks.length - 1]; + const skippedSuffix = Number(skipped) > 0 ? `, ${skipped} skipped` : ''; + const summary = `${passed}/${tests} passed${skippedSuffix}, ${files} files`; + return blocks.length > 1 ? `${summary} (last of ${blocks.length} suites)` : summary; +}; + +const runStep = async (leg, step, stepIndex) => { + const logPath = join(logsRoot, leg.name, `${String(stepIndex + 1).padStart(2, '0')}-${step.id.replaceAll(':', '-')}.log`); + await mkdir(dirname(logPath), { recursive: true }); + const [tool, ...args] = step.command; + const command = tool === 'pnpm' ? join(leg.syntheticBinDirectory, 'pnpm') : tool; + const startedAt = Date.now(); + console.log(`[${leg.name}] ${step.id} started`); + const exitCode = await new Promise((resolveExit, rejectExit) => { + const child = spawn(command, args, { cwd: leg.directory, env: leg.environment, stdio: ['ignore', 'pipe', 'pipe'] }); + const chunks = []; + child.stdout.on('data', (chunk) => chunks.push(chunk)); + child.stderr.on('data', (chunk) => chunks.push(chunk)); + child.on('error', (error) => { + chunks.push(Buffer.from(`\nFailed to spawn ${command}: ${error.message}\n`)); + writeFile(logPath, Buffer.concat(chunks)).then(() => resolveExit(1), rejectExit); + }); + child.on('close', (code) => { + writeFile(logPath, Buffer.concat(chunks)).then(() => resolveExit(code ?? 1), rejectExit); + }); + }); + const durationMs = Date.now() - startedAt; + const logText = await readFile(logPath, 'utf8').catch(() => ''); + const census = extractTestCensus(logText); + const status = exitCode === 0 ? 'pass' : 'fail'; + console.log(`[${leg.name}] ${step.id} ${status} in ${formatDuration(durationMs)}${census === undefined ? '' : ` (${census})`}`); + if (status === 'fail') { + const tail = logText.split('\n').slice(-25).join('\n'); + console.error(`[${leg.name}] ${step.id} failed (exit ${exitCode}). Log: ${logPath}\n${tail}`); + } + return { leg: leg.name, step: step.id, hostedJob: step.hostedJob, status, durationMs, census, logPath }; +}; + +/** Steps run sequentially inside a leg; a failure skips the leg's remaining steps (matching a hosted job's step semantics). */ +const runLeg = async (leg) => { + const results = []; + let failed = false; + for (const [index, step] of leg.steps.entries()) { + if (failed) { + results.push({ leg: leg.name, step: step.id, hostedJob: step.hostedJob, status: 'skipped', durationMs: 0 }); + continue; + } + const result = await runStep(leg, step, index); + results.push(result); + if (result.status === 'fail') failed = true; + } + return results; +}; + +const main = async () => { + const startedAt = Date.now(); + const { stdout: shaRaw } = await execGit(['rev-parse', 'HEAD']); + const sha = shaRaw.trim(); + const { stdout: dirtyRaw } = await execGit(['status', '--porcelain']); + if (dirtyRaw.trim().length > 0) { + console.warn( + '\nWARNING: the working tree has uncommitted changes. Legs check out the HEAD commit ' + + `(${sha.slice(0, 8)}); uncommitted work is NOT covered by this gate.\n`, + ); + } + + const totalCores = availableParallelism(); + const verifyLegCount = currentNodeOnly ? 1 : 3; + // Per-leg cap over the repo's own derivation (integration config computes + // min(4, cores/2) for a leg that owns the machine): give each concurrent + // leg a cores/N slice before halving, and keep the memory-bounding cap of 4. + const integrationWorkerCap = Math.max(1, Math.min(4, Math.floor(totalCores / (2 * verifyLegCount)))); + const unitWorkerCap = Math.max(2, Math.floor(totalCores / verifyLegCount)); + // Full runs pin the polling-budget scale to 4 — the same scale hosted CI + // uses — because three legs plus the gates leg share the machine by design. + const timeScale = process.env.AGENT_BUNDLE_TEST_TIME_SCALE ?? (currentNodeOnly ? undefined : '4'); + + const pnpmEntrypoint = await resolvePnpmEntrypoint(); + + const verifyStepList = (unitCapArguments) => [ + { id: 'install', hostedJob: 'verify', command: ['pnpm', 'install', '--frozen-lockfile'] }, + // Hosted runs `playwright install --with-deps chrome`; --with-deps is + // apt/root-only and the OS packages are a one-time machine setup, so the + // local step installs/validates the browser only. + { id: 'browsers', hostedJob: 'verify', command: ['pnpm', 'exec', 'playwright', 'install', 'chrome'] }, + { id: 'build', hostedJob: 'verify', command: ['pnpm', 'build'] }, + { id: 'lint:package', hostedJob: 'verify', command: ['pnpm', 'lint:package'] }, + { id: 'typecheck', hostedJob: 'verify', command: ['pnpm', 'typecheck'] }, + { id: 'lint', hostedJob: 'verify', command: ['pnpm', 'lint'] }, + { id: 'test:unit', hostedJob: 'verify', command: ['pnpm', 'test:unit', ...unitCapArguments] }, + { id: 'test:integration', hostedJob: 'verify', command: ['pnpm', 'test:integration'] }, + ]; + + const legPlans = []; + if (currentNodeOnly) { + const version = await nodeBinaryVersion(process.execPath); + legPlans.push({ + name: `verify-current-${version.text}`, + nodeBinary: process.execPath, + nodeVersion: version, + // Fast path: keep the repo's own local defaults (this leg owns the machine). + environmentOverrides: {}, + steps: verifyStepList([]), + }); + } else { + const sharedOverrides = { + AGENT_BUNDLE_INTEGRATION_MAX_WORKERS: String(integrationWorkerCap), + AGENT_BUNDLE_TEST_TIME_SCALE: timeScale, + }; + for (const line of hostedNodeLines) { + const { binary, version } = await resolveNodeBinary(line); + legPlans.push({ + name: `verify-node${line.key}`, + nodeBinary: binary, + nodeVersion: version, + environmentOverrides: sharedOverrides, + steps: verifyStepList(['--pool.maxWorkers', String(unitWorkerCap)]), + }); + } + const node22 = legPlans.find((leg) => leg.name === 'verify-node22'); + legPlans.push({ + name: 'gates-node22', + nodeBinary: node22.nodeBinary, + nodeVersion: node22.nodeVersion, + environmentOverrides: sharedOverrides, + steps: [ + { id: 'install', hostedJob: 'examples-check/release-gates/micro-eval', command: ['pnpm', 'install', '--frozen-lockfile'] }, + { id: 'browsers', hostedJob: 'release-gates', command: ['pnpm', 'exec', 'playwright', 'install', 'chrome'] }, + { id: 'examples:check', hostedJob: 'examples-check', command: ['pnpm', 'examples:check'] }, + { id: 'check:release', hostedJob: 'release-gates', command: ['pnpm', 'check:release'] }, + { id: 'eval:spot', hostedJob: 'rsc-runtime-micro-eval', command: ['pnpm', 'eval:spot'] }, + ], + }); + } + + console.log(`Local CI gate at ${sha.slice(0, 8)} — ${legPlans.length} leg(s), ${totalCores} cores`); + console.log( + `Caps: integration ${integrationWorkerCap} worker(s)/leg, unit ${currentNodeOnly ? '(repo default)' : `${unitWorkerCap} workers/leg`}, ` + + `time scale ${timeScale ?? '(repo default)'}`, + ); + for (const leg of legPlans) console.log(` ${leg.name}: ${leg.nodeVersion.text} (${leg.nodeBinary})`); + + await mkdir(legsRoot, { recursive: true }); + await rm(logsRoot, { recursive: true, force: true }); + const legs = []; + for (const plan of legPlans) { + const directory = join(legsRoot, plan.name); + await ensureLegWorktree(directory, sha); + const syntheticBinDirectory = await createSyntheticBinDirectory(plan, pnpmEntrypoint); + legs.push({ + ...plan, + directory, + syntheticBinDirectory, + environment: buildLegEnvironment(syntheticBinDirectory, plan.environmentOverrides), + }); + } + + const legResults = await Promise.all(legs.map((leg) => runLeg(leg))); + const results = legResults.flat(); + const wallMs = Date.now() - startedAt; + const failed = results.some((result) => result.status === 'fail'); + + const rows = results.map((result) => [ + result.leg, + result.step, + result.status, + result.status === 'skipped' ? '—' : formatDuration(result.durationMs), + result.census ?? '—', + ]); + const header = ['Leg', 'Step', 'Status', 'Duration', 'Tests']; + const widths = header.map((title, column) => Math.max(title.length, ...rows.map((row) => row[column].length))); + const renderRow = (row) => `| ${row.map((cell, column) => cell.padEnd(widths[column])).join(' | ')} |`; + const tableLines = [ + renderRow(header), + `| ${widths.map((width) => '-'.repeat(width)).join(' | ')} |`, + ...rows.map(renderRow), + ]; + const summaryMarkdown = [ + `# Local CI gate — ${new Date(startedAt).toISOString()}`, + '', + `Commit ${sha} · ${legs.length} leg(s) · wall time ${formatDuration(wallMs)} · ${failed ? 'FAILED' : 'GREEN'}`, + '', + ...tableLines, + '', + 'Not covered locally: dependency-review (GitHub-side), package previews and npm publish (publish-side), native host smokes (opt-in). See docs/local-ci.md.', + '', + ].join('\n'); + + const summaryPath = join(legsRoot, 'summary.md'); + await writeFile(summaryPath, summaryMarkdown); + await writeFile(join(legsRoot, 'summary.json'), `${JSON.stringify({ sha, startedAt, wallMs, failed, results }, null, 2)}\n`); + + console.log(`\n${tableLines.join('\n')}`); + console.log(`\nWall time ${formatDuration(wallMs)} · summary written to ${summaryPath}`); + console.log(failed ? 'Local CI gate: FAILED' : 'Local CI gate: GREEN'); + process.exitCode = failed ? 1 : 0; +}; + +await main();