From 654cfef02366b6de77101606aef6e5c2a01f4ef0 Mon Sep 17 00:00:00 2001 From: Casey Vega Date: Sun, 14 Jun 2026 15:27:03 -0700 Subject: [PATCH 1/2] build(release): add git-cliff version-bump script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `bun run release:prep`, which computes the next semver from the Conventional Commits since the last tag (via git-cliff — breaking → major, feat → minor, else → patch), writes it to package.json, and regenerates CHANGELOG.md so the unreleased section is filed under the new version. It stops short of committing or tagging, so the release still goes through a normal reviewed `chore(release): ` PR and the existing tag-triggered release.yml — it just removes the hand-editing that set the version for v0.0.2 and v0.0.3. --- bun.lock | 15 +++++++++ package.json | 2 ++ scripts/release-prep.ts | 68 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 scripts/release-prep.ts diff --git a/bun.lock b/bun.lock index 7e88253..16f8dd6 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,7 @@ "@tailwindcss/vite": "^4.3.1", "@types/bun": "^1.3.14", "@types/primer__octicons": "^19.21.0", + "git-cliff": "^2.13.1", "jscpd": "^5.0.9", "knip": "^6.16.1", "madge": "^8.0.0", @@ -687,6 +688,20 @@ "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], + "git-cliff": ["git-cliff@2.13.1", "", { "dependencies": { "execa": "^9.6.0" }, "optionalDependencies": { "git-cliff-darwin-arm64": "2.13.1", "git-cliff-darwin-x64": "2.13.1", "git-cliff-linux-arm64": "2.13.1", "git-cliff-linux-x64": "2.13.1", "git-cliff-windows-arm64": "2.13.1", "git-cliff-windows-x64": "2.13.1" }, "bin": "lib/cli/cli.js" }, "sha512-2BzXwrom+SMHeNA5Ut+MtzqXejg0wbVwmzj3k7e9w62UQoyCDrM9UIcvtl6hnT3jocEQ1zLRQBaXXx97Gmnk7A=="], + + "git-cliff-darwin-arm64": ["git-cliff-darwin-arm64@2.13.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3ebPUnUlLmrSZDHknSZQmyZV7DEJVtKse8I25Am0cENET5Py9u9Hg9k8IRXdiDtHtPDs6MYZx7BOi11WtcfqSg=="], + + "git-cliff-darwin-x64": ["git-cliff-darwin-x64@2.13.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-KZfggGAiw1EvZH3BOUclEU4eGCP2+Z+lH/N2Ni3FH9L2M1U7FYJqqaMhqgO8azTj67betvDshH8WBUkIkSsVxA=="], + + "git-cliff-linux-arm64": ["git-cliff-linux-arm64@2.13.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-clNRcNzdvk4GKyTt3ZhhWqcrjVRghcUcGNSV/Y87YJf3Mc/zl9ajUAhnXPOBSX/KWBr2od5SmkCt0qGYagY07g=="], + + "git-cliff-linux-x64": ["git-cliff-linux-x64@2.13.1", "", { "os": "linux", "cpu": "x64" }, "sha512-gZcIQhIQ1lDUMD8UieUSEZAYoyZN7nPd8O3VQoj1Ddhqll4UAS1Zxms1SU3U8pb0UTdc2m3ia/wtOnyhvbjdjQ=="], + + "git-cliff-windows-arm64": ["git-cliff-windows-arm64@2.13.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-+XubuQv68DuDwF0u6Af5d889MEf/RD48VBQS7ZmPi4sUSCXAZqRm1/ApCsStLqxMDCf1+7s05B/kNbm9wjV80A=="], + + "git-cliff-windows-x64": ["git-cliff-windows-x64@2.13.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Bi8ehp1VMkomY7M/356PgovKQ8CBRiuOOkq+aWC2evQuMFfXWjG0GBlPED9QkZoUJ4iQ5ygB5DYdK3BjhaOyPw=="], + "gonzales-pe": ["gonzales-pe@4.3.0", "", { "dependencies": { "minimist": "^1.2.5" }, "bin": { "gonzales": "bin/gonzales.js" } }, "sha512-otgSPpUmdWJ43VXyiNgEYE4luzHCL2pz4wQ0OnDluC6Eg4Ko3Vexy/SrSynglw/eR+OhkzmqFCZa/OFa/RgAOQ=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], diff --git a/package.json b/package.json index 929aaed..8d88a7f 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "mutation": "stryker run", "ci": "bun run check && bun run build", "seed": "bun scripts/seed.ts", + "release:prep": "bun scripts/release-prep.ts", "verify": "bun run typecheck && bun run check && bun run lint && bun run format:check && bun run coverage:check && bun run dup && bun run deadcode && bun run cycles && bun run boundaries && bun run build && bun run audit", "verify:json": "bun scripts/verify-json.ts" }, @@ -44,6 +45,7 @@ "@tailwindcss/vite": "^4.3.1", "@types/bun": "^1.3.14", "@types/primer__octicons": "^19.21.0", + "git-cliff": "^2.13.1", "jscpd": "^5.0.9", "knip": "^6.16.1", "madge": "^8.0.0", diff --git a/scripts/release-prep.ts b/scripts/release-prep.ts new file mode 100644 index 0000000..49aeaf3 --- /dev/null +++ b/scripts/release-prep.ts @@ -0,0 +1,68 @@ +#!/usr/bin/env bun +/** + * release:prep — bump the version for the next release. + * + * Computes the next semantic version from the Conventional Commits since the + * last tag (via git-cliff: a breaking change → major, a feat → minor, anything + * else → patch), writes it to package.json, and regenerates CHANGELOG.md so the + * unreleased commits are filed under the new version. + * + * It deliberately stops there — it does NOT commit, tag, or push. Review the + * diff, then land it as a normal `chore(release): ` PR; pushing the + * matching `vX.Y.Z` tag is what triggers release.yml. This keeps the human gate + * before a release while removing the hand-editing that used to set the version. + * + * bun run release:prep + */ +import { $ } from "bun"; + +const root = new URL("..", import.meta.url).pathname; +const gitCliff = new URL("../node_modules/.bin/git-cliff", import.meta.url).pathname; +const configPath = new URL("../cliff.toml", import.meta.url).pathname; +const pkgPath = new URL("../package.json", import.meta.url).pathname; + +/** Run git-cliff with the repo config; throws (with stderr) on failure. */ +async function cliff(...args: string[]): Promise { + const result = await $`${gitCliff} --config ${configPath} ${args}`.cwd(root).quiet().nothrow(); + if (result.exitCode !== 0) { + process.stderr.write(result.stderr.toString()); + throw new Error(`git-cliff ${args.join(" ")} failed (exit ${result.exitCode})`); + } + return result.stdout.toString().trim(); +} + +// The version git-cliff assigns to the unreleased commits (e.g. "v0.0.4"), and +// the most recent release tag we'd be bumping from. +const bumped = await cliff("--bumped-version"); +const nextVersion = bumped.replace(/^v/, ""); +const lastTag = (await $`git describe --tags --abbrev=0`.cwd(root).quiet().nothrow()).stdout + .toString() + .trim(); + +// git-cliff returns the last tag unchanged when there are no releasable commits. +if (lastTag && bumped === lastTag) { + console.log(`No releasable commits since ${lastTag} — nothing to bump.`); + process.exit(0); +} + +// Edit only the version line so Biome's formatting (the `format` gate) is left +// untouched — a full JSON re-serialize would risk reformatting the file. +const pkgRaw = await Bun.file(pkgPath).text(); +const current = pkgRaw.match(/"version":\s*"([^"]+)"/)?.[1] ?? "(unknown)"; +if (current === nextVersion) { + console.log(`package.json is already at ${nextVersion} — nothing to bump.`); + process.exit(0); +} +await Bun.write(pkgPath, pkgRaw.replace(/("version":\s*)"[^"]+"/, `$1"${nextVersion}"`)); + +// Regenerate the changelog with the unreleased section under the new version. +await cliff("--bump", "--output", "CHANGELOG.md"); + +console.log(`Bumped ${current} → ${nextVersion} (updated package.json + CHANGELOG.md)`); +console.log(""); +console.log("Next:"); +console.log(" 1. Review the diff"); +console.log(` 2. Commit: git commit -am "chore(release): ${nextVersion}"`); +console.log(" 3. Open a PR and merge it"); +console.log(` 4. Tag: git tag v${nextVersion} && git push origin v${nextVersion}`); +console.log(" (the tag triggers release.yml: build → scan → draft release → changelog)"); From d13758ccd47174d83d9cb6532861489e9c1398e2 Mon Sep 17 00:00:00 2001 From: Casey Vega Date: Sun, 14 Jun 2026 15:28:48 -0700 Subject: [PATCH 2/2] docs(development): document the release:prep flow --- docs/development.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/development.md b/docs/development.md index d32fcf7..105c3cf 100644 --- a/docs/development.md +++ b/docs/development.md @@ -169,6 +169,41 @@ docker compose restart --- +## Releasing + +Versions follow [Semantic Versioning](https://semver.org) and are derived from +the Conventional Commit history — you don't hand-edit the number. + +```bash +bun run release:prep +``` + +This runs git-cliff to compute the next version from the commits since the last +tag (breaking → major, `feat` → minor, anything else → patch), writes it to +`package.json`, and regenerates `CHANGELOG.md` with the unreleased section filed +under the new version. It does **not** commit, tag, or push. + +Then cut the release: + +1. Review the diff `bun run release:prep` produced. +2. Commit it as `chore(release): ` and open a PR (the title satisfies + the Conventional-Commit check; `chore(release)` is excluded from the + changelog itself). +3. Merge the PR. +4. Tag the merge commit and push the tag: + + ```bash + git tag v && git push origin v + ``` + +Pushing the `v*` tag triggers `release.yml`, which guards that the tag matches +`package.json`, runs the gate suite, builds and Trivy-scans the image, pushes it +to GHCR, drafts a GitHub Release with the git-cliff notes, and commits the +regenerated `CHANGELOG.md` back to `main`. The release is created as a **draft** +for review before publishing. + +--- + ## Conventions - **Fix root causes, not symptoms.** Don't silence a gate with `any`, a