From 21f435e4250da881621095d9e7bf475d82744176 Mon Sep 17 00:00:00 2001 From: Graham Barber Date: Thu, 16 Jul 2026 19:17:37 -0700 Subject: [PATCH] add changelog generation to the release flow - scripts/changelog.ts: group commits by `area:` prefix into Keep-a-Changelog sections; release boundaries are package.json version bumps, no git tags - release.ts prepends the section and bumps package.json; `deno task changelog` previews the pending section - Dockerfile: point image source/url labels at the Tangled repo Co-Authored-By: Claude Opus 4.8 --- Dockerfile | 4 +- deno.json | 3 +- scripts/changelog.ts | 87 ++++++++++++++++++++++++++++++++++++++++++++ scripts/release.ts | 42 +++++++++++++++++---- 4 files changed, 125 insertions(+), 11 deletions(-) create mode 100644 scripts/changelog.ts diff --git a/Dockerfile b/Dockerfile index 2425081..0c50419 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,8 +25,8 @@ ARG REVISION # Deno image's labels instead of Quantum's. See https://specs.opencontainers.org LABEL org.opencontainers.image.title="Quantum" LABEL org.opencontainers.image.description="Personal finance app: SvelteKit + Deno + SQLite with automatic SimpleFIN account sync." -LABEL org.opencontainers.image.source="https://atcr.io/r/graham.systems/quantum" -LABEL org.opencontainers.image.url="https://atcr.io/r/graham.systems/quantum" +LABEL org.opencontainers.image.source="https://tangled.org/did:plc:tpgm6lapok6izkduegieg57j" +LABEL org.opencontainers.image.url="https://tangled.org/did:plc:tpgm6lapok6izkduegieg57j" LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later" LABEL org.opencontainers.image.authors="Graham Barber" LABEL org.opencontainers.image.version="${VERSION}" diff --git a/deno.json b/deno.json index 0578fc3..8fb60df 100644 --- a/deno.json +++ b/deno.json @@ -9,7 +9,8 @@ "test": "deno test -A src", "generate-key": "deno run scripts/generate-oauth-key.ts", "version": "deno run --allow-read scripts/version.ts", - "release": "deno run --allow-read --allow-write scripts/release.ts", + "changelog": "deno run --allow-read --allow-run=git scripts/changelog.ts", + "release": "deno run --allow-read --allow-write --allow-run=git scripts/release.ts", "image": "deno run --allow-read --allow-run=git,docker scripts/build-image.ts" } } diff --git a/scripts/changelog.ts b/scripts/changelog.ts new file mode 100644 index 0000000..275496c --- /dev/null +++ b/scripts/changelog.ts @@ -0,0 +1,87 @@ +// Generate CHANGELOG.md sections from git history, grouped by the `area:` prefix +// in commit subjects (e.g. "csv import: style the file input"). The ChronVer +// version is the section heading. +// +// deno task changelog # preview the pending section (commits since last release) +// +// Release boundaries are the commits that changed package.json's `version` +// field — no git tags required, so the jj workflow stays friction-free. +// `deno task release` calls renderSection() to prepend a new section. +import { nextVersion } from "./version.ts"; + +async function git(args: string[]): Promise { + const { code, stdout, stderr } = await new Deno.Command("git", { + args, + stdout: "piped", + stderr: "piped", + }).output(); + if (code !== 0) { + throw new Error(`git ${args.join(" ")}: ${new TextDecoder().decode(stderr)}`); + } + return new TextDecoder().decode(stdout).trim(); +} + +/** + * The most recent commit that changed package.json's version — i.e. the last + * release. Empty string if the version line was never touched. `-G` matches + * commits whose diff adds or removes a line containing `"version"`. + */ +export async function lastReleaseCommit(): Promise { + return await git(["log", "-1", "--format=%H", "-G", '"version"', "--", "package.json"]); +} + +// Skip the release commits themselves (version/changelog bumps) so they don't +// show up as entries in the next section. +const RELEASE_SUBJECT = /^(release\b|\d{4}\.\d{2}\.\d{2}\b)/i; + +/** Commit subjects for the pending release, oldest first. */ +export async function pendingCommits(): Promise { + const since = await lastReleaseCommit(); + const range = since ? [`${since}..HEAD`] : ["HEAD"]; + const out = await git(["log", ...range, "--reverse", "--no-merges", "--format=%s"]); + if (!out) return []; + return out + .split("\n") + .map((s) => s.trim()) + .filter((s) => s && !RELEASE_SUBJECT.test(s)); +} + +interface Entry { + area: string; + summary: string; +} + +function parse(subject: string): Entry { + const m = subject.match(/^([a-z][a-z0-9 +/_-]*?):\s+(.*)$/i); + if (m) return { area: m[1].trim(), summary: m[2].trim() }; + return { area: "other", summary: subject }; +} + +/** Render a Keep-a-Changelog section, grouping entries by their area prefix. */ +export function renderSection(version: string, subjects: string[]): string { + const groups = new Map(); + for (const s of subjects) { + const { area, summary } = parse(s); + const bucket = groups.get(area) ?? []; + bucket.push(summary); + groups.set(area, bucket); + } + + const lines = [`## ${version}`, ""]; + if (groups.size === 0) { + lines.push("_No user-facing changes._", ""); + return lines.join("\n"); + } + for (const [area, summaries] of groups) { + lines.push(`### ${area}`, ""); + for (const s of summaries) lines.push(`- ${s}`); + lines.push(""); + } + return lines.join("\n"); +} + +if (import.meta.main) { + const version = await nextVersion(); + const subjects = await pendingCommits(); + console.log(renderSection(version, subjects)); +} diff --git a/scripts/release.ts b/scripts/release.ts index d1265a7..1fc3a6f 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -1,15 +1,25 @@ -// Cut a ChronVer release: compute the next version and write it to package.json. +// Cut a ChronVer release: compute the next version, prepend a changelog section +// for the pending commits, and write the version to package.json. // // deno task release # e.g. 2026.07.16, then 2026.07.16.1 same day // deno task release --break # append a -break suffix (breaking change) // deno task release --feature # append a -feature suffix (feature branch) // -// This only touches package.json. Commit it with jj as usual; the version then -// rides along with the commit and into `deno task image`. No git tag is created -// (add one by hand if you want: `git tag ` works in the colocated repo). +// This only edits CHANGELOG.md and package.json. Review the generated changelog +// (it is a draft — curate it), then commit both with jj. Suggested description: +// `release `. The version then rides along into `deno task image`. import { nextVersion } from "./version.ts"; +import { pendingCommits, renderSection } from "./changelog.ts"; const PKG = new URL("../package.json", import.meta.url); +const CHANGELOG = new URL("../CHANGELOG.md", import.meta.url); + +const PREAMBLE = `# Changelog + +All notable changes to Quantum are documented here. Versions follow +[ChronVer](https://chronver.org); the format follows +[Keep a Changelog](https://keepachangelog.com). +`; const suffix = Deno.args.includes("--break") ? "break" @@ -19,12 +29,28 @@ const suffix = Deno.args.includes("--break") const version = await nextVersion(suffix); -const raw = await Deno.readTextFile(PKG); -const pkg = JSON.parse(raw); +// Build the new section from commits since the last release. +const section = renderSection(version, await pendingCommits()); + +// Prepend it: keep the preamble, then the new section, then the prior sections. +let prior = ""; +try { + const existing = await Deno.readTextFile(CHANGELOG); + const firstHeading = existing.indexOf("## "); + prior = firstHeading === -1 ? "" : existing.slice(firstHeading); +} catch { + // No CHANGELOG.md yet — first release creates it. +} +const changelog = `${PREAMBLE}\n${section}\n${prior}`.trimEnd() + "\n"; +await Deno.writeTextFile(CHANGELOG, changelog); + +// Bump package.json (source of truth), preserving tab indentation. +const pkg = JSON.parse(await Deno.readTextFile(PKG)); const previous = pkg.version; pkg.version = version; -// Preserve the tab indentation the file already uses and keep a trailing newline. await Deno.writeTextFile(PKG, JSON.stringify(pkg, null, "\t") + "\n"); console.log(`Released ${version} (was ${previous}).`); -console.log("package.json updated. Commit it, then `deno task image` to build."); +console.log("Updated CHANGELOG.md and package.json."); +console.log("Next: review/curate CHANGELOG.md, then commit both"); +console.log(` (e.g. \`jj describe -m "release ${version}"\`), then \`deno task image\`.`); -- 2.51.2