#!/usr/bin/env bun /** * Release script for pi-mono * * Usage: * bun scripts/release.ts Full release (preflight, version, changelog, commit, push, watch) * bun scripts/release.ts watch Watch CI for current commit * * Example: bun scripts/release.ts 3.10.0 */ import { $, Glob } from "bun"; const changelogGlob = new Glob("packages/*/CHANGELOG.md"); const packageJsonGlob = new Glob("packages/*/package.json"); const cargoTomlGlob = new Glob("crates/*/Cargo.toml"); function git(args: readonly string[]) { return $`git -c core.fsmonitor=false -c core.untrackedCache=false ${args}`; } // ============================================================================= // Shared functions // ============================================================================= async function watchCI(): Promise { const commitSha = (await git(["rev-parse", "HEAD"]).text()).trim(); console.log(` Commit: ${commitSha.slice(0, 8)}`); while (true) { const runsOutput = await $`gh run list --commit ${commitSha} --json databaseId,status,conclusion,name`.text(); const runs: Array<{ databaseId: number; status: string; conclusion: string | null; name: string }> = JSON.parse(runsOutput); if (runs.length === 0) { console.log(" Waiting for CI to start..."); await Bun.sleep(3000); continue; } // Check job-level status for in-progress runs (fail fast on first job failure) const failedJobs: Array<{ workflow: string; job: string; jobId: number; conclusion: string }> = []; const inProgressRuns = runs.filter((r) => r.status === "in_progress" || r.status === "queued"); for (const run of inProgressRuns) { const jobsOutput = await $`gh run view ${run.databaseId} --json jobs`.quiet().nothrow().text(); try { const { jobs } = JSON.parse(jobsOutput) as { jobs: Array<{ name: string; databaseId: number; status: string; conclusion: string | null }>; }; for (const job of jobs) { if (job.status === "completed" && job.conclusion !== "success" && job.conclusion !== "skipped") { failedJobs.push({ workflow: run.name, job: job.name, jobId: job.databaseId, conclusion: job.conclusion ?? "unknown", }); } } } catch { // Ignore parse errors } } if (failedJobs.length > 0) { console.error("\nCI job failed:"); for (const f of failedJobs) { console.error(` - ${f.workflow} / ${f.job} (job ${f.jobId}): ${f.conclusion}`); // Tail the failed job's log const log = await $`gh run view --job ${f.jobId} --log-failed`.quiet().nothrow().text(); if (log.trim()) { const lines = log.trimEnd().split("\n"); const tail = lines.slice(-20).join("\n"); console.error(`\n--- Last 20 lines of ${f.job} ---\n${tail}\n`); } } return false; } // Check workflow-level status const pending = runs.filter((r) => r.status !== "completed"); const failed = runs.filter((r) => r.status === "completed" && r.conclusion !== "success"); const passed = runs.filter((r) => r.status === "completed" && r.conclusion === "success"); console.log(` ${passed.length} passed, ${pending.length} pending, ${failed.length} failed`); if (failed.length > 0) { console.error("\nCI failed:"); for (const r of failed) { console.error(` - ${r.name}: ${r.conclusion}`); // Fetch failed jobs and tail their logs const jobsOutput = await $`gh run view ${r.databaseId} --json jobs`.quiet().nothrow().text(); try { const { jobs } = JSON.parse(jobsOutput) as { jobs: Array<{ name: string; databaseId: number; status: string; conclusion: string | null }>; }; for (const job of jobs) { if (job.conclusion !== "success" && job.conclusion !== "skipped") { const log = await $`gh run view --job ${job.databaseId} --log-failed`.quiet().nothrow().text(); if (log.trim()) { const lines = log.trimEnd().split("\n"); const tail = lines.slice(-20).join("\n"); console.error(`\n--- Last 20 lines of ${job.name} (job ${job.databaseId}) ---\n${tail}\n`); } } } } catch { // Ignore parse errors } } return false; } if (pending.length === 0) { console.log(" All CI checks passed!\n"); return true; } await Bun.sleep(5000); } } function hasUnreleasedContent(content: string): boolean { const unreleasedMatch = content.match(/## \[Unreleased\]\s*\n([\s\S]*?)(?=## \[\d|$)/); if (!unreleasedMatch) return false; const sectionContent = unreleasedMatch[1].trim(); return sectionContent.length > 0; } function removeEmptyVersionEntries(content: string): string { // Remove version entries that have no content (just whitespace until next ## [ or EOF) return content.replace(/## \[\d+\.\d+\.\d+\] - \d{4}-\d{2}-\d{2}\s*\n(?=## \[|\s*$)/g, ""); } async function updateChangelogsForRelease(version: string): Promise { const date = new Date().toISOString().split("T")[0]; for await (const changelog of changelogGlob.scan(".")) { let content = await Bun.file(changelog).text(); if (!content.includes("## [Unreleased]")) { console.log(` Skipping ${changelog}: no [Unreleased] section`); continue; } // Only create version entry if [Unreleased] has content if (hasUnreleasedContent(content)) { content = content.replace("## [Unreleased]", `## [${version}] - ${date}`); content = content.replace(/^(# Changelog\n\n)/, `$1## [Unreleased]\n\n`); } // Clean up any existing empty version entries content = removeEmptyVersionEntries(content); await Bun.write(changelog, content); console.log(` Updated ${changelog}`); } } // ============================================================================= // Subcommands // ============================================================================= async function cmdWatch(): Promise { console.log("\n=== Watching CI ===\n"); const success = await watchCI(); process.exit(success ? 0 : 1); } function parseVersion(v: string): [number, number, number] { const match = v.replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/); if (!match) throw new Error(`Invalid version: ${v}`); return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])]; } function compareVersions(a: string, b: string): number { const [aMajor, aMinor, aPatch] = parseVersion(a); const [bMajor, bMinor, bPatch] = parseVersion(b); if (aMajor !== bMajor) return aMajor - bMajor; if (aMinor !== bMinor) return aMinor - bMinor; return aPatch - bPatch; } async function cmdRelease(version: string): Promise { console.log("\n=== Release Script ===\n"); // 1. Pre-flight checks console.log("Pre-flight checks..."); const branch = await git(["branch", "--show-current"]).text(); if (branch.trim() !== "main") { console.error(`Error: Must be on main branch (currently on '${branch.trim()}')`); process.exit(1); } console.log(" On main branch"); const status = await git(["status", "--porcelain"]).text(); if (status.trim()) { console.error("Error: Uncommitted changes detected. Commit or stash first."); console.error(status); process.exit(1); } console.log(" Working directory clean"); const latestTag = (await git(["describe", "--tags", "--abbrev=0"]).text()).trim(); if (compareVersions(version, latestTag) <= 0) { console.error(`Error: Version ${version} must be greater than latest tag ${latestTag}`); process.exit(1); } console.log(` Version ${version} > ${latestTag}\n`); // 2. Update package versions console.log(`Updating package versions to ${version}…`); const pkgJsonPaths = await Array.fromAsync(packageJsonGlob.scan(".")); // Filter out private packages const publicPkgPaths: string[] = []; for (const pkgPath of pkgJsonPaths) { const pkgJson = await Bun.file(pkgPath).json(); if (pkgJson.private) { console.log(` Skipping ${pkgJson.name} (private)`); continue; } publicPkgPaths.push(pkgPath); } await $`sd '"version": "[^"]+"' ${`"version": "${version}"`} ${publicPkgPaths}`; // Verify console.log(" Verifying versions:"); for (const pkgPath of publicPkgPaths) { const pkgJson = await Bun.file(pkgPath).json(); console.log(` ${pkgJson.name}: ${pkgJson.version}`); } console.log(); // Update @oh-my-pi/* catalog entries in root package.json console.log("Updating root catalog versions..."); let rootPkgRaw = await Bun.file("package.json").text(); rootPkgRaw = rootPkgRaw.replace( /("@oh-my-pi\/[^"]+":\s*)"[^"]+"/g, `$1"${version}"`, ); await Bun.write("package.json", rootPkgRaw); console.log(" Updated root catalog @oh-my-pi/* entries"); // 3. Update Rust workspace version console.log(`Updating Rust workspace version to ${version}…`); await $`sd '^version = "[^"]+"' ${`version = "${version}"`} Cargo.toml`; // Verify const cargoToml = await Bun.file("Cargo.toml").text(); const versionMatch = cargoToml.match(/^\[workspace\.package\][\s\S]*?^version = "([^"]+)"/m); if (versionMatch) { console.log(` workspace: ${versionMatch[1]}`); } // List crates using workspace version for await (const cargoPath of cargoTomlGlob.scan(".")) { const content = await Bun.file(cargoPath).text(); if (content.includes("version.workspace = true")) { const nameMatch = content.match(/^name = "([^"]+)"/m); if (nameMatch) { console.log(` ${nameMatch[1]}: ${version} (workspace)`); } } } console.log(); // 4. Regenerate lockfiles console.log("Regenerating lockfiles..."); await $`rm -f bun.lock`; await $`bun install`; await $`cargo generate-lockfile`; console.log(); // 5. Update changelogs console.log("Updating CHANGELOGs..."); await updateChangelogsForRelease(version); console.log(); // 6. Run checks console.log("Running checks..."); await $`bun run check`; console.log(); // 7. Commit and tag console.log("Committing and tagging..."); await git(["add", "."]); await git(["commit", "-m", `chore: bump version to ${version}`]); await git(["tag", `v${version}`]); console.log(); // 8. Push console.log("Pushing to remote..."); await git(["push", "origin", "main"]); await git(["push", "origin", `v${version}`]); console.log(); // 9. Watch CI console.log("Watching CI..."); const success = await watchCI(); if (success) { console.log(`=== Released v${version} ===`); } else { console.log("\nTo retry after fixing (repeat until CI passes):"); console.log(" git commit -m \"fix: \""); console.log(" git push origin main"); console.log(` git tag -f v${version} && git push origin v${version} --force`); console.log(" bun scripts/release.ts watch"); process.exit(1); } } // ============================================================================= // Main // ============================================================================= const arg = process.argv[2]; if (!arg) { console.error("Usage:"); console.error(" bun scripts/release.ts Full release"); console.error(" bun scripts/release.ts watch Watch CI for current commit"); process.exit(1); } if (arg === "watch") { await cmdWatch(); } else if (/^\d+\.\d+\.\d+/.test(arg)) { await cmdRelease(arg); } else { console.error(`Unknown command or invalid version: ${arg}`); console.error("Usage:"); console.error(" bun scripts/release.ts Full release"); console.error(" bun scripts/release.ts watch Watch CI for current commit"); process.exit(1); }