Files
2026-07-13 12:52:40 +08:00

549 lines
16 KiB
TypeScript
Executable File

#!/usr/bin/env bun
/** biome-ignore-all lint/style/noNonNullAssertion: expected non-null assertions */
/**
* Unified release script for SDK packages and CLI.
*
* Usage:
* bun release sdk # auto-increment patch, publish SDK
* bun release sdk 0.1.0 # publish SDK at exact version
* bun release sdk 0.1.0 --tag next # publish SDK with npm tag
* bun release cli # publish CLI from the current tagged commit
* bun release cli 0.1.0 # require apps/cli/package.json to match
* bun release cli --dry-run # preview without side effects
* bun release sdk --skip-tests # skip the test suite
* bun release sdk --skip-git-tags # skip git tag creation
*/
import { constants } from "node:fs";
import { copyFile, readdir, readFile, unlink } from "node:fs/promises";
import { join } from "node:path";
import { parseArgs } from "node:util";
// ── CLI args ──────────────────────────────────────────────────────────────────
const { values, positionals } = parseArgs({
args: Bun.argv.slice(2),
options: {
"dry-run": { type: "boolean", default: false },
tag: { type: "string", default: "latest" },
"skip-tests": { type: "boolean", default: false },
"skip-git-tags": { type: "boolean", default: false },
},
allowPositionals: true,
strict: true,
});
const dryRun = values["dry-run"]!;
const npmTag = values.tag!;
const skipTests = values["skip-tests"]!;
const skipGitTags = values["skip-git-tags"]!;
const target = positionals[0] as "sdk" | "cli" | undefined;
const explicitVersion = positionals[1];
if (!target || !["sdk", "cli"].includes(target)) {
console.error("Usage: bun release <sdk|cli> [version] [options]");
console.error("");
console.error("Targets:");
console.error(" sdk Publish @cline/{shared,llms,agents,core,sdk} to npm");
console.error(" cli Publish cline from an existing cli-vX.Y.Z git tag");
console.error("");
console.error("Options:");
console.error(
" [version] Semver version (omit to auto-increment patch)",
);
console.error(' --tag <tag> npm dist-tag (default: "latest")');
console.error(" --dry-run Preview all steps without side effects");
console.error(" --skip-tests Skip running the test suite");
console.error(" --skip-git-tags Skip git tag creation");
console.error("");
console.error("Examples:");
console.error(" bun release sdk");
console.error(" bun release sdk 0.1.0");
console.error(" bun release sdk 0.1.0 --tag next");
console.error(" bun release cli");
process.exit(1);
}
if (explicitVersion && !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(explicitVersion)) {
console.error(`Invalid semver version: ${explicitVersion}`);
process.exit(1);
}
// ── Constants ─────────────────────────────────────────────────────────────────
const SDK_PUBLISH_ORDER = ["shared", "llms", "agents", "core", "sdk"] as const;
const MAIN_BRANCH = "main";
const root = join(import.meta.dir, "..", "..");
const sdkRoot = join(import.meta.dir, "..");
const packagesDir = join(sdkRoot, "packages");
const cliDir = join(sdkRoot, "../apps/cli");
// ── Helpers ───────────────────────────────────────────────────────────────────
function header(msg: string): void {
console.log(`\n${"─".repeat(60)}`);
console.log(` ${msg}`);
console.log(`${"─".repeat(60)}\n`);
}
async function run(
cmd: string[],
options: { cwd?: string; stdout?: "inherit" | "pipe" } = {},
): Promise<string> {
const cwd = options.cwd ?? root;
const label = cmd.join(" ");
if (dryRun) {
console.log(` [dry-run] ${label}`);
return "";
}
console.log(` $ ${label}`);
const proc = Bun.spawn(cmd, {
cwd,
stdin: "inherit",
stdout: options.stdout ?? "inherit",
stderr: "inherit",
});
const exitCode = await proc.exited;
const stdout =
options.stdout === "pipe" && proc.stdout
? await new Response(proc.stdout).text()
: "";
if (exitCode !== 0) {
throw new Error(`Command failed (exit ${exitCode}): ${label}`);
}
return stdout;
}
async function stageSdkReadmeForPublish(
workspace: (typeof SDK_PUBLISH_ORDER)[number],
): Promise<string | undefined> {
if (workspace !== "sdk") {
return undefined;
}
const destination = join(packagesDir, "sdk", "README.md");
if (dryRun) {
console.log(` [dry-run] Copy README.md to ${destination}`);
return undefined;
}
await copyFile(
join(sdkRoot, "README.md"),
destination,
constants.COPYFILE_EXCL,
);
return destination;
}
async function removeStagedSdkReadme(path: string | undefined): Promise<void> {
if (!path) {
return;
}
await unlink(path);
}
async function confirm(prompt: string): Promise<boolean> {
process.stdout.write(`${prompt} [y/N] `);
for await (const line of console) {
const answer = line.trim().toLowerCase();
return answer === "y" || answer === "yes";
}
return false;
}
function incrementPatchVersion(input: string): string {
const match = input.match(/^(\d+)\.(\d+)\.(\d+)(-[\w.]+)?$/);
if (!match) {
throw new Error(`Invalid semver version: ${input}`);
}
const [, major, minor, patch] = match;
return `${major}.${minor}.${Number(patch) + 1}`;
}
async function resolveVersion(): Promise<string> {
if (explicitVersion) return explicitVersion;
// Read the current version from the first workspace package that has one,
// same logic as version.ts.
const dirs = await readdir(packagesDir, { withFileTypes: true });
for (const dir of dirs) {
if (!dir.isDirectory()) continue;
try {
const raw = await readFile(
join(packagesDir, dir.name, "package.json"),
"utf-8",
);
const pkg = JSON.parse(raw);
if (typeof pkg.version === "string" && pkg.version !== "0.0.0") {
const next = incrementPatchVersion(pkg.version);
console.log(
` No version specified, auto-incrementing: ${pkg.version} -> ${next}`,
);
return next;
}
} catch {
// skip
}
}
throw new Error(
"Could not determine current version from workspace packages.",
);
}
async function resolveCliVersion(): Promise<string> {
const raw = await readFile(join(cliDir, "package.json"), "utf-8");
const pkg: unknown = JSON.parse(raw);
const version =
pkg !== null &&
typeof pkg === "object" &&
!Array.isArray(pkg) &&
"version" in pkg &&
typeof pkg.version === "string"
? pkg.version
: undefined;
if (!version) {
throw new Error("Could not determine version from apps/cli/package.json.");
}
if (!/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(version)) {
throw new Error(`Invalid apps/cli/package.json version: ${version}`);
}
if (explicitVersion && explicitVersion !== version) {
throw new Error(
`CLI version argument ${explicitVersion} does not match apps/cli/package.json version ${version}. Update apps/cli/package.json first.`,
);
}
return version;
}
async function ensureMainBranch(): Promise<void> {
const branch = (
await run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { stdout: "pipe" })
).trim();
if (dryRun) {
console.log(` [dry-run] Current branch: ${branch || "(unknown)"}`);
return;
}
if (branch === MAIN_BRANCH) {
console.log(` Already on ${MAIN_BRANCH}`);
return;
}
// Check for uncommitted changes before switching
const status = (
await run(["git", "status", "--porcelain"], { stdout: "pipe" })
).trim();
if (status) {
throw new Error(
`Working tree is dirty. Commit or stash changes before releasing.\n${status}`,
);
}
console.log(` Switching from ${branch} to ${MAIN_BRANCH}...`);
await run(["git", "checkout", MAIN_BRANCH]);
await run(["git", "pull", "--ff-only"]);
}
async function ensureCleanWorkingTree(): Promise<void> {
const status = (
await run(["git", "status", "--porcelain"], { stdout: "pipe" })
).trim();
if (status) {
throw new Error(
`Working tree is dirty. Commit or stash changes before releasing.\n${status}`,
);
}
}
async function ensureCliReleaseTag(version: string): Promise<void> {
const expectedTag = `cli-v${version}`;
if (dryRun) {
console.log(` [dry-run] Required pushed git tag: ${expectedTag}`);
return;
}
const headCommit = (
await run(["git", "rev-parse", "HEAD"], { stdout: "pipe" })
).trim();
const shortHead = headCommit.slice(0, 12);
const tagsAtHead = (
await run(["git", "tag", "--points-at", "HEAD"], { stdout: "pipe" })
)
.split(/\r?\n/)
.map((tag) => tag.trim())
.filter(Boolean);
if (!tagsAtHead.includes(expectedTag)) {
const localTagExists = (
await run(["git", "tag", "--list", expectedTag], { stdout: "pipe" })
).trim();
if (localTagExists) {
const taggedCommit = (
await run(["git", "rev-parse", `${expectedTag}^{commit}`], {
stdout: "pipe",
})
).trim();
throw new Error(
[
`Tag ${expectedTag} points at ${taggedCommit.slice(0, 12)}, but current HEAD is ${shortHead}.`,
"Check out the tagged commit before publishing:",
` git checkout ${expectedTag}`,
].join("\n"),
);
}
throw new Error(
`Current HEAD is not tagged ${expectedTag}. Create and push the tag before publishing.`,
);
}
const localTagCommit = (
await run(
["git", "rev-parse", "-q", "--verify", `${expectedTag}^{commit}`],
{
stdout: "pipe",
},
)
).trim();
if (localTagCommit !== headCommit) {
throw new Error(
`Current commit is not tagged ${expectedTag}. Create the tag on HEAD before publishing.`,
);
}
const remoteRefs = (
await run(
[
"git",
"ls-remote",
"origin",
`refs/tags/${expectedTag}`,
`refs/tags/${expectedTag}^{}`,
],
{ stdout: "pipe" },
)
)
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const remotePeeledRef = remoteRefs.find((line) =>
line.endsWith(`refs/tags/${expectedTag}^{}`),
);
const remoteTagRef = remoteRefs.find((line) =>
line.endsWith(`refs/tags/${expectedTag}`),
);
const remoteTagCommit = (remotePeeledRef ?? remoteTagRef)?.split(/\s+/)[0];
if (remoteTagCommit !== headCommit) {
if (remoteTagCommit) {
throw new Error(
[
`Remote tag ${expectedTag} points at ${remoteTagCommit.slice(0, 12)}, but current HEAD is ${shortHead}.`,
"Check out the tagged commit or push the correct tag before publishing.",
].join("\n"),
);
}
throw new Error(
`Remote tag ${expectedTag} must exist on origin and point at HEAD before publishing.`,
);
}
console.log(` Found pushed git tag: ${expectedTag}`);
}
async function getPublishedPackages(): Promise<
{ name: string; workspace: string }[]
> {
const dirs = await readdir(packagesDir, { withFileTypes: true });
const packages: { name: string; workspace: string }[] = [];
for (const dir of dirs) {
if (!dir.isDirectory()) continue;
try {
const raw = await readFile(
join(packagesDir, dir.name, "package.json"),
"utf-8",
);
const pkg = JSON.parse(raw);
if (!pkg.internal && typeof pkg.name === "string") {
packages.push({ name: pkg.name, workspace: dir.name });
}
} catch {
// skip directories without package.json
}
}
return packages;
}
// ── SDK Release ───────────────────────────────────────────────────────────────
async function releaseSDK(version: string): Promise<number> {
const published = await getPublishedPackages();
const packageNames = published.map((p) => p.name);
console.log(`\nRelease SDK`);
console.log(` Version: ${version}`);
console.log(` Tag: ${npmTag}`);
console.log(` Dry run: ${dryRun}`);
console.log(` Skip tests: ${skipTests}`);
console.log(` Packages: ${packageNames.join(", ")}`);
if (!dryRun) {
const ok = await confirm("\nProceed with SDK release?");
if (!ok) {
console.log("Aborted.");
return 1;
}
}
// Step 1: Tests
if (!skipTests) {
header("Step 1/5: Running tests");
await run(["bun", "run", "test"]);
} else {
header("Step 1/5: Skipping tests (--skip-tests)");
}
// Step 2: Update versions
// version.ts handles: version bump -> lockfile regeneration -> generate:models -> format -> build
header("Step 2/5: Updating package versions and lockfile");
await run(["bun", "scripts/version.ts", version], { cwd: sdkRoot });
// Step 3: Verify publishability
header("Step 3/5: Verifying packed tarballs");
await run(["bun", "scripts/check-publish.ts"], { cwd: sdkRoot });
// Step 4: Publish in dependency order
header("Step 4/5: Publishing packages");
for (const workspace of SDK_PUBLISH_ORDER) {
const pkgDir = join(packagesDir, workspace);
const name = `@cline/${workspace}`;
console.log(` Publishing ${name}@${version} with tag '${npmTag}'...`);
const stagedReadme = await stageSdkReadmeForPublish(workspace);
try {
await run(["bun", "publish", "--tag", npmTag, "--access", "public"], {
cwd: pkgDir,
});
} finally {
await removeStagedSdkReadme(stagedReadme);
}
}
// Step 5: Git tag
if (npmTag === "latest" && !skipGitTags) {
header("Step 5/5: Creating git tag");
const gitTag = `sdk-v${version}`;
console.log(` Creating tag: ${gitTag}`);
await run(["git", "tag", "-a", gitTag, "-m", `SDK v${version}`]);
if (!dryRun) {
const pushOk = await confirm(
"\nPush tag to remote? This makes the release public.",
);
if (pushOk) {
await run(["git", "push", "origin", `refs/tags/${gitTag}`]);
} else {
console.log(` Skipped pushing tag. Push manually with:`);
console.log(` git push origin refs/tags/${gitTag}`);
}
}
} else if (skipGitTags) {
header("Step 5/5: Skipping git tag (--skip-git-tags)");
} else {
header("Step 5/5: Skipping git tag (non-latest channel)");
}
// Done
console.log(`\n${"═".repeat(60)}`);
if (dryRun) {
console.log(" Dry run complete. No SDK packages were published.");
} else {
console.log(` Published SDK packages with tag '${npmTag}':`);
for (const workspace of SDK_PUBLISH_ORDER) {
console.log(` - @cline/${workspace}@${version}`);
}
}
console.log(`${"═".repeat(60)}\n`);
return 0;
}
// ── CLI Release ───────────────────────────────────────────────────────────────
// Builds cross-platform binaries via apps/cli/script/build.ts, then publishes
// the generated platform packages and wrapper package to npm.
async function releaseCLI(version: string): Promise<number> {
console.log(`\nRelease CLI`);
console.log(` Version: ${version}`);
console.log(` Git tag: cli-v${version}`);
console.log(` Tag: ${npmTag}`);
console.log(` Dry run: ${dryRun}`);
console.log(` Skip tests: ${skipTests}`);
if (!dryRun) {
const ok = await confirm("\nProceed with CLI release?");
if (!ok) {
console.log("Aborted.");
return 1;
}
}
// Step 1: Tests
if (!skipTests) {
header("Step 1/3: Running tests");
await run(["bun", "run", "test"]);
} else {
header("Step 1/3: Skipping tests (--skip-tests)");
}
// Step 2: Build all platform binaries
header("Step 2/3: Cross-compiling for all platforms");
await run(["bun", "script/build.ts", "--install-native-variants"], {
cwd: cliDir,
});
// Step 3: Publish to npm
header("Step 3/3: Publishing to npm");
const npmArgs = ["bun", "script/publish-npm.ts", "--tag", npmTag];
if (dryRun) {
npmArgs.push("--dry-run");
}
await run(npmArgs, { cwd: cliDir });
console.log(`\n${"═".repeat(60)}`);
if (dryRun) {
console.log(" Dry run complete. CLI was not published.");
} else {
console.log(` CLI v${version} published to npm.`);
console.log("");
console.log(" Install via npm:");
console.log(" npm install -g cline");
}
console.log(`${"═".repeat(60)}\n`);
return 0;
}
// ── Main ──────────────────────────────────────────────────────────────────────
let exitCode: number;
if (target === "sdk") {
header("Checking branch");
await ensureMainBranch();
const version = await resolveVersion();
exitCode = await releaseSDK(version);
} else {
header("Checking CLI release tag");
const version = await resolveCliVersion();
await ensureCleanWorkingTree();
await ensureCliReleaseTag(version);
exitCode = await releaseCLI(version);
}
process.exit(exitCode);