|
| 1 | +#!/usr/bin/env node |
| 2 | + |
| 3 | +import { execFileSync } from "node:child_process"; |
| 4 | +import { readFileSync, writeFileSync } from "node:fs"; |
| 5 | + |
| 6 | +const CLI_PACKAGE_PATH = "packages/cli/package.json"; |
| 7 | +const LOCKFILE_PATH = "package-lock.json"; |
| 8 | +const CLI_DIST_PATH = "packages/cli/dist/cli.js"; |
| 9 | +const KNOWN_FLAGS = new Set([ |
| 10 | + "--push", |
| 11 | + "--dry-run", |
| 12 | + "--skip-checks", |
| 13 | + "--allow-dirty", |
| 14 | + "--allow-non-main", |
| 15 | + "--help", |
| 16 | + "-h", |
| 17 | +]); |
| 18 | +const RELEASE_CHECKS = [ |
| 19 | + ["npm", ["-w", "packages/cli", "run", "build"]], |
| 20 | + ["npm", ["-w", "packages/cli", "run", "typecheck"]], |
| 21 | + ["npm", ["-w", "packages/cli", "run", "lint"]], |
| 22 | + ["npm", ["-w", "packages/cli", "test"]], |
| 23 | +]; |
| 24 | + |
| 25 | +function printUsage() { |
| 26 | + console.log(`Usage: |
| 27 | + npm run release -- <version> [--push] |
| 28 | +
|
| 29 | +Examples: |
| 30 | + npm run release -- 1.0.6 |
| 31 | + npm run release -- 1.0.6 --push |
| 32 | + npm run release -- 1.0.6 --dry-run --allow-non-main --allow-dirty |
| 33 | +
|
| 34 | +Options: |
| 35 | + --push Push main and the release tag after creating them locally. |
| 36 | + --dry-run Print the planned release without editing files, committing, or tagging. |
| 37 | + --skip-checks Skip local build/typecheck/lint/test checks. |
| 38 | + --allow-dirty Allow a dirty working tree before the release command starts. |
| 39 | + --allow-non-main Allow running from a branch other than main. |
| 40 | + --help Show this help. |
| 41 | +`); |
| 42 | +} |
| 43 | + |
| 44 | +function run(command, args, opts = {}) { |
| 45 | + const printable = [command, ...args].join(" "); |
| 46 | + if (opts.dryRun) { |
| 47 | + console.log(`$ ${printable}`); |
| 48 | + return ""; |
| 49 | + } |
| 50 | + console.log(`$ ${printable}`); |
| 51 | + return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }); |
| 52 | +} |
| 53 | + |
| 54 | +function readJson(path) { |
| 55 | + return JSON.parse(readFileSync(path, "utf8")); |
| 56 | +} |
| 57 | + |
| 58 | +function writeJson(path, value, dryRun) { |
| 59 | + if (dryRun) return; |
| 60 | + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); |
| 61 | +} |
| 62 | + |
| 63 | +function normalizeVersion(input) { |
| 64 | + const version = input.startsWith("v") ? input.slice(1) : input; |
| 65 | + if (!/^\d+\.\d+\.\d+$/.test(version)) { |
| 66 | + throw new Error(`Release version must be x.y.z, got: ${input}`); |
| 67 | + } |
| 68 | + return version; |
| 69 | +} |
| 70 | + |
| 71 | +function gitOutput(args) { |
| 72 | + return execFileSync("git", args, { encoding: "utf8" }).trim(); |
| 73 | +} |
| 74 | + |
| 75 | +function assertCleanWorktree(allowDirty) { |
| 76 | + if (allowDirty) return; |
| 77 | + const status = gitOutput(["status", "--porcelain"]); |
| 78 | + if (status) { |
| 79 | + throw new Error( |
| 80 | + "Working tree must be clean before release. Commit/stash changes or pass --allow-dirty.", |
| 81 | + ); |
| 82 | + } |
| 83 | +} |
| 84 | + |
| 85 | +function assertMainBranch(allowNonMain) { |
| 86 | + if (allowNonMain) return; |
| 87 | + const branch = gitOutput(["branch", "--show-current"]); |
| 88 | + if (branch !== "main") { |
| 89 | + throw new Error(`Release command must run on main, current branch is: ${branch}`); |
| 90 | + } |
| 91 | +} |
| 92 | + |
| 93 | +function assertTagAvailable(tag) { |
| 94 | + const existing = gitOutput(["tag", "--list", tag]); |
| 95 | + if (existing) throw new Error(`Local tag already exists: ${tag}`); |
| 96 | +} |
| 97 | + |
| 98 | +function updateVersions(version, dryRun) { |
| 99 | + const cliPackage = readJson(CLI_PACKAGE_PATH); |
| 100 | + cliPackage.version = version; |
| 101 | + writeJson(CLI_PACKAGE_PATH, cliPackage, dryRun); |
| 102 | + |
| 103 | + const lockfile = readJson(LOCKFILE_PATH); |
| 104 | + const packages = lockfile.packages; |
| 105 | + const cliPackageLock = packages?.["packages/cli"]; |
| 106 | + if (!cliPackageLock) { |
| 107 | + throw new Error("package-lock.json is missing packages/cli metadata."); |
| 108 | + } |
| 109 | + cliPackageLock.version = version; |
| 110 | + writeJson(LOCKFILE_PATH, lockfile, dryRun); |
| 111 | +} |
| 112 | + |
| 113 | +function previewReleaseNotes(tag) { |
| 114 | + console.log("\nRelease note preview:\n"); |
| 115 | + const output = execFileSync( |
| 116 | + "git-cliff", |
| 117 | + ["--config", ".github/cliff.toml", "--unreleased", "--tag", tag, "--strip", "header"], |
| 118 | + { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }, |
| 119 | + ); |
| 120 | + console.log(output.trim()); |
| 121 | + console.log(""); |
| 122 | +} |
| 123 | + |
| 124 | +function commitAndTag(version, tag, dryRun) { |
| 125 | + run("git", ["add", CLI_PACKAGE_PATH, LOCKFILE_PATH], { dryRun }); |
| 126 | + run("git", ["add", "-f", CLI_DIST_PATH], { dryRun }); |
| 127 | + run( |
| 128 | + "git", |
| 129 | + [ |
| 130 | + "commit", |
| 131 | + "-m", |
| 132 | + `chore: bump version to ${version}`, |
| 133 | + "-m", |
| 134 | + [ |
| 135 | + "Why", |
| 136 | + `Prepare the CLI package for the v${version} release. The release workflow publishes the committed package version, so package metadata and the bundled CLI must match the tag.`, |
| 137 | + "", |
| 138 | + "User impact", |
| 139 | + `The v${version} npm package and GitHub release will publish the changes already merged on main.`, |
| 140 | + "", |
| 141 | + "Verification", |
| 142 | + "npm -w packages/cli run build", |
| 143 | + "npm -w packages/cli run typecheck", |
| 144 | + "npm -w packages/cli run lint", |
| 145 | + "npm -w packages/cli test", |
| 146 | + `git-cliff --config .github/cliff.toml --unreleased --tag ${tag} --strip header`, |
| 147 | + "", |
| 148 | + "Release note: skip", |
| 149 | + ].join("\n"), |
| 150 | + ], |
| 151 | + { dryRun }, |
| 152 | + ); |
| 153 | + run("git", ["tag", "-a", tag, "-m", tag], { dryRun }); |
| 154 | +} |
| 155 | + |
| 156 | +function parseArgs(argv) { |
| 157 | + const flags = new Set(argv.filter((arg) => arg.startsWith("-"))); |
| 158 | + const positional = argv.filter((arg) => !arg.startsWith("-")); |
| 159 | + for (const flag of flags) { |
| 160 | + if (!KNOWN_FLAGS.has(flag)) { |
| 161 | + throw new Error(`Unknown option: ${flag}`); |
| 162 | + } |
| 163 | + } |
| 164 | + if (positional.length > 1) { |
| 165 | + throw new Error(`Expected one version argument, got: ${positional.join(" ")}`); |
| 166 | + } |
| 167 | + return { |
| 168 | + version: positional[0] ?? null, |
| 169 | + push: flags.has("--push"), |
| 170 | + dryRun: flags.has("--dry-run"), |
| 171 | + skipChecks: flags.has("--skip-checks"), |
| 172 | + allowDirty: flags.has("--allow-dirty"), |
| 173 | + allowNonMain: flags.has("--allow-non-main"), |
| 174 | + help: flags.has("--help") || flags.has("-h"), |
| 175 | + }; |
| 176 | +} |
| 177 | + |
| 178 | +function main() { |
| 179 | + const args = parseArgs(process.argv.slice(2)); |
| 180 | + if (args.help || !args.version) { |
| 181 | + printUsage(); |
| 182 | + process.exit(args.help ? 0 : 1); |
| 183 | + } |
| 184 | + |
| 185 | + const version = normalizeVersion(args.version); |
| 186 | + const tag = `v${version}`; |
| 187 | + |
| 188 | + assertMainBranch(args.allowNonMain); |
| 189 | + assertCleanWorktree(args.allowDirty); |
| 190 | + assertTagAvailable(tag); |
| 191 | + |
| 192 | + console.log(`Preparing ${tag}${args.dryRun ? " (dry run)" : ""}`); |
| 193 | + updateVersions(version, args.dryRun); |
| 194 | + |
| 195 | + if (!args.skipChecks) { |
| 196 | + for (const [command, commandArgs] of RELEASE_CHECKS) { |
| 197 | + run(command, commandArgs, { dryRun: args.dryRun }); |
| 198 | + } |
| 199 | + } |
| 200 | + |
| 201 | + previewReleaseNotes(tag); |
| 202 | + commitAndTag(version, tag, args.dryRun); |
| 203 | + |
| 204 | + if (args.push) { |
| 205 | + run("git", ["push", "origin", "main"], { dryRun: args.dryRun }); |
| 206 | + run("git", ["push", "origin", tag], { dryRun: args.dryRun }); |
| 207 | + } else { |
| 208 | + console.log(`Local release commit and ${tag} tag are ready.`); |
| 209 | + console.log(`Run: git push origin main && git push origin ${tag}`); |
| 210 | + } |
| 211 | +} |
| 212 | + |
| 213 | +try { |
| 214 | + main(); |
| 215 | +} catch (error) { |
| 216 | + console.error(`release: ${error instanceof Error ? error.message : String(error)}`); |
| 217 | + process.exit(1); |
| 218 | +} |
0 commit comments