71fe258320
- Removed `tags: ["v*"]` trigger; release now fires from the single atomic `main` push that carries the tag. - Replaced `gate.skip` with `gate.is-release` and `gate.release-tag` so downstream jobs detect the tag via `git tag --points-at HEAD`. - Passed `release-tag` explicitly to release steps (gh-release, curl, release notes) since `github.ref` is now `refs/heads/main`, not the tag. - Fixed rust-cache key collision on macOS x64 by setting `RUSTFLAGS` (target-cpu) before cache restore and including the native source hash in the shared key.
396 lines
14 KiB
TypeScript
Executable File
396 lines
14 KiB
TypeScript
Executable File
#!/usr/bin/env bun
|
|
/**
|
|
* Release script for pi-mono
|
|
*
|
|
* Usage:
|
|
* bun scripts/release.ts <version> 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 -c fetch.pruneTags=false ${args}`;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Shared functions
|
|
// =============================================================================
|
|
|
|
async function watchCI(): Promise<boolean> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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();
|
|
|
|
// 3b. Rename the pi-natives version sentinel so any `.node` left on disk from
|
|
// a previous release physically cannot expose the symbol the new `index.js`
|
|
// expects. The JS loader derives `VERSION_SENTINEL_EXPORT` from `package.json`
|
|
// at runtime, so the only thing that has to move on the Rust side is the
|
|
// `js_name = "__piNativesV…"` literal. `gen-enums.ts` regenerates the matching
|
|
// entries in `packages/natives/native/{index.d.ts,index.js}` on the next napi
|
|
// build, but bump them here too so the committed surface tracks the version
|
|
// without waiting for a local rebuild on the release host.
|
|
console.log(`Bumping pi-natives version sentinel to v${version}…`);
|
|
const sentinelJsId = version.replace(/[^A-Za-z0-9]/g, "_");
|
|
const sentinelName = `__piNativesV${sentinelJsId}`;
|
|
const sentinelFiles = [
|
|
"crates/pi-natives/src/lib.rs",
|
|
"packages/natives/native/index.d.ts",
|
|
"packages/natives/native/index.js",
|
|
];
|
|
await $`sd '__piNativesV[A-Za-z0-9_]+' ${sentinelName} ${sentinelFiles}`;
|
|
const libRs = await Bun.file("crates/pi-natives/src/lib.rs").text();
|
|
if (!libRs.includes(`js_name = "${sentinelName}"`)) {
|
|
console.error(
|
|
`Error: pi-natives version sentinel did not move to ${sentinelName} in crates/pi-natives/src/lib.rs. ` +
|
|
"The `__piNativesV…` literal may have been removed or renamed; restore it before releasing.",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
console.log(` sentinel: ${sentinelName}\n`);
|
|
|
|
// 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
|
|
console.log("Committing...");
|
|
await git(["add", "."]);
|
|
await git(["commit", "-m", `chore: bump version to ${version}`]);
|
|
console.log();
|
|
|
|
// 8. Tag + push atomically.
|
|
//
|
|
// Background `git maintenance run` (scheduled via the global `[maintenance]
|
|
// repo = …` list) fetches origin with `fetch.pruneTags=true` set globally,
|
|
// which deletes any local tag that does not yet exist on the remote — i.e.
|
|
// the brand-new release tag. The `-c fetch.pruneTags=false` we pass to our
|
|
// git wrapper only applies to our git invocations, not to the concurrent
|
|
// maintenance process, so we have to defend against the race ourselves:
|
|
// (re)create the tag immediately before the push and retry on the specific
|
|
// "src refspec … does not match any" symptom that means it got pruned.
|
|
console.log("Tagging and pushing to remote...");
|
|
const tagRef = `v${version}`;
|
|
for (let attempt = 1; ; attempt++) {
|
|
await git(["tag", "-f", tagRef]);
|
|
const result = await git([
|
|
"push",
|
|
"--atomic",
|
|
"origin",
|
|
"main",
|
|
`refs/tags/${tagRef}`,
|
|
]).nothrow();
|
|
if (result.exitCode === 0) break;
|
|
const stderr = result.stderr.toString();
|
|
process.stderr.write(stderr);
|
|
const pruned = /src refspec .* does not match any/.test(stderr);
|
|
if (!pruned || attempt >= 3) {
|
|
throw new Error(`git push failed for ${tagRef} (attempt ${attempt})`);
|
|
}
|
|
console.warn(
|
|
` Tag ${tagRef} pruned by background maintenance, retrying (${attempt + 1}/3)...`,
|
|
);
|
|
}
|
|
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: <brief description>\"");
|
|
console.log(` git tag -f v${version}`);
|
|
console.log(` git push --atomic origin main +refs/tags/v${version}`);
|
|
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 <version> 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 <version> Full release");
|
|
console.error(" bun scripts/release.ts watch Watch CI for current commit");
|
|
process.exit(1);
|
|
}
|