From 3f4e2a29fc7fdb3461d68fad9efb0f8ee5532b99 Mon Sep 17 00:00:00 2001 From: 0xRoy <1997roylee@gmail.com> Date: Mon, 1 Jun 2026 15:01:15 +0800 Subject: [PATCH 1/2] Add Bun CLI release helper --- package.json | 1 + packages/cli/package.json | 3 +- packages/cli/scripts/release-cli.ts | 170 +++++++++++++++++++++++++ packages/cli/tests/release-cli.test.ts | 128 +++++++++++++++++++ 4 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 packages/cli/scripts/release-cli.ts create mode 100644 packages/cli/tests/release-cli.test.ts diff --git a/package.json b/package.json index 1e96c827..4b09443f 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "check:write": "bun run biome check --write .", "cron:once": "bun run ./packages/server/src/cron/run-cron.ts --once", "prepare:publish": "bun run --filter devos prepare:publish", + "release:cli": "bun run --filter devos release", "changeset": "changeset", "prepare": "bun run ./scripts/prepare.ts" }, diff --git a/packages/cli/package.json b/packages/cli/package.json index 02b0df62..52fad43c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -19,7 +19,8 @@ "lint": "biome lint .", "check": "biome check .", "check:write": "biome check --write .", - "prepare:publish": "bun run check && bun run typecheck && bun run test && bun run build && npm --cache ./.npm-cache pack --dry-run --ignore-scripts" + "prepare:publish": "bun run check && bun run typecheck && bun run test && bun run build && npm --cache ./.npm-cache pack --dry-run --ignore-scripts", + "release": "bun run ./scripts/release-cli.ts" }, "devDependencies": { "@devos/tsconfig": "workspace:*", diff --git a/packages/cli/scripts/release-cli.ts b/packages/cli/scripts/release-cli.ts new file mode 100644 index 00000000..e2751ded --- /dev/null +++ b/packages/cli/scripts/release-cli.ts @@ -0,0 +1,170 @@ +import path from "node:path"; + +export interface ReleaseCliOptions { + publish?: boolean; + tag?: string; + otp?: string; +} + +export type ReleaseCommandRunner = ( + command: string, + args: string[], + options: { + cwd: string; + env?: Record; + streamStdout?: boolean; + streamStderr?: boolean; + stdinMode?: "ignore" | "pipe"; + }, +) => Promise; + +export interface CommandResult { + code: number; + stdout: string; + stderr: string; +} + +export async function releaseCliPackage( + workspaceRoot: string, + options: ReleaseCliOptions = {}, + commandRunner?: ReleaseCommandRunner, +): Promise { + const runner = commandRunner ?? (await loadReleaseCommandRunner()); + const packageRoot = path.join(workspaceRoot, "packages/cli"); + const commands: Array<{ command: string; args: string[]; cwd: string }> = [ + { command: "git", args: ["status", "--porcelain"], cwd: workspaceRoot }, + { + command: "bun", + args: ["run", "--filter", "devos", "check"], + cwd: workspaceRoot, + }, + { + command: "bun", + args: ["run", "--filter", "devos", "typecheck"], + cwd: workspaceRoot, + }, + { + command: "bun", + args: ["run", "--filter", "devos", "test"], + cwd: workspaceRoot, + }, + { + command: "bun", + args: ["run", "--filter", "devos", "build"], + cwd: workspaceRoot, + }, + { + command: "bun", + args: ["pm", "pack", "--dry-run", "--ignore-scripts"], + cwd: packageRoot, + }, + { + command: "bun", + args: ["publish", "--dry-run", "--access", "public"], + cwd: packageRoot, + }, + ]; + if (options.publish) { + commands.push({ + command: "bun", + args: buildPublishArgs(options), + cwd: packageRoot, + }); + } + + for (const step of commands) { + const result = await runner(step.command, step.args, { + cwd: step.cwd, + streamStdout: true, + streamStderr: true, + }); + if (step.command === "git") { + assertCleanWorktree(result); + continue; + } + assertCommandSucceeded(step.command, step.args, result); + } +} + +async function loadReleaseCommandRunner(): Promise { + const { runCommand } = await import("../src/utils/shell"); + return runCommand; +} + +function buildPublishArgs(options: ReleaseCliOptions): string[] { + const args = ["publish", "--access", "public"]; + if (options.tag) { + args.push("--tag", options.tag); + } + if (options.otp) { + args.push("--otp", options.otp); + } + return args; +} + +function assertCleanWorktree(result: CommandResult): void { + if (result.code !== 0) { + throw new Error( + `Failed to verify git status:\n${result.stderr || result.stdout}`, + ); + } + if (result.stdout.trim().length > 0) { + throw new Error( + "Working tree is not clean. Commit or stash changes before releasing.", + ); + } +} + +function assertCommandSucceeded( + command: string, + args: string[], + result: CommandResult, +): void { + if (result.code !== 0) { + throw new Error( + `${command} ${args.join(" ")} failed with ${result.code}\n${result.stderr || result.stdout}`, + ); + } +} + +export function parseReleaseCliArgs(args: string[]): ReleaseCliOptions { + const options: ReleaseCliOptions = {}; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--publish") { + options.publish = true; + continue; + } + if (arg === "--tag") { + options.tag = readOptionValue(args, index, arg); + index += 1; + continue; + } + if (arg === "--otp") { + options.otp = readOptionValue(args, index, arg); + index += 1; + continue; + } + throw new Error(`Unknown release option: ${arg}`); + } + return options; +} + +function readOptionValue( + args: string[], + index: number, + option: string, +): string { + const value = args[index + 1]; + if (!value) { + throw new Error(`Missing value for ${option}`); + } + return value; +} + +if (import.meta.main) { + await releaseCliPackage( + path.resolve(import.meta.dir, "../../.."), + parseReleaseCliArgs(process.argv.slice(2)), + ); +} diff --git a/packages/cli/tests/release-cli.test.ts b/packages/cli/tests/release-cli.test.ts new file mode 100644 index 00000000..6397d961 --- /dev/null +++ b/packages/cli/tests/release-cli.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, mock } from "bun:test"; +import { + type CommandResult, + type ReleaseCommandRunner, + parseReleaseCliArgs, + releaseCliPackage, +} from "../scripts/release-cli"; + +function ok(stdout = ""): CommandResult { + return { code: 0, stdout, stderr: "" }; +} + +describe("releaseCliPackage", () => { + it("runs verification and publish dry-run without publishing by default", async () => { + const calls: Array<{ command: string; args: string[]; cwd: string }> = []; + const runner = mock( + async ( + command: string, + args: string[], + options: { cwd: string }, + ): Promise => { + calls.push({ command, args, cwd: options.cwd }); + return ok(""); + }, + ) as ReleaseCommandRunner; + + await releaseCliPackage("/repo", {}, runner); + + expect(calls).toEqual([ + { command: "git", args: ["status", "--porcelain"], cwd: "/repo" }, + { + command: "bun", + args: ["run", "--filter", "devos", "check"], + cwd: "/repo", + }, + { + command: "bun", + args: ["run", "--filter", "devos", "typecheck"], + cwd: "/repo", + }, + { + command: "bun", + args: ["run", "--filter", "devos", "test"], + cwd: "/repo", + }, + { + command: "bun", + args: ["run", "--filter", "devos", "build"], + cwd: "/repo", + }, + { + command: "bun", + args: ["pm", "pack", "--dry-run", "--ignore-scripts"], + cwd: "/repo/packages/cli", + }, + { + command: "bun", + args: ["publish", "--dry-run", "--access", "public"], + cwd: "/repo/packages/cli", + }, + ]); + }); + + it("publishes explicitly after verification", async () => { + const calls: Array<{ command: string; args: string[]; cwd: string }> = []; + const runner = mock( + async ( + command: string, + args: string[], + options: { cwd: string }, + ): Promise => { + calls.push({ command, args, cwd: options.cwd }); + return ok(""); + }, + ) as ReleaseCommandRunner; + + await releaseCliPackage( + "/repo", + { publish: true, tag: "next", otp: "123456" }, + runner, + ); + + expect(calls.at(-1)).toEqual({ + command: "bun", + args: [ + "publish", + "--access", + "public", + "--tag", + "next", + "--otp", + "123456", + ], + cwd: "/repo/packages/cli", + }); + }); + + it("fails when the worktree is dirty", async () => { + const runner = mock(async (command: string): Promise => { + if (command === "git") { + return ok(" M packages/cli/package.json\n"); + } + return ok(""); + }) as ReleaseCommandRunner; + + await expect(releaseCliPackage("/repo", {}, runner)).rejects.toThrow( + "Working tree is not clean", + ); + }); +}); + +describe("parseReleaseCliArgs", () => { + it("parses publish options", () => { + expect( + parseReleaseCliArgs(["--publish", "--tag", "next", "--otp", "123456"]), + ).toEqual({ + publish: true, + tag: "next", + otp: "123456", + }); + }); + + it("rejects unknown options", () => { + expect(() => parseReleaseCliArgs(["--surprise"])).toThrow( + "Unknown release option: --surprise", + ); + }); +}); From 2d14b77cb9923c7de35a5d3819e5239db1f07952 Mon Sep 17 00:00:00 2001 From: 0xRoy <1997roylee@gmail.com> Date: Mon, 1 Jun 2026 15:52:34 +0800 Subject: [PATCH 2/2] Add CLI release guide --- docs/release/README.md | 132 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 docs/release/README.md diff --git a/docs/release/README.md b/docs/release/README.md new file mode 100644 index 00000000..fda43fe3 --- /dev/null +++ b/docs/release/README.md @@ -0,0 +1,132 @@ +# Release Guide + +This guide covers releasing the published `devos` CLI package so users can run +it through the hosted installer, `npx devos`, or `bunx devos`. + +## Scope + +The release helper publishes the current `packages/cli` package version. It does +not create changesets, bump versions, update changelogs, or commit files. + +Use the helper when the CLI package is already versioned and committed. + +## Prerequisites + +1. Start from an up-to-date branch that includes current `main`. +2. Confirm the release version in `packages/cli/package.json`. +3. Commit version, changelog, and release-note changes before publishing. +4. Authenticate to the npm registry used by Bun. +5. Keep an npm one-time password ready if the package requires 2FA. + +Useful checks: + +```bash +git status --short --branch +bun pm whoami +``` + +## Prepare The Version + +If the release needs a version bump, run the normal changeset versioning flow +first and commit the result. + +```bash +bun run changeset version +git status --short +``` + +Review all changed package versions before committing. The CLI release script is +package-specific, but the changeset versioning step may update other workspace +packages when their changesets require it. + +## Dry Run + +Run the release helper without `--publish` first. + +```bash +bun run release:cli +``` + +The dry run stops unless the worktree is clean. It then runs: + +1. `bun run --filter devos check` +2. `bun run --filter devos typecheck` +3. `bun run --filter devos test` +4. `bun run --filter devos build` +5. `bun pm pack --dry-run --ignore-scripts` +6. `bun publish --dry-run --access public` + +Treat any failure as a release blocker. Fix the issue, commit the fix, and run +the dry run again. + +## Publish + +Publish only after the dry run succeeds on the same committed version. + +```bash +bun run --filter devos release --publish +``` + +Publish with a dist tag: + +```bash +bun run --filter devos release --publish --tag next +``` + +Publish with a one-time password: + +```bash +bun run --filter devos release --publish --otp 123456 +``` + +Dist tags and OTP can be combined: + +```bash +bun run --filter devos release --publish --tag next --otp 123456 +``` + +The publish command always runs the same verification and dry-run publish steps +before the real `bun publish`. + +## Post-Release Checks + +Verify the registry entry and the zero-install CLI path. + +```bash +bun pm view devos version +bunx devos@latest help +bunx devos@latest onboard --check +``` + +If you published with a non-`latest` tag, test that tag explicitly. + +```bash +bunx devos@next help +``` + +Check the hosted installer script without executing it: + +```bash +curl -fsSL https://devos.ing/cli +``` + +## Troubleshooting + +Dirty worktree: + +The release helper exits before checks when `git status --porcelain` has output. +Commit, stash, or revert unrelated changes before releasing. + +Registry auth failure: + +Run `bun pm whoami` to confirm the active registry identity. Re-authenticate with +your npm credentials if needed, then rerun the dry run. + +Two-factor auth failure: + +Rerun publish with `--otp `. + +Wrong version published: + +Do not overwrite the published version. Prepare and publish a new patch version +with a corrective changelog entry.