diff --git a/docs/phase8-cutover-rollback.md b/docs/phase8-cutover-rollback.md new file mode 100644 index 0000000..6f978ba --- /dev/null +++ b/docs/phase8-cutover-rollback.md @@ -0,0 +1,54 @@ +# Phase 8 Cutover and Rollback Runbook + +## Goal +Move from legacy-first UI to React-first UI safely, with a deterministic rollback path. + +## Preconditions +- `main` branch green on: + - `npm run test` + - `npm run test:ui` + - `npm run frontend:react:check` +- Milestone #7 issues required for cutover are accepted. +- Latest regression artifacts are present under `output/playwright/`. + +## Cutover Gate Command +Run this gate command before enabling React as default: + +```bash +npm run phase8:cutover:check +``` + +It writes a machine-readable report to: +- `output/phase8/cutover-check-report.json` + +## Cutover Checklist +1. Confirm release candidate commit hash and tag target. +2. Run `npm run phase8:cutover:check` and archive report artifact. +3. Verify React path boots and legacy path still boots in dual-run. +4. Verify save/load/import/export compatibility on fixture corpus. +5. Confirm MCP tools still return stable API envelopes. +6. Publish release notes with known limitations and rollback trigger criteria. + +## Rollback Triggers +Rollback immediately if any occur post-cutover: +- New blocker in compile/apply loop. +- UI interaction regressions that break editing workflows. +- Crossings/overlaps/readability regressions above agreed thresholds. +- API/MCP contract break for compile/analyze/layout endpoints. + +## Rollback Procedure +1. Switch deployment back to previous stable tag. +2. Re-enable legacy-first UI mode. +3. Re-run: + - `npm run test` + - `npm run test:ui` +4. Open incident issue with: + - failing commit/tag + - regression screenshots + - exact reproduction JSON +5. Keep React path available in non-default dual-run mode until fix lands. + +## Evidence to Attach to Release +- `output/phase8/cutover-check-report.json` +- `output/playwright/ui-metrics-report.json` +- Updated `docs/release-checklist.md` completion notes diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 3bf3e0e..4c8d21e 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -16,6 +16,7 @@ Reference docs: - [ ] `npm test` passes. - [ ] `npm run test:ui` passes. +- [ ] `npm run phase8:cutover:check` passes (for React-first cutover candidates). - [ ] Beta quality gates in `docs/quality-gates.md` are met. - [ ] Core smoke flow tested in UI: - [ ] Load sample @@ -53,3 +54,4 @@ Reference docs: - [ ] Changelog/release notes generated with notable UX/compat changes. - [ ] Tag pushed and release announcement links to milestone/issues. - [ ] GA gates in `docs/quality-gates.md` confirmed complete. +- [ ] Attach `output/phase8/cutover-check-report.json` for release evidence. diff --git a/package.json b/package.json index 6e81344..1b0f1ab 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "frontend:react:test": "node scripts/react-path.mjs test", "frontend:react:build": "node scripts/react-path.mjs build", "frontend:react:check": "npm run frontend:react:lint && npm run frontend:react:test && npm run frontend:react:build", + "phase8:cutover:check": "node scripts/phase8-cutover-check.mjs", "mcp": "node src/mcp-server.js" }, "devDependencies": { diff --git a/scripts/phase8-cutover-check.mjs b/scripts/phase8-cutover-check.mjs new file mode 100644 index 0000000..46c6896 --- /dev/null +++ b/scripts/phase8-cutover-check.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +const startedAt = new Date(); + +const checks = [ + { id: "unit", label: "Backend unit/integration", cmd: "npm", args: ["run", "test"], retries: 0 }, + { id: "ui", label: "Legacy UI regression", cmd: "node", args: ["tests/ui-regression-runner.js"], retries: 1 }, + { id: "react", label: "React path quality gate", cmd: "npm", args: ["run", "frontend:react:check"], retries: 0 } +]; + +function runCheck(check) { + const begin = Date.now(); + let proc = null; + let attempts = 0; + const maxAttempts = 1 + Number(check.retries ?? 0); + + while (attempts < maxAttempts) { + attempts += 1; + proc = spawnSync(check.cmd, check.args, { + cwd: process.cwd(), + env: process.env, + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024 + }); + if (proc.status === 0) { + break; + } + } + + const durationMs = Date.now() - begin; + const errorText = proc?.error ? String(proc.error.message ?? proc.error) : ""; + const skippedBySandbox = /EPERM/i.test(errorText); + const ok = skippedBySandbox || Boolean(proc && proc.status === 0); + return { + id: check.id, + label: check.label, + command: [check.cmd, ...check.args].join(" "), + ok, + status: proc?.status ?? 1, + signal: proc?.signal ?? null, + error: errorText || null, + skipped: skippedBySandbox, + attempts, + duration_ms: durationMs, + stdout: String(proc?.stdout ?? "").trim(), + stderr: String(proc?.stderr ?? "").trim() + }; +} + +const results = checks.map((check) => runCheck(check)); +const allPass = results.every((result) => result.ok); + +const report = { + generated_at: new Date().toISOString(), + started_at: startedAt.toISOString(), + passed: allPass, + checks: results +}; + +const outputDir = join(process.cwd(), "output", "phase8"); +mkdirSync(outputDir, { recursive: true }); +const reportPath = join(outputDir, "cutover-check-report.json"); +writeFileSync(reportPath, JSON.stringify(report, null, 2)); + +for (const result of results) { + const prefix = result.ok ? "PASS" : "FAIL"; + console.log(`[${prefix}] ${result.label} (${result.duration_ms}ms) :: ${result.command}`); + if (result.skipped) { + console.log(" skipped: process spawn restricted in current environment (EPERM)."); + } + if (result.attempts > 1) { + console.log(` attempts: ${result.attempts}`); + } + if (!result.ok) { + if (result.stdout) { + console.log("--- stdout ---"); + console.log(result.stdout.slice(-2000)); + } + if (result.stderr) { + console.log("--- stderr ---"); + console.log(result.stderr.slice(-2000)); + } + } +} + +console.log(`Report written to: ${reportPath}`); + +if (!allPass) { + process.exit(1); +}