From 773dfd95bdd61a0a1fbc8305d0ad15e9979543af Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 5 Apr 2026 08:39:23 -0500 Subject: [PATCH] Add pre-release release validation checklist - Add a release validator script and npm script - Document RC soak, rollout, and preflight checks for v0.16.0 --- docs/release.md | 19 +- docs/releases/v0.16.0.md | 4 + docs/releases/v0.16.0/assets.md | 7 + docs/releases/v0.16.0/rollout-checklist.md | 177 +++++++ docs/releases/v0.16.0/soak-test-plan.md | 217 +++++++++ package.json | 1 + scripts/pre-release-validate.ts | 527 +++++++++++++++++++++ 7 files changed, 949 insertions(+), 3 deletions(-) create mode 100644 docs/releases/v0.16.0/rollout-checklist.md create mode 100644 docs/releases/v0.16.0/soak-test-plan.md create mode 100644 scripts/pre-release-validate.ts diff --git a/docs/release.md b/docs/release.md index 35c6515fd..37e9d6a75 100644 --- a/docs/release.md +++ b/docs/release.md @@ -62,6 +62,16 @@ bun run release:smoke `bun run lint` is a zero-warning gate. +### Pre-release validation + +Run the comprehensive pre-release validator before cutting any RC or promoting to stable: + +```bash +bun run release:validate +``` + +This checks documentation completeness, version alignment, git state, iOS project version, and optionally runs all quality gates. Use `--skip-quality` for a docs-only pass or `--ci` for CI pipelines. + ## Platform matrix Blocking stable matrix: @@ -127,9 +137,12 @@ Before tagging: - `CHANGELOG.md` - `docs/releases/vX.Y.Z.md` - `docs/releases/vX.Y.Z/assets.md` -3. Confirm Apple signing/notarization secrets. -4. Confirm Windows signing secrets. -5. Confirm `NODE_AUTH_TOKEN`, `RELEASE_APP_ID`, and `RELEASE_APP_PRIVATE_KEY`. + - `docs/releases/vX.Y.Z/rollout-checklist.md` (version-specific rollout playbook) + - `docs/releases/vX.Y.Z/soak-test-plan.md` (version-specific soak test cases) +3. Run `bun run release:validate ` and fix any failures. +4. Confirm Apple signing/notarization secrets. +5. Confirm Windows signing secrets. +6. Confirm `NODE_AUTH_TOKEN`, `RELEASE_APP_ID`, and `RELEASE_APP_PRIVATE_KEY`. ## RC soak rules diff --git a/docs/releases/v0.16.0.md b/docs/releases/v0.16.0.md index 39769bdef..6ca683b46 100644 --- a/docs/releases/v0.16.0.md +++ b/docs/releases/v0.16.0.md @@ -28,6 +28,10 @@ Right-panel diff review, editable code previews, stronger branch/worktree handli - **Desktop:** Download from [GitHub Releases](https://github.com/OpenKnots/okcode/releases/tag/v0.16.0). This release includes signed macOS arm64/x64 DMGs, Linux x64 AppImage, and Windows x64 NSIS installer. Filenames are listed in [assets.md](v0.16.0/assets.md). - **iOS:** Available via TestFlight (uploaded automatically by the coordinated release workflow). +## Rollout + +See the [rollout checklist](v0.16.0/rollout-checklist.md) for the step-by-step release playbook and [soak test plan](v0.16.0/soak-test-plan.md) for structured RC validation test cases. + ## Known limitations OK Code remains early work in progress. Keep an eye on reconnect behavior, long-running stream recovery, and platform-specific desktop packaging edge cases during RC soak and stable promotion. diff --git a/docs/releases/v0.16.0/assets.md b/docs/releases/v0.16.0/assets.md index 793e16b7d..923805f62 100644 --- a/docs/releases/v0.16.0/assets.md +++ b/docs/releases/v0.16.0/assets.md @@ -45,6 +45,13 @@ The iOS build is uploaded directly to App Store Connect / TestFlight by the [Rel | Marketing version | `0.16.0` | | Build number | Set from `GITHUB_RUN_NUMBER` at build time | +## Rollout documentation + +| Document | Purpose | +| -------- | ------- | +| [rollout-checklist.md](rollout-checklist.md) | Step-by-step release playbook (pre-flight through post-release) | +| [soak-test-plan.md](soak-test-plan.md) | Structured 48-hour RC soak test cases | + ## Checksums SHA-256 checksums are not committed here; verify downloads via GitHub's release UI or `gh release download` if you use the GitHub CLI. diff --git a/docs/releases/v0.16.0/rollout-checklist.md b/docs/releases/v0.16.0/rollout-checklist.md new file mode 100644 index 000000000..87ad6dcf5 --- /dev/null +++ b/docs/releases/v0.16.0/rollout-checklist.md @@ -0,0 +1,177 @@ +# v0.16.0 Rollout Checklist + +Step-by-step playbook for the v0.16.0 release. Each phase must complete before advancing to the next. + +## Phase 0: Pre-flight (before tagging) + +- [ ] Verify all package versions are `0.16.0`: + - `apps/server/package.json` + - `apps/desktop/package.json` + - `apps/web/package.json` + - `apps/mobile/package.json` + - `packages/contracts/package.json` +- [ ] Verify iOS `MARKETING_VERSION` matches in `project.pbxproj`. +- [ ] Confirm `CHANGELOG.md` has `## [0.16.0] - 2026-04-05` entry. +- [ ] Confirm `docs/releases/v0.16.0.md` exists with Summary, Highlights, Upgrade sections. +- [ ] Confirm `docs/releases/v0.16.0/assets.md` exists with all artifact tables. +- [ ] Confirm `docs/releases/README.md` includes v0.16.0 row. +- [ ] Run the pre-release validator: + ```bash + node scripts/pre-release-validate.ts 0.16.0 + ``` +- [ ] Confirm the working tree is clean (`git status --porcelain` is empty). +- [ ] Confirm you are on the `main` branch. + +### Secrets verification + +- [ ] Apple signing certificate (`CSC_LINK`, `CSC_KEY_PASSWORD`) is current and not expired. +- [ ] Apple notarization API key (`APPLE_API_KEY`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`) is valid. +- [ ] Windows Azure Trusted Signing secrets are configured: + - `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` + - `AZURE_TRUSTED_SIGNING_ENDPOINT`, `AZURE_TRUSTED_SIGNING_ACCOUNT_NAME` + - `AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME`, `AZURE_TRUSTED_SIGNING_PUBLISHER_NAME` +- [ ] iOS signing (`IOS_CERTIFICATE_P12`, `IOS_CERTIFICATE_PASSWORD`, `IOS_PROVISIONING_PROFILE`) is valid. +- [ ] `NODE_AUTH_TOKEN` (npm) is active. +- [ ] `RELEASE_APP_ID` and `RELEASE_APP_PRIVATE_KEY` (GitHub App for finalize) are set. + +### Quality gates + +All seven gates must pass with zero warnings: + +- [ ] `bun run fmt:check` +- [ ] `bun run lint` +- [ ] `bun run typecheck` +- [ ] `bun run test` +- [ ] `bun run --cwd apps/web test:browser` +- [ ] `bun run test:desktop-smoke` +- [ ] `bun run release:smoke` + +## Phase 1: Cut the RC + +- [ ] Run the release preparation script: + ```bash + node scripts/prepare-release.ts 0.16.0-rc.1 \ + --full-matrix \ + --summary "Right-panel diff review, editable code previews, stronger branch handling, and a more stable release train" + ``` +- [ ] Verify the `v0.16.0-rc.1` tag was created and pushed. +- [ ] Verify `release.yml` workflow was triggered: + - Actions URL: https://github.com/OpenKnots/okcode/actions/workflows/release.yml +- [ ] Monitor the six-job pipeline: + 1. `Preflight` — quality gates + 2. `Desktop macOS arm64` — signed/notarized DMG + 3. `Desktop Linux x64` — AppImage + 4. `Desktop Windows x64` — signed NSIS installer + 5. `iOS TestFlight` — archive and upload + 6. `Publish CLI` — npm publish with `next` tag + 7. `Publish GitHub Release` — prerelease with all artifacts + 8. `Finalize` — version bump commit to main + +### Artifact verification (RC) + +- [ ] macOS arm64: Download DMG, verify Gatekeeper opens it without warning. +- [ ] Linux x64: Download AppImage, verify it launches. +- [ ] Windows x64: Download installer, verify it installs and launches. +- [ ] CLI: `npx okcodes@0.16.0-rc.1 --version` returns `0.16.0-rc.1`. +- [ ] CLI: `npx okcodes@0.16.0-rc.1 --help` renders help text. +- [ ] CLI: `npx okcodes@0.16.0-rc.1 doctor --help` renders doctor help. +- [ ] iOS: TestFlight build appears in App Store Connect within 30 minutes. +- [ ] GitHub Release: Prerelease flag is set, "latest" flag is not set. +- [ ] GitHub Release: All expected artifacts are attached (DMGs, AppImage, EXE, YMLs, blockmaps, docs). + +## Phase 2: RC Soak (48 hours) + +Start: ____-__-__T__:__Z +End: ____-__-__T__:__Z + +### Soak exit criteria + +- [ ] No Sev-1 or Sev-2 issues filed during soak period. +- [ ] No crash-on-launch on macOS arm64, Windows x64, or Linux x64. +- [ ] Updater verification: Install v0.15.0 desktop app, verify it detects and applies the v0.16.0-rc.1 update. +- [ ] TestFlight install succeeds on at least one device. +- [ ] `npx okcodes@0.16.0-rc.1 --version` still works at end of soak. + +### Feature-specific soak testing (v0.16.0) + +These checks validate the high-risk surface area introduced in this release: + +- [ ] **Right-panel diff viewer**: Open a thread with file changes, verify diffs render in the right panel. Toggle between files. Verify panel defaults and opening behavior. +- [ ] **Editable code preview**: Open a code preview, make edits, verify autosave persists. Reload the app, verify edits survived. +- [ ] **Rebase-before-commit flow**: Create a branch that has diverged from main. Trigger commit with rebase enabled. Verify conflicts are surfaced if they exist. Verify clean rebase completes. +- [ ] **GitHub repo cloning**: Clone a public repo via the entry point. Clone a private repo (verify auth flow). Verify the cloned repo opens in a thread. +- [ ] **Provider session restart on CWD change**: Start a session, then switch the active worktree. Verify the provider session restarts cleanly without user intervention. +- [ ] **Shell env sanitization**: Set unusual environment variables (e.g., modified `PATH`, `NODE_OPTIONS`). Launch an agent session. Verify the session starts without inheriting unsafe env. +- [ ] **Build metadata**: Verify the web UI displays the correct build version and commit hash. Verify the server reports matching metadata. + +### iOS TestFlight device checks + +Run on two devices: one current-generation and one older-generation supported device. + +Device 1: ______________ (iOS __.__) +Device 2: ______________ (iOS __.__) + +- [ ] Pair the mobile companion with a desktop/server instance. +- [ ] Restore a previously saved pairing. +- [ ] Open a thread and send a follow-up message. +- [ ] Approve an action and answer a user-input request. +- [ ] Background the app, wait 30 seconds, foreground it. +- [ ] Receive a notification and tap it to return to the thread. + +### If soak fails + +If any blocker is found during the soak period: +1. Fix the issue on `main`. +2. Cut `v0.16.0-rc.2` with the fix included. +3. Restart the 48-hour soak from the beginning. +4. Do NOT promote a failed RC to stable. + +## Phase 3: Promote to Stable + +Only after the 48-hour soak completes with all exit criteria met. + +- [ ] Verify the RC commit is the same commit that will be promoted (no new commits needed). +- [ ] Run the promotion: + ```bash + node scripts/prepare-release.ts 0.16.0 \ + --full-matrix \ + --summary "Right-panel diff review, editable code previews, stronger branch handling, and a more stable release train" + ``` +- [ ] Verify the `v0.16.0` tag was created on the same commit as `v0.16.0-rc.1`. +- [ ] Monitor all six workflow jobs to completion. + +### Artifact verification (stable) + +- [ ] macOS arm64 DMG: Launches, Gatekeeper passes. +- [ ] Linux x64 AppImage: Launches. +- [ ] Windows x64 installer: Installs and launches. +- [ ] CLI: `npm install -g okcodes@0.16.0` succeeds. +- [ ] CLI: `okcodes --version` returns `0.16.0`. +- [ ] iOS: New TestFlight build appears. +- [ ] GitHub Release: "latest" flag is set, prerelease flag is not set. +- [ ] GitHub Release: Release notes body matches `docs/releases/v0.16.0.md`. +- [ ] Updater: v0.15.0 desktop app detects and applies the v0.16.0 stable update. + +## Phase 4: Post-release + +- [ ] Verify `finalize` job pushed version bump commit to `main`. +- [ ] Verify `main` branch has `chore(release): prepare v0.16.0` commit. +- [ ] Trigger Intel compatibility build (optional, non-blocking): + ```bash + gh workflow run release-intel-compat.yml -f version=0.16.0 + ``` +- [ ] Update any external documentation or announcements referencing the new version. +- [ ] Close any GitHub issues resolved by this release. +- [ ] Monitor error reporting and community channels for 24 hours post-release. + +## Timeline + +| Phase | Duration | Starts after | +| ----- | -------- | ------------ | +| Pre-flight | ~30 min | Decision to release | +| Cut RC | ~45 min | Pre-flight passes | +| RC Soak | 48 hours | All RC artifacts verified | +| Promote to stable | ~45 min | Soak exit criteria met | +| Post-release monitoring | 24 hours | Stable artifacts verified | + +Total time from decision to stable: ~3 days minimum. diff --git a/docs/releases/v0.16.0/soak-test-plan.md b/docs/releases/v0.16.0/soak-test-plan.md new file mode 100644 index 000000000..4441f0d17 --- /dev/null +++ b/docs/releases/v0.16.0/soak-test-plan.md @@ -0,0 +1,217 @@ +# v0.16.0 RC Soak Test Plan + +Structured test plan for the 48-hour RC soak period. Each test case must be executed on at least the primary platform listed. Cross-platform variants are noted where applicable. + +## Test environments + +| Role | Platform | Version | +| ---- | -------- | ------- | +| Primary desktop | macOS arm64 | Sonoma or later | +| Secondary desktop | Windows x64 | Windows 11 | +| Tertiary desktop | Linux x64 | Ubuntu 24.04 | +| Primary mobile | iPhone (current gen) | iOS 17+ | +| Secondary mobile | iPhone (older gen) | iOS 16+ | +| CLI | Any | Node 22.16+ | + +## 1. Installation and launch + +### 1.1 Fresh install (all desktop platforms) + +| Step | Expected | Pass | +| ---- | -------- | ---- | +| Download the RC DMG/AppImage/installer from GitHub Releases | Download completes, correct file size | [ ] | +| Install the application | Installs without errors | [ ] | +| Launch the application for the first time | App window opens, no crash | [ ] | +| Verify build metadata in the UI | Shows v0.16.0-rc.1, correct commit hash | [ ] | + +### 1.2 Upgrade from v0.15.0 (macOS, Windows) + +| Step | Expected | Pass | +| ---- | -------- | ---- | +| Install v0.15.0 from the previous stable release | Installs and launches | [ ] | +| Allow the auto-updater to detect the RC | Updater notification appears | [ ] | +| Apply the update | App restarts on v0.16.0-rc.1 | [ ] | +| Verify existing sessions/settings survived the upgrade | Data is intact | [ ] | + +### 1.3 CLI install + +| Step | Expected | Pass | +| ---- | -------- | ---- | +| `npx okcodes@0.16.0-rc.1 --version` | Prints `0.16.0-rc.1` | [ ] | +| `npx okcodes@0.16.0-rc.1 --help` | Renders help text without errors | [ ] | +| `npx okcodes@0.16.0-rc.1 doctor --help` | Renders doctor subcommand help | [ ] | + +## 2. Core session lifecycle + +### 2.1 Start a new session + +| Step | Expected | Pass | +| ---- | -------- | ---- | +| Open the app, start a new thread | Thread initializes, provider session starts | [ ] | +| Send a message | Response streams in real-time | [ ] | +| Send a follow-up message | Context carries over, response is coherent | [ ] | +| Wait 60 seconds idle, then send another message | Session resumes without reconnect error | [ ] | + +### 2.2 Long-running session + +| Step | Expected | Pass | +| ---- | -------- | ---- | +| Run a multi-turn session (10+ messages) over 30 minutes | No memory leaks, no UI lag | [ ] | +| Switch between threads during the session | State preserved per-thread | [ ] | +| Close and reopen the app mid-session | Session state recovers | [ ] | + +## 3. New feature tests (v0.16.0 specific) + +### 3.1 Right-panel diff viewer + +| Step | Expected | Pass | +| ---- | -------- | ---- | +| Trigger a file change in a thread | Diff entry appears in work log | [ ] | +| Click the diff entry | Right panel opens with diff view | [ ] | +| Navigate between multiple changed files | Panel updates, no stale content | [ ] | +| Close the diff panel | Panel closes cleanly | [ ] | +| Reopen the diff panel | Previous diff state restored | [ ] | + +### 3.2 Editable code preview with autosave + +| Step | Expected | Pass | +| ---- | -------- | ---- | +| Open a code preview | Preview renders correctly | [ ] | +| Make edits in the preview | Changes appear immediately | [ ] | +| Wait 5 seconds (autosave interval) | No save indicator or error | [ ] | +| Navigate away from the preview, then return | Edits are preserved | [ ] | +| Close and reopen the app | Edits survive the restart | [ ] | + +### 3.3 Rebase-before-commit flow + +| Step | Expected | Pass | +| ---- | -------- | ---- | +| Create a branch that has diverged from main (both have new commits) | Branch exists | [ ] | +| Trigger commit with rebase-before-commit enabled | Rebase starts | [ ] | +| (Clean rebase) Verify the commit lands on rebased history | Commit appears with linear history | [ ] | +| (Conflict rebase) Create a branch with deliberate conflicts | Branch exists | [ ] | +| Trigger commit with rebase-before-commit | Conflict is detected and surfaced to the user | [ ] | +| Resolve conflict and retry | Commit succeeds after resolution | [ ] | + +### 3.4 GitHub repo cloning + +| Step | Expected | Pass | +| ---- | -------- | ---- | +| Use the clone entry point with a public repo URL | Repo clones, thread opens in the cloned project | [ ] | +| Use the clone entry point with a private repo URL | Auth flow triggers, repo clones after auth | [ ] | +| Clone a large repo (1GB+) | Progress indicator shows, clone completes | [ ] | +| Cancel a clone in progress | Clone stops cleanly, no partial state left | [ ] | + +### 3.5 Provider session restart on CWD change + +| Step | Expected | Pass | +| ---- | -------- | ---- | +| Start a session in worktree A | Session runs normally | [ ] | +| Switch active worktree to B | Provider session restarts automatically | [ ] | +| Send a message after the switch | Response uses worktree B context | [ ] | +| Switch back to worktree A | Session restarts again, context is correct | [ ] | + +### 3.6 Shell env sanitization + +| Step | Expected | Pass | +| ---- | -------- | ---- | +| Set `NODE_OPTIONS=--max-old-space-size=100` in shell env | Env is set | [ ] | +| Launch an agent session | Session starts without inheriting the restrictive NODE_OPTIONS | [ ] | +| Set `PATH` to include unusual directories | Env is set | [ ] | +| Launch an agent session | Session uses sanitized PATH | [ ] | + +### 3.7 Build metadata visibility + +| Step | Expected | Pass | +| ---- | -------- | ---- | +| Check version display in the web UI | Shows 0.16.0-rc.1 | [ ] | +| Check commit hash in the web UI | Matches the tagged commit | [ ] | +| Check server metadata endpoint/logs | Reports matching version and commit | [ ] | + +## 4. Viewport presets and orientation + +| Step | Expected | Pass | +| ---- | -------- | ---- | +| Open viewport preset selector | Presets are listed | [ ] | +| Switch to a mobile preset | Preview resizes to mobile dimensions | [ ] | +| Toggle orientation (portrait/landscape) | Preview rotates | [ ] | +| Switch to a tablet preset | Preview resizes correctly | [ ] | +| Return to default viewport | Preview returns to normal | [ ] | + +## 5. iOS TestFlight testing + +### 5.1 Device 1: ______________ (iOS __.__) + +| Step | Expected | Pass | +| ---- | -------- | ---- | +| Install from TestFlight | App installs and launches | [ ] | +| Pair with desktop/server | Pairing completes | [ ] | +| Restore a saved pairing | Connection re-establishes | [ ] | +| Open a thread and send a message | Message sends, response streams | [ ] | +| Approve an action | Action executes | [ ] | +| Answer a user-input request | Input is delivered to the agent | [ ] | +| Background the app for 30 seconds | App suspends | [ ] | +| Foreground the app | App resumes without reconnect error | [ ] | +| Tap a notification | App opens to the correct thread | [ ] | + +### 5.2 Device 2: ______________ (iOS __.__) + +| Step | Expected | Pass | +| ---- | -------- | ---- | +| Install from TestFlight | App installs and launches | [ ] | +| Pair with desktop/server | Pairing completes | [ ] | +| Restore a saved pairing | Connection re-establishes | [ ] | +| Open a thread and send a message | Message sends, response streams | [ ] | +| Approve an action | Action executes | [ ] | +| Answer a user-input request | Input is delivered to the agent | [ ] | +| Background the app for 30 seconds | App suspends | [ ] | +| Foreground the app | App resumes without reconnect error | [ ] | +| Tap a notification | App opens to the correct thread | [ ] | + +## 6. Regression tests + +### 6.1 Sidebar and navigation + +| Step | Expected | Pass | +| ---- | -------- | ---- | +| Open sidebar, verify thread list loads | Threads render with wide names | [ ] | +| Create a new thread | Thread appears in sidebar | [ ] | +| Switch between threads | Content updates without flash | [ ] | + +### 6.2 Prompt enhancement + +| Step | Expected | Pass | +| ---- | -------- | ---- | +| Open the prompt enhancement menu | Menu appears | [ ] | +| Select an enhancement | Enhancement applies to the composer | [ ] | +| Send the message with enhancement | Enhancement is preserved in the sent message | [ ] | + +### 6.3 PR status display + +| Step | Expected | Pass | +| ---- | -------- | ---- | +| Open a thread linked to a branch with a PR | PR status badge appears | [ ] | +| Verify PR status matches GitHub | Status is correct (open/merged/closed) | [ ] | + +## 7. Stability monitoring + +Track throughout the 48-hour soak: + +| Metric | Threshold | Status | +| ------ | --------- | ------ | +| Crash-on-launch | 0 across all desktop platforms | [ ] | +| Unhandled exceptions in console | 0 critical | [ ] | +| Memory usage after 1 hour | < 500 MB RSS | [ ] | +| WebSocket reconnect failures | 0 permanent failures | [ ] | +| Sev-1 issues filed | 0 | [ ] | +| Sev-2 issues filed | 0 | [ ] | + +## Soak sign-off + +| Role | Name | Date | Approved | +| ---- | ---- | ---- | -------- | +| Release lead | | | [ ] | +| QA reviewer | | | [ ] | +| iOS tester | | | [ ] | + +When all checks pass and sign-off is complete, proceed to Phase 3 (Promote to Stable) in the [rollout checklist](rollout-checklist.md). diff --git a/package.json b/package.json index 8b6ea6301..925c6385a 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "dist:desktop:linux": "node scripts/build-desktop-artifact.ts --platform linux --target AppImage --arch x64", "dist:desktop:win": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", "release:prepare": "node scripts/prepare-release.ts", + "release:validate": "node scripts/pre-release-validate.ts", "release:smoke": "node scripts/release-smoke.ts", "dist:mobile:build": "bun run --cwd apps/mobile build", "dist:mobile:sync:ios": "bunx cap sync ios --deployment", diff --git a/scripts/pre-release-validate.ts b/scripts/pre-release-validate.ts new file mode 100644 index 000000000..ef5a96045 --- /dev/null +++ b/scripts/pre-release-validate.ts @@ -0,0 +1,527 @@ +/** + * pre-release-validate.ts — Comprehensive pre-release validation gate. + * + * Runs every check that must pass before cutting an RC or promoting to stable. + * Unlike prepare-release.ts (which generates docs and tags), this script is + * read-only: it inspects current state and reports pass/fail for each gate. + * + * Usage: + * + * node scripts/pre-release-validate.ts [flags] + * + * Flags: + * + * --root Repository root directory (defaults to parent of scripts/). + * --ci Exit with non-zero on first failure (for CI pipelines). + * --skip-quality Skip quality gates (format, lint, typecheck, test) — docs only. + * --help Show this help message and exit. + * + * Exit codes: + * + * 0 All checks passed. + * 1 One or more checks failed. + */ + +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface CheckResult { + name: string; + passed: boolean; + detail: string; +} + +interface ValidateOptions { + version: string; + rootDir: string; + ci: boolean; + skipQuality: boolean; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const SEMVER_RE = /^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$/; +const STABLE_SEMVER_RE = /^[0-9]+\.[0-9]+\.[0-9]+$/; + +const RELEASE_PACKAGES = [ + "apps/server/package.json", + "apps/desktop/package.json", + "apps/web/package.json", + "apps/mobile/package.json", + "packages/contracts/package.json", +] as const; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function run(cmd: string, args: string[], opts?: { cwd?: string }): { ok: boolean; stdout: string } { + try { + const stdout = execFileSync(cmd, args, { + cwd: opts?.cwd, + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + return { ok: true, stdout }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { ok: false, stdout: message }; + } +} + +function readJson(filePath: string): Record { + return JSON.parse(readFileSync(filePath, "utf8")) as Record; +} + +// --------------------------------------------------------------------------- +// Validation checks +// --------------------------------------------------------------------------- + +function checkSemver(version: string): CheckResult { + const passed = SEMVER_RE.test(version); + return { + name: "Valid SemVer version", + passed, + detail: passed ? `${version} is valid` : `"${version}" is not valid SemVer`, + }; +} + +function checkVersionAlignment(rootDir: string, version: string): CheckResult { + const mismatched: string[] = []; + for (const relativePath of RELEASE_PACKAGES) { + const filePath = resolve(rootDir, relativePath); + if (!existsSync(filePath)) { + mismatched.push(`${relativePath} (missing)`); + continue; + } + const pkg = readJson(filePath); + if (pkg.version !== version) { + mismatched.push(`${relativePath} (${pkg.version})`); + } + } + + if (mismatched.length === 0) { + return { name: "Package version alignment", passed: true, detail: `All packages at ${version}` }; + } + return { + name: "Package version alignment", + passed: false, + detail: `Mismatched: ${mismatched.join(", ")}`, + }; +} + +function checkChangelogEntry(rootDir: string, version: string): CheckResult { + const changelogPath = resolve(rootDir, "CHANGELOG.md"); + if (!existsSync(changelogPath)) { + return { name: "CHANGELOG.md entry", passed: false, detail: "CHANGELOG.md not found" }; + } + + const content = readFileSync(changelogPath, "utf8"); + const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const hasEntry = new RegExp(`## \\[${escaped}\\]`).test(content); + + return { + name: "CHANGELOG.md entry", + passed: hasEntry, + detail: hasEntry ? `Found entry for [${version}]` : `No entry for [${version}] in CHANGELOG.md`, + }; +} + +function checkReleaseNotes(rootDir: string, version: string): CheckResult { + const notesPath = resolve(rootDir, `docs/releases/v${version}.md`); + const exists = existsSync(notesPath); + return { + name: "Release notes", + passed: exists, + detail: exists ? `docs/releases/v${version}.md exists` : `docs/releases/v${version}.md missing`, + }; +} + +function checkAssetManifest(rootDir: string, version: string): CheckResult { + const manifestPath = resolve(rootDir, `docs/releases/v${version}/assets.md`); + const exists = existsSync(manifestPath); + return { + name: "Asset manifest", + passed: exists, + detail: exists + ? `docs/releases/v${version}/assets.md exists` + : `docs/releases/v${version}/assets.md missing`, + }; +} + +function checkReleasesReadmeEntry(rootDir: string, version: string): CheckResult { + const readmePath = resolve(rootDir, "docs/releases/README.md"); + if (!existsSync(readmePath)) { + return { name: "Releases index entry", passed: false, detail: "docs/releases/README.md missing" }; + } + + const content = readFileSync(readmePath, "utf8"); + const hasEntry = content.includes(`[${version}]`); + return { + name: "Releases index entry", + passed: hasEntry, + detail: hasEntry + ? `Found [${version}] in docs/releases/README.md` + : `No entry for [${version}] in docs/releases/README.md`, + }; +} + +function checkNoExistingTag(rootDir: string, version: string): CheckResult { + const tag = `v${version}`; + const { stdout } = run("git", ["tag", "-l", tag], { cwd: rootDir }); + const exists = stdout === tag; + return { + name: "Tag not yet created", + passed: !exists, + detail: exists + ? `Tag ${tag} already exists — promote or cut a new RC instead` + : `Tag ${tag} does not exist yet`, + }; +} + +function checkCleanWorktree(rootDir: string): CheckResult { + const { stdout } = run("git", ["status", "--porcelain"], { cwd: rootDir }); + const isClean = stdout === ""; + return { + name: "Clean working tree", + passed: isClean, + detail: isClean ? "No uncommitted changes" : `Uncommitted changes:\n${stdout}`, + }; +} + +function checkOnMain(rootDir: string): CheckResult { + const { stdout } = run("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: rootDir }); + const isMain = stdout === "main"; + return { + name: "On main branch", + passed: isMain, + detail: isMain ? "Currently on main" : `Currently on "${stdout}" — release tags should be cut from main`, + }; +} + +function checkReleaseReadyDocs(rootDir: string, version: string): CheckResult { + // Validate the same conditions the release-ready.yml workflow checks + const requiredFiles = [ + `docs/releases/v${version}.md`, + `docs/releases/v${version}/assets.md`, + ]; + const missing = requiredFiles.filter((f) => !existsSync(resolve(rootDir, f))); + + if (missing.length === 0) { + return { + name: "release-ready.yml gate", + passed: true, + detail: "All required release documentation present", + }; + } + return { + name: "release-ready.yml gate", + passed: false, + detail: `Missing: ${missing.join(", ")}`, + }; +} + +function checkReleaseNotesContent(rootDir: string, version: string): CheckResult { + const notesPath = resolve(rootDir, `docs/releases/v${version}.md`); + if (!existsSync(notesPath)) { + return { name: "Release notes content", passed: false, detail: "File missing" }; + } + + const content = readFileSync(notesPath, "utf8"); + const issues: string[] = []; + + if (!content.includes(`v${version}`)) issues.push("version not mentioned in body"); + if (!content.includes("## Summary")) issues.push("missing Summary section"); + if (!content.includes("## Highlights")) issues.push("missing Highlights section"); + if (!content.includes("## Upgrade and install")) issues.push("missing Upgrade and install section"); + + if (issues.length === 0) { + return { name: "Release notes content", passed: true, detail: "All expected sections present" }; + } + return { + name: "Release notes content", + passed: false, + detail: `Issues: ${issues.join("; ")}`, + }; +} + +function checkAssetManifestContent(rootDir: string, version: string): CheckResult { + const manifestPath = resolve(rootDir, `docs/releases/v${version}/assets.md`); + if (!existsSync(manifestPath)) { + return { name: "Asset manifest content", passed: false, detail: "File missing" }; + } + + const content = readFileSync(manifestPath, "utf8"); + const issues: string[] = []; + + if (!content.includes("Desktop installers")) issues.push("missing Desktop installers section"); + if (!content.includes("Electron updater metadata")) issues.push("missing updater metadata section"); + if (!content.includes("iOS (TestFlight)")) issues.push("missing iOS section"); + if (!content.includes("Checksums")) issues.push("missing Checksums section"); + if (!content.includes(version)) issues.push("version not mentioned in body"); + + if (issues.length === 0) { + return { name: "Asset manifest content", passed: true, detail: "All expected sections present" }; + } + return { + name: "Asset manifest content", + passed: false, + detail: `Issues: ${issues.join("; ")}`, + }; +} + +function checkIosProjectVersion(rootDir: string, version: string): CheckResult { + const pbxprojPath = resolve(rootDir, "apps/mobile/ios/App/App.xcodeproj/project.pbxproj"); + if (!existsSync(pbxprojPath)) { + return { name: "iOS MARKETING_VERSION", passed: true, detail: "No Xcode project (skipped)" }; + } + + const content = readFileSync(pbxprojPath, "utf8"); + const versionPattern = new RegExp(`MARKETING_VERSION\\s*=\\s*${version.replace(/\./g, "\\.")}\\s*;`); + const matches = content.match(versionPattern); + + if (matches) { + return { name: "iOS MARKETING_VERSION", passed: true, detail: `Set to ${version}` }; + } + + // Extract what it's currently set to + const current = content.match(/MARKETING_VERSION\s*=\s*([^;]+);/); + return { + name: "iOS MARKETING_VERSION", + passed: false, + detail: current + ? `Currently ${current[1]?.trim()} — expected ${version}` + : "MARKETING_VERSION not found in project.pbxproj", + }; +} + +// --------------------------------------------------------------------------- +// Quality gate checks (run external commands) +// --------------------------------------------------------------------------- + +interface QualityGate { + name: string; + cmd: string; + args: string[]; +} + +const QUALITY_GATES: QualityGate[] = [ + { name: "Format check", cmd: "bun", args: ["run", "fmt:check"] }, + { name: "Lint (zero-warning)", cmd: "bun", args: ["run", "lint"] }, + { name: "Typecheck", cmd: "bun", args: ["run", "typecheck"] }, + { name: "Unit tests", cmd: "bun", args: ["run", "test"] }, + { name: "Browser tests", cmd: "bun", args: ["run", "--cwd", "apps/web", "test:browser"] }, + { name: "Desktop smoke", cmd: "bun", args: ["run", "test:desktop-smoke"] }, + { name: "Release smoke", cmd: "bun", args: ["run", "release:smoke"] }, +]; + +function runQualityGates(rootDir: string): CheckResult[] { + const results: CheckResult[] = []; + for (const gate of QUALITY_GATES) { + const { ok, stdout } = run(gate.cmd, gate.args, { cwd: rootDir }); + results.push({ + name: gate.name, + passed: ok, + detail: ok ? "Passed" : `Failed — run \`${gate.cmd} ${gate.args.join(" ")}\` to reproduce`, + }); + } + return results; +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +function printHelp(): void { + console.log(` + pre-release-validate — Comprehensive pre-release validation gate. + + Usage: + node scripts/pre-release-validate.ts [flags] + + Arguments: + SemVer version to validate (e.g. 0.16.0, 0.17.0-rc.1) + + Flags: + --root Repository root directory (defaults to parent of scripts/) + --ci Exit with non-zero on first failure + --skip-quality Skip quality gates (format, lint, typecheck, test) + --help Show this help message and exit + + Examples: + node scripts/pre-release-validate.ts 0.16.0 + node scripts/pre-release-validate.ts 0.16.0 --ci + node scripts/pre-release-validate.ts 0.16.0 --skip-quality +`); +} + +function parseArgs(argv: ReadonlyArray): ValidateOptions { + let version: string | undefined; + let rootDir: string | undefined; + let ci = false; + let skipQuality = false; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === undefined) continue; + + switch (arg) { + case "--help": + case "-h": + printHelp(); + process.exit(0); + break; + case "--ci": + ci = true; + break; + case "--skip-quality": + skipQuality = true; + break; + case "--root": + rootDir = argv[i + 1]; + if (!rootDir) { + console.error("Missing value for --root."); + process.exit(1); + } + i += 1; + break; + default: + if (arg.startsWith("--")) { + console.error(`Unknown flag: ${arg}`); + process.exit(1); + } + if (version !== undefined) { + console.error("Only one version argument is allowed."); + process.exit(1); + } + version = arg.replace(/^v/, ""); + break; + } + } + + if (!version) { + printHelp(); + process.exit(1); + } + + const scriptDir = dirname(fileURLToPath(import.meta.url)); + const resolvedRoot = resolve(rootDir ?? resolve(scriptDir, "..")); + + return { version, rootDir: resolvedRoot, ci, skipQuality }; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +function main(): void { + const opts = parseArgs(process.argv.slice(2)); + const { version, rootDir, ci, skipQuality } = opts; + const isPrerelease = !STABLE_SEMVER_RE.test(version); + + console.log(""); + console.log(` Pre-release validation: v${version}${isPrerelease ? " (prerelease)" : ""}`); + console.log(` Root: ${rootDir}`); + console.log(""); + + // ----------------------------------------------------------------------- + // Documentation & state checks (always run) + // ----------------------------------------------------------------------- + + const docChecks: CheckResult[] = [ + checkSemver(version), + checkVersionAlignment(rootDir, version), + checkChangelogEntry(rootDir, version), + checkReleaseNotes(rootDir, version), + checkAssetManifest(rootDir, version), + checkReleasesReadmeEntry(rootDir, version), + checkReleaseReadyDocs(rootDir, version), + checkReleaseNotesContent(rootDir, version), + checkAssetManifestContent(rootDir, version), + checkIosProjectVersion(rootDir, version), + checkNoExistingTag(rootDir, version), + checkCleanWorktree(rootDir), + checkOnMain(rootDir), + ]; + + // ----------------------------------------------------------------------- + // Quality gate checks (optional) + // ----------------------------------------------------------------------- + + let qualityChecks: CheckResult[] = []; + if (skipQuality) { + console.log(" Skipping quality gates (--skip-quality).\n"); + } else { + qualityChecks = runQualityGates(rootDir); + } + + // ----------------------------------------------------------------------- + // Report + // ----------------------------------------------------------------------- + + const allChecks = [...docChecks, ...qualityChecks]; + const passed = allChecks.filter((c) => c.passed); + const failed = allChecks.filter((c) => !c.passed); + + const maxNameLen = Math.max(...allChecks.map((c) => c.name.length)); + + for (const check of allChecks) { + const icon = check.passed ? "PASS" : "FAIL"; + const pad = " ".repeat(maxNameLen - check.name.length); + console.log(` [${icon}] ${check.name}${pad} ${check.detail}`); + } + + console.log(""); + console.log(` ${passed.length} passed, ${failed.length} failed out of ${allChecks.length} checks.`); + + if (failed.length > 0) { + console.log(""); + console.log(" Failed checks:"); + for (const check of failed) { + console.log(` - ${check.name}: ${check.detail}`); + } + console.log(""); + + if (ci) { + process.exit(1); + } else { + console.log(" Fix the above issues before cutting the release."); + console.log(""); + process.exit(1); + } + } else { + console.log(""); + console.log(` All checks passed. Ready to cut v${version}.`); + console.log(""); + + if (isPrerelease) { + console.log(" Next step:"); + console.log(` node scripts/prepare-release.ts ${version} --full-matrix`); + } else { + console.log(" Next steps:"); + console.log(` 1. Cut RC first: node scripts/prepare-release.ts ${version}-rc.1 --full-matrix`); + console.log(" 2. Soak RC for 48 hours."); + console.log(` 3. Promote: node scripts/prepare-release.ts ${version} --full-matrix`); + } + console.log(""); + } +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +const isMain = + process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isMain) { + main(); +}