| #!/usr/bin/env node |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { execSync, spawnSync } from "node:child_process"; |
| import { existsSync, readFileSync, writeFileSync } from "node:fs"; |
| import { join } from "node:path"; |
| import { findPackageDirectories } from "./package-workspaces.mjs"; |
| import { getPublicWorkspacePackages } from "./release-packages.mjs"; |
|
|
| const RELEASE_TARGET = process.argv[2]; |
| const BUMP_TYPES = new Set(["major", "minor", "patch"]); |
| const SEMVER_RE = /^\d+\.\d+\.\d+$/; |
|
|
| if (!RELEASE_TARGET || (!BUMP_TYPES.has(RELEASE_TARGET) && !SEMVER_RE.test(RELEASE_TARGET))) { |
| console.error("Usage: node scripts/release.mjs <major|minor|patch|x.y.z>"); |
| process.exit(1); |
| } |
|
|
| function run(cmd, options = {}) { |
| console.log(`$ ${cmd}`); |
| try { |
| return execSync(cmd, { encoding: "utf-8", stdio: options.silent ? "pipe" : "inherit", ...options }); |
| } catch (e) { |
| if (!options.ignoreError) { |
| console.error(`Command failed: ${cmd}`); |
| process.exit(1); |
| } |
| return null; |
| } |
| } |
|
|
| function getVersion() { |
| const pkg = JSON.parse(readFileSync("packages/ai/package.json", "utf-8")); |
| return pkg.version; |
| } |
|
|
| function assertPackagesAreRegisteredWithNpm() { |
| const packageNames = getPublicWorkspacePackages().map((pkg) => pkg.name); |
| const unregisteredPackages = []; |
|
|
| console.log("Checking npm package registration..."); |
| for (const packageName of packageNames) { |
| const result = spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", ["view", packageName, "version", "--json"], { |
| encoding: "utf8", |
| stdio: ["ignore", "pipe", "pipe"], |
| }); |
|
|
| if (result.status === 0 && result.stdout.trim()) { |
| console.log(` ${packageName}`); |
| continue; |
| } |
|
|
| const output = [result.stdout, result.stderr, result.error?.message].filter(Boolean).join("\n"); |
| if (output.includes("E404") || output.includes("404 Not Found")) { |
| unregisteredPackages.push(packageName); |
| continue; |
| } |
|
|
| throw new Error(output ? `Failed to query npm registration for ${packageName}\n${output}` : `Failed to query npm registration for ${packageName}`); |
| } |
|
|
| if (unregisteredPackages.length > 0) { |
| throw new Error(`The following public workspace packages are not registered on npm:\n${unregisteredPackages.map((packageName) => ` ${packageName}`).join("\n")}\nRegister them before running a release.`); |
| } |
|
|
| console.log(" All public workspace packages are registered on npm\n"); |
| } |
|
|
| function compareVersions(a, b) { |
| const aParts = a.split(".").map(Number); |
| const bParts = b.split(".").map(Number); |
|
|
| for (let i = 0; i < 3; i++) { |
| const diff = (aParts[i] || 0) - (bParts[i] || 0); |
| if (diff !== 0) { |
| return diff; |
| } |
| } |
|
|
| return 0; |
| } |
|
|
| function shellQuote(value) { |
| return `'${value.replace(/'/g, `'\\''`)}'`; |
| } |
| |
| function removeStaleWorkspaceLockEntries() { |
| const workspaceVersions = new Map( |
| getPublicWorkspacePackages().map((pkg) => [pkg.name, pkg.version]), |
| ); |
| const lockPath = "package-lock.json"; |
| const lock = JSON.parse(readFileSync(lockPath, "utf8")); |
| let removed = 0; |
| |
| for (const [path, pkg] of Object.entries(lock.packages)) { |
| if (!path.startsWith("packages/") || pkg.link === true) { |
| continue; |
| } |
| for (const [name, version] of workspaceVersions) { |
| if (path.endsWith(`/node_modules/${name}`) && pkg.version !== version) { |
| delete lock.packages[path]; |
| removed++; |
| break; |
| } |
| } |
| } |
| |
| if (removed > 0) { |
| writeFileSync(lockPath, `${JSON.stringify(lock, null, "\t")}\n`); |
| console.log(`Removed ${removed} stale workspace package lock ${removed === 1 ? "entry" : "entries"}.`); |
| } |
| } |
| |
| function stageChangedFiles() { |
| const output = run("git ls-files -m -o -d --exclude-standard", { silent: true }); |
| const paths = [...new Set((output || "").split("\n").map((line) => line.trim()).filter(Boolean))]; |
| if (paths.length === 0) { |
| return; |
| } |
| |
| run(`git add -- ${paths.map(shellQuote).join(" ")}`); |
| } |
| |
| function bumpOrSetVersion(target) { |
| const currentVersion = getVersion(); |
| |
| if (BUMP_TYPES.has(target)) { |
| console.log(`Bumping version (${target})...`); |
| run(`npm run version:${target}`); |
| } else { |
| if (compareVersions(target, currentVersion) <= 0) { |
| console.error(`Error: explicit version ${target} must be greater than current version ${currentVersion}.`); |
| process.exit(1); |
| } |
| |
| console.log(`Setting explicit version (${target})...`); |
| run(`npm version ${target} --workspaces --no-git-tag-version --no-workspaces-update && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts`); |
| } |
| |
| // npm version can temporarily install the previous workspace versions before |
| // sync-versions updates inter-package ranges. Remove those stale lock entries, |
| // refresh the lockfile, then hydrate from the final dependency graph. |
| removeStaleWorkspaceLockEntries(); |
| run("npm install --package-lock-only --ignore-scripts"); |
| run("npm ci --ignore-scripts"); |
| return getVersion(); |
| } |
| |
| function getChangelogs() { |
| return findPackageDirectories() |
| .map((directory) => join(directory, "CHANGELOG.md")) |
| .filter((path) => existsSync(path)); |
| } |
| |
| function updateChangelogsForRelease(version) { |
| const date = new Date().toISOString().split("T")[0]; |
| const changelogs = getChangelogs(); |
| |
| for (const changelog of changelogs) { |
| const content = readFileSync(changelog, "utf-8"); |
| |
| if (!content.includes("## [Unreleased]")) { |
| console.log(` Skipping ${changelog}: no [Unreleased] section`); |
| continue; |
| } |
| |
| const updated = content.replace( |
| "## [Unreleased]", |
| `## [${version}] - ${date}` |
| ); |
| writeFileSync(changelog, updated); |
| console.log(` Updated ${changelog}`); |
| } |
| } |
| |
| function addUnreleasedSection() { |
| const changelogs = getChangelogs(); |
| const unreleasedSection = "## [Unreleased]\n\n"; |
| |
| for (const changelog of changelogs) { |
| const content = readFileSync(changelog, "utf-8"); |
| |
| // Insert after "# Changelog\n\n" |
| const updated = content.replace( |
| /^(# Changelog\n\n)/, |
| `$1${unreleasedSection}` |
| ); |
| writeFileSync(changelog, updated); |
| console.log(` Added [Unreleased] to ${changelog}`); |
| } |
| } |
| |
| // Main flow |
| console.log("\n=== Release Script ===\n"); |
| |
| // 1. Check for uncommitted changes |
| console.log("Checking for uncommitted changes..."); |
| const status = run("git status --porcelain", { silent: true }); |
| if (status && status.trim()) { |
| console.error("Error: Uncommitted changes detected. Commit or stash first."); |
| console.error(status); |
| process.exit(1); |
| } |
| console.log(" Working directory clean\n"); |
| |
| // 2. Verify npm package registration before modifying the worktree. |
| assertPackagesAreRegisteredWithNpm(); |
| |
| // 3. Bump or set version |
| const version = bumpOrSetVersion(RELEASE_TARGET); |
| console.log(` New version: ${version}\n`); |
| |
| // 4. Update changelogs |
| console.log("Updating CHANGELOG.md files..."); |
| updateChangelogsForRelease(version); |
| console.log(); |
| |
| // 5. Regenerate release artifacts |
| console.log("Regenerating release artifacts..."); |
| run("npm run generate:models"); |
| run("npm run check:model-data"); |
| run("npm run shrinkwrap:coding-agent"); |
| run("npm run install-lock:coding-agent"); |
| console.log(); |
| |
| // 6. Run checks and tests |
| console.log("Running checks..."); |
| run("npm run check"); |
| console.log(); |
| |
| console.log("Building packages for tests..."); |
| run("npm run build:offline"); |
| console.log(); |
| |
| console.log("Running tests..."); |
| run("./test.sh"); |
| console.log(); |
| |
| console.log("Checking the packed coding-agent consumer install..."); |
| run("npm run check:package-install"); |
| console.log(); |
| |
| // 7. Commit and tag |
| console.log("Committing and tagging..."); |
| stageChangedFiles(); |
| run(`git commit -m "Release v${version}"`); |
| run(`git tag v${version}`); |
| console.log(); |
| |
| // 8. Add new [Unreleased] sections |
| console.log("Adding [Unreleased] sections for next cycle..."); |
| addUnreleasedSection(); |
| console.log(); |
| |
| // 9. Commit |
| console.log("Committing changelog updates..."); |
| stageChangedFiles(); |
| run(`git commit -m "Add [Unreleased] section for next cycle"`); |
| console.log(); |
| |
| // 10. Push |
| console.log("Pushing to remote..."); |
| run("git push origin main"); |
| run(`git push origin v${version}`); |
| console.log(); |
| |
| console.log(`=== Prepared release v${version}; CI publication and pi.dev announcement start after the tag push ===`); |
| |