Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 35 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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): <version>` 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<version> && git push origin v<version>
```

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
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -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",
Expand Down
68 changes: 68 additions & 0 deletions scripts/release-prep.ts
Original file line number Diff line number Diff line change
@@ -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): <version>` 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<string> {
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)");