From e703aa1e34487ccfcc2605256a7b4208d3c1c61b Mon Sep 17 00:00:00 2001 From: Kalvin Chau Date: Thu, 13 Aug 2026 12:19:42 -0700 Subject: [PATCH 1/4] fix(release): generate cumulative stable notes select prerelease baselines from the latest tag and stable baselines from the previous stable release. refresh release tags and fall back to the first prerelease changelog baseline for initial stable promotion. --- docs/release-and-updates.md | 2 +- scripts/release/release.mjs | 50 ++++++++++++++++--- .../release/tests/release-tooling.test.mjs | 35 +++++++++++-- 3 files changed, 75 insertions(+), 12 deletions(-) diff --git a/docs/release-and-updates.md b/docs/release-and-updates.md index b9c672d3f..b787866eb 100644 --- a/docs/release-and-updates.md +++ b/docs/release-and-updates.md @@ -37,7 +37,7 @@ The updater manifest currently contains only `darwin-aarch64`. Windows and Linux Release tags use canonical SemVer without build metadata, such as `v1.2.3` or `v1.2.3-rc.1`. -1. Run `just release-prepare X.Y.Z`. It generates and prints release notes, requires explicit approval, then creates `release/vX.Y.Z`, synchronizes every release version and Cargo lock entry, updates `CHANGELOG.md`, validates, commits, pushes, and opens a PR. +1. Run `just release-prepare X.Y.Z`. It generates and prints release notes, requires explicit approval, then creates `release/vX.Y.Z`, synchronizes every release version and Cargo lock entry, updates `CHANGELOG.md`, validates, commits, pushes, and opens a PR. Prerelease notes start at the latest release tag; stable notes start at the latest stable tag so they include the full release cycle. 2. Review and squash-merge the release PR after CI passes. 3. Run `just release-publish X.Y.Z`. It resolves the PR's squash-merge commit, verifies the committed release state, creates an annotated tag on that exact commit, and pushes only `refs/tags/vX.Y.Z`. 4. The workflow verifies that the checkout and canonical remote tag resolve to the same main-reachable commit and that the tag is annotated. diff --git a/scripts/release/release.mjs b/scripts/release/release.mjs index dc7425a6c..5c8eac92a 100755 --- a/scripts/release/release.mjs +++ b/scripts/release/release.mjs @@ -409,7 +409,7 @@ function refExists(root, ref) { }); } -function fetchOriginMain(root) { +function fetchReleaseState(root) { run( "git", [ @@ -417,6 +417,7 @@ function fetchOriginMain(root) { "--no-tags", "origin", "refs/heads/main:refs/remotes/origin/main", + "refs/tags/v*:refs/tags/v*", ], { cwd: root }, ); @@ -462,9 +463,42 @@ function validateLocalTag(root, tag, expectedTarget) { } } -async function generateReviewedNotes(root) { - process.stderr.write("generating release notes...\n"); - const notes = run("just", ["release-notes"], { cwd: root }); +export function releaseNotesFrom(root, version, changelog) { + const args = ["describe", "--tags", "--abbrev=0", "--match", "v*"]; + const parsed = parseSemver(version); + if (parsed.prerelease.length > 0) { + return run("git", args, { cwd: root }); + } + args.push("--exclude", "v*-*"); + const previousStable = commandResult("git", args, { cwd: root }); + if (previousStable.status === 0) return previousStable.stdout.trim(); + + const firstPrerelease = changelogEntries(changelog) + .filter( + (entry) => + sameNumericVersion(entry.version, version) && + parseSemver(entry.version).prerelease.length > 0, + ) + .at(-1); + const from = + /^\*\*Full Changelog\*\*: https:\/\/github\.com\/\S+\/compare\/(.+?)\.\.\.\S+$/m.exec( + firstPrerelease?.body ?? "", + )?.[1]; + if ( + !from || + !succeeds("git", ["rev-parse", "--verify", `${from}^{commit}`], { + cwd: root, + }) + ) { + fail("no previous stable tag or first prerelease changelog baseline found"); + } + return from; +} + +async function generateReviewedNotes(root, version, changelog) { + const from = releaseNotesFrom(root, version, changelog); + process.stderr.write(`generating release notes from ${from}...\n`); + const notes = run("just", ["release-notes", from], { cwd: root }); if (!notes) fail("generated release notes are empty"); process.stdout.write(`\n${notes}\n\n`); const prompt = createInterface({ @@ -549,13 +583,13 @@ async function prepare(version, notesPath) { const branch = `release/v${version}`; const subject = `chore: release v${version}`; assertClean(root); + run("gh", ["auth", "status", "--hostname", "github.com"], { cwd: root }); + fetchReleaseState(root); const notes = notesPath ? (await readFile(resolve(notesPath), "utf8")).trim() - : await generateReviewedNotes(root); + : await generateReviewedNotes(root, version, await read("CHANGELOG.md")); if (!notes) fail("release notes file must contain reviewed Markdown"); if (notes.includes("\0")) fail("release notes file contains a NUL byte"); - run("gh", ["auth", "status", "--hostname", "github.com"], { cwd: root }); - fetchOriginMain(root); const remoteBranchOutput = commandResult( "git", @@ -744,7 +778,7 @@ async function publish(version) { const subject = `chore: release v${version}`; assertClean(root); run("gh", ["auth", "status", "--hostname", "github.com"], { cwd: root }); - fetchOriginMain(root); + fetchReleaseState(root); const prs = releasePrs(root, config.repository, branch); if (prs.length !== 1) fail(`expected exactly one PR for ${branch}`); diff --git a/scripts/release/tests/release-tooling.test.mjs b/scripts/release/tests/release-tooling.test.mjs index f176f07e9..a43304661 100644 --- a/scripts/release/tests/release-tooling.test.mjs +++ b/scripts/release/tests/release-tooling.test.mjs @@ -10,7 +10,7 @@ import { import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { updateChangelog } from "../release.mjs"; +import { releaseNotesFrom, updateChangelog } from "../release.mjs"; import { compareSemver, parseSemver } from "../version.mjs"; const sourceRepo = resolve(import.meta.dirname, "../../.."); @@ -130,8 +130,10 @@ printf '%s\n' '## generated changes' '' '- generated release notes' expect(git(["config", "user.email", "release@example.test"]).status).toBe(0); expect(git(["add", "."]).status).toBe(0); expect(git(["commit", "-m", "initial"]).status).toBe(0); + expect(git(["tag", "v0.5.0"]).status).toBe(0); expect(git(["remote", "add", "origin", remote]).status).toBe(0); expect(git(["push", "--set-upstream", "origin", "main"]).status).toBe(0); + expect(git(["push", "origin", "refs/tags/v0.5.0"]).status).toBe(0); const env = { BERD_RELEASE_DATE: "2026-08-12", BERD_RELEASE_REPO_ROOT: repo, @@ -159,6 +161,31 @@ describe("release SemVer and changelog", () => { expect(() => parseSemver("0.6.0+build.1")).toThrow(/build metadata/); }); + it("selects cumulative and incremental release note baselines", async () => { + const f = await fixture(); + const initial = f.git(["rev-parse", "v0.5.0^{commit}"]).stdout.trim(); + await writeFile(join(f.repo, "feature"), "release candidate change\n"); + expect(f.git(["add", "feature"]).status).toBe(0); + expect(f.git(["commit", "-m", "feat: test release candidate"]).status).toBe( + 0, + ); + expect(f.git(["tag", "v0.6.0-rc.1"]).status).toBe(0); + + expect(releaseNotesFrom(f.repo, "0.6.0-rc.2")).toBe("v0.6.0-rc.1"); + expect(releaseNotesFrom(f.repo, "0.6.0")).toBe("v0.5.0"); + + expect(f.git(["tag", "--delete", "v0.5.0"]).status).toBe(0); + const changelog = `# Changelog + +## [v0.6.0-rc.1](https://github.com/block/berd/releases/tag/v0.6.0-rc.1) - 2026-08-12 + +RC notes. + +**Full Changelog**: https://github.com/block/berd/compare/${initial}...HEAD +`; + expect(releaseNotesFrom(f.repo, "0.6.0", changelog)).toBe(initial); + }); + it("replaces only the top same-version RC during stable promotion", () => { const rc1 = updateChangelog( "# Changelog\n", @@ -190,9 +217,11 @@ describe("release SemVer and changelog", () => { describe("release preparation", () => { it("generates notes and requires explicit approval", async () => { const declined = await fixture(); + expect(declined.git(["tag", "--delete", "v0.5.0"]).status).toBe(0); const cancelled = declined.release(["prepare", "0.6.0-rc.1"], {}, "n\n"); expect(cancelled.status).not.toBe(0); expect(cancelled.stdout).toContain("generated release notes"); + expect(cancelled.stderr).toContain("release notes from v0.5.0"); expect(cancelled.stderr).toContain("release preparation cancelled"); expect(declined.git(["branch", "--show-current"]).stdout.trim()).toBe( "main", @@ -220,7 +249,7 @@ describe("release preparation", () => { expect(published.stderr).toContain( "0.5.0 is below minimum public version 0.6.0-rc.1", ); - expect(f.git(["tag", "--list"]).stdout.trim()).toBe(""); + expect(f.git(["tag", "--list"]).stdout.trim()).toBe("v0.5.0"); }); it("creates one lockstep release commit, pushes it, opens a PR, and resumes", async () => { @@ -235,7 +264,7 @@ describe("release preparation", () => { ); const checked = f.release(["version-check", "0.6.0-rc.1"]); expect(checked.status, checked.stderr).toBe(0); - expect(f.git(["tag", "--list"]).stdout.trim()).toBe(""); + expect(f.git(["tag", "--list"]).stdout.trim()).toBe("v0.5.0"); expect(await readFile(f.calls, "utf8")).toContain("pr create"); const head = f.git(["rev-parse", "HEAD"]).stdout.trim(); From 1e731f1c02a50924b78de6e39fac45c4e9beeab9 Mon Sep 17 00:00:00 2001 From: Kalvin Chau Date: Thu, 13 Aug 2026 12:30:58 -0700 Subject: [PATCH 2/4] fix(release): trust remote release note tags ignore local-only and remotely deleted tags when selecting release note baselines. add regression coverage for both cases. --- scripts/release/release.mjs | 39 ++++++++++++++++--- .../release/tests/release-tooling.test.mjs | 16 ++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/scripts/release/release.mjs b/scripts/release/release.mjs index 5c8eac92a..4db672583 100755 --- a/scripts/release/release.mjs +++ b/scripts/release/release.mjs @@ -423,6 +423,35 @@ function fetchReleaseState(root) { ); } +function remoteReleaseTags(root) { + const output = run( + "git", + ["ls-remote", "--tags", "--refs", "origin", "refs/tags/v*"], + { cwd: root }, + ); + return new Set( + output + .split("\n") + .filter(Boolean) + .map((line) => line.split("\t", 2)[1]?.replace("refs/tags/", "")) + .filter(Boolean), + ); +} + +function describeRemoteReleaseTag(root, stableOnly) { + const remoteTags = remoteReleaseTags(root); + const args = ["describe", "--tags", "--abbrev=0", "--match", "v*"]; + if (stableOnly) args.push("--exclude", "v*-*"); + + while (true) { + const result = commandResult("git", args, { cwd: root }); + if (result.status !== 0) return null; + const tag = result.stdout.trim(); + if (remoteTags.has(tag)) return tag; + args.push("--exclude", tag); + } +} + function remoteTagTarget(root, tag) { const result = commandResult( "git", @@ -464,14 +493,14 @@ function validateLocalTag(root, tag, expectedTarget) { } export function releaseNotesFrom(root, version, changelog) { - const args = ["describe", "--tags", "--abbrev=0", "--match", "v*"]; const parsed = parseSemver(version); if (parsed.prerelease.length > 0) { - return run("git", args, { cwd: root }); + const previousRelease = describeRemoteReleaseTag(root, false); + if (!previousRelease) fail("no previous release tag found"); + return previousRelease; } - args.push("--exclude", "v*-*"); - const previousStable = commandResult("git", args, { cwd: root }); - if (previousStable.status === 0) return previousStable.stdout.trim(); + const previousStable = describeRemoteReleaseTag(root, true); + if (previousStable) return previousStable; const firstPrerelease = changelogEntries(changelog) .filter( diff --git a/scripts/release/tests/release-tooling.test.mjs b/scripts/release/tests/release-tooling.test.mjs index a43304661..7ae3edde5 100644 --- a/scripts/release/tests/release-tooling.test.mjs +++ b/scripts/release/tests/release-tooling.test.mjs @@ -170,6 +170,7 @@ describe("release SemVer and changelog", () => { 0, ); expect(f.git(["tag", "v0.6.0-rc.1"]).status).toBe(0); + expect(f.git(["push", "origin", "refs/tags/v0.6.0-rc.1"]).status).toBe(0); expect(releaseNotesFrom(f.repo, "0.6.0-rc.2")).toBe("v0.6.0-rc.1"); expect(releaseNotesFrom(f.repo, "0.6.0")).toBe("v0.5.0"); @@ -186,6 +187,21 @@ RC notes. expect(releaseNotesFrom(f.repo, "0.6.0", changelog)).toBe(initial); }); + it("ignores local-only and remotely deleted release tags", async () => { + const f = await fixture(); + await writeFile(join(f.repo, "feature"), "unreleased change\n"); + expect(f.git(["add", "feature"]).status).toBe(0); + expect(f.git(["commit", "-m", "feat: unreleased change"]).status).toBe(0); + expect(f.git(["tag", "v0.5.1"]).status).toBe(0); + + expect(releaseNotesFrom(f.repo, "0.6.0-rc.1")).toBe("v0.5.0"); + expect(releaseNotesFrom(f.repo, "0.6.0")).toBe("v0.5.0"); + + expect(f.git(["push", "origin", "refs/tags/v0.5.1"]).status).toBe(0); + expect(f.git(["push", "origin", ":refs/tags/v0.5.1"]).status).toBe(0); + expect(releaseNotesFrom(f.repo, "0.6.0-rc.1")).toBe("v0.5.0"); + }); + it("replaces only the top same-version RC during stable promotion", () => { const rc1 = updateChangelog( "# Changelog\n", From c0483a69360f03cf1f39a3746fd00069cfad3006 Mon Sep 17 00:00:00 2001 From: Kalvin Chau Date: Thu, 13 Aug 2026 12:43:46 -0700 Subject: [PATCH 3/4] fix(release): refresh notes when main advances recheck the reviewed main cutoff after approval. fast-forward main, regenerate release notes, and require fresh approval when the cutoff changes. --- scripts/release/release.mjs | 35 ++++++++++- .../release/tests/release-tooling.test.mjs | 63 ++++++++++++++++++- 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/scripts/release/release.mjs b/scripts/release/release.mjs index 4db672583..f50ee07ea 100755 --- a/scripts/release/release.mjs +++ b/scripts/release/release.mjs @@ -545,6 +545,39 @@ async function generateReviewedNotes(root, version, changelog) { return notes; } +async function generateReviewedCurrentNotes(root, version, read) { + while (true) { + const reviewedMain = run("git", ["rev-parse", "refs/remotes/origin/main"], { + cwd: root, + }); + const notes = await generateReviewedNotes( + root, + version, + await read("CHANGELOG.md"), + ); + fetchReleaseState(root); + const currentMain = run("git", ["rev-parse", "refs/remotes/origin/main"], { + cwd: root, + }); + if (currentMain === reviewedMain) return notes; + + const currentBranch = run("git", ["branch", "--show-current"], { + cwd: root, + }); + const head = run("git", ["rev-parse", "HEAD"], { cwd: root }); + if (currentBranch !== "main" || head !== reviewedMain) { + fail("origin/main advanced; rerun release preparation from main"); + } + run("git", ["merge", "--ff-only", "refs/remotes/origin/main"], { + cwd: root, + visible: true, + }); + process.stderr.write( + "origin/main advanced while release notes were under review; regenerating...\n", + ); + } +} + function releasePrs(root, repository, branch) { const json = run( "gh", @@ -616,7 +649,7 @@ async function prepare(version, notesPath) { fetchReleaseState(root); const notes = notesPath ? (await readFile(resolve(notesPath), "utf8")).trim() - : await generateReviewedNotes(root, version, await read("CHANGELOG.md")); + : await generateReviewedCurrentNotes(root, version, read); if (!notes) fail("release notes file must contain reviewed Markdown"); if (notes.includes("\0")) fail("release notes file contains a NUL byte"); diff --git a/scripts/release/tests/release-tooling.test.mjs b/scripts/release/tests/release-tooling.test.mjs index 7ae3edde5..2413525d3 100644 --- a/scripts/release/tests/release-tooling.test.mjs +++ b/scripts/release/tests/release-tooling.test.mjs @@ -1,4 +1,4 @@ -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { chmod, mkdir, @@ -8,7 +8,7 @@ import { writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { releaseNotesFrom, updateChangelog } from "../release.mjs"; import { compareSemver, parseSemver } from "../version.mjs"; @@ -110,6 +110,16 @@ fi `#!/usr/bin/env bash set -euo pipefail [[ "$1" == "release-notes" ]] +if [[ -n "\${ADVANCE_ORIGIN_MAIN_ONCE:-}" && ! -e "$ADVANCE_ORIGIN_MAIN_ONCE" ]]; then + touch "$ADVANCE_ORIGIN_MAIN_ONCE" + advance_repo="$(mktemp -d)" + git clone --quiet "$(git remote get-url origin)" "$advance_repo" + git -C "$advance_repo" config user.name 'Release Test' + git -C "$advance_repo" config user.email 'release@example.test' + git -C "$advance_repo" commit --allow-empty -m 'feat: concurrent main change' >/dev/null + git -C "$advance_repo" push origin main >/dev/null 2>&1 + rm -rf "$advance_repo" +fi printf '%s\n' '## generated changes' '' '- generated release notes' `, ), @@ -147,7 +157,34 @@ printf '%s\n' '## generated changes' '' '- generated release notes' env: { ...env, ...extraEnv }, input, }); - return { repo, remote, notes, calls, git, release }; + const releaseInteractive = (args, extraEnv = {}, answers = []) => + new Promise((resolvePromise) => { + const child = spawn(process.execPath, [releaseScript, ...args], { + cwd: repo, + env: { ...process.env, ...env, ...extraEnv }, + }); + let stdout = ""; + let stderr = ""; + let answered = 0; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + const promptCount = + stdout.match(/use these release notes\?/g)?.length ?? 0; + while (answered < promptCount && answered < answers.length) { + child.stdin.write(`${answers[answered]}\n`); + answered += 1; + } + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("close", (status) => { + resolvePromise({ status, stdout, stderr }); + }); + }); + return { repo, remote, notes, calls, git, release, releaseInteractive }; } describe("release SemVer and changelog", () => { @@ -251,6 +288,26 @@ describe("release preparation", () => { ).toContain("generated release notes"); }); + it("regenerates notes when main advances while they are reviewed", async () => { + const f = await fixture(); + const marker = join(dirname(f.repo), "advanced-main"); + const prepared = await f.releaseInteractive( + ["prepare", "0.6.0-rc.1"], + { ADVANCE_ORIGIN_MAIN_ONCE: marker }, + ["y", "y"], + ); + + expect(prepared.status, `${prepared.stdout}\n${prepared.stderr}`).toBe(0); + expect(prepared.stderr).toContain( + "origin/main advanced while release notes were under review; regenerating", + ); + expect(prepared.stdout.match(/generated release notes/g)).toHaveLength(2); + expect(f.git(["branch", "--show-current"]).stdout.trim()).toBe( + "release/v0.6.0-rc.1", + ); + expect(await readFile(f.calls, "utf8")).toContain("pr create"); + }); + it("rejects versions below the configured public minimum", async () => { const f = await fixture(); const prepared = f.release(["prepare", "0.5.0", f.notes]); From 5544979518ce5dfaf50f2ab75e2a21064b355a48 Mon Sep 17 00:00:00 2001 From: Kalvin Chau Date: Thu, 13 Aug 2026 13:04:16 -0700 Subject: [PATCH 4/4] fix(release): stabilize release note snapshots fetch published tags into an isolated pruned namespace and regenerate approved notes when the selected baseline changes. preserve public tag names in comparison links. --- justfile | 4 +- scripts/generate-release-notes.sh | 3 +- scripts/release/release.mjs | 106 ++++++++++-------- .../release/tests/release-tooling.test.mjs | 83 +++++++++++++- 4 files changed, 142 insertions(+), 54 deletions(-) diff --git a/justfile b/justfile index 6f8dd3b54..54e3fc5de 100644 --- a/justfile +++ b/justfile @@ -600,8 +600,8 @@ bump-node-runtime *ARGS: # Draft release notes from commits without mutating GitHub. [unix] -release-notes from="" to="HEAD": - FROM_REF="{{ from }}" TO_REF="{{ to }}" ./scripts/generate-release-notes.sh +release-notes from="" to="HEAD" compare_from="": + FROM_REF="{{ from }}" TO_REF="{{ to }}" COMPARE_FROM="{{ compare_from }}" ./scripts/generate-release-notes.sh # ── Utilities ──────────────────────────────────────────────── diff --git a/scripts/generate-release-notes.sh b/scripts/generate-release-notes.sh index 110ad1625..588940c0a 100755 --- a/scripts/generate-release-notes.sh +++ b/scripts/generate-release-notes.sh @@ -27,6 +27,7 @@ fi FROM_REF="${1:-${FROM_REF:-}}" TO_REF="${2:-${TO_REF:-HEAD}}" +COMPARE_FROM="${COMPARE_FROM:-$FROM_REF}" if [[ -z "$FROM_REF" ]]; then FROM_REF="$(git -C "$REPO_ROOT" describe --tags --abbrev=0 --match 'v*' 2>/dev/null || true)" @@ -82,7 +83,7 @@ if [[ "$TO_REF" == "HEAD" ]]; then fi NOTES="${NOTES} -**Full Changelog**: https://github.com/${RELEASE_REPOSITORY}/compare/${FROM_REF}...${COMPARE_TO}" +**Full Changelog**: https://github.com/${RELEASE_REPOSITORY}/compare/${COMPARE_FROM}...${COMPARE_TO}" printf '\n%s\n' "$NOTES" echo "Draft only; review these notes before release preparation." >&2 diff --git a/scripts/release/release.mjs b/scripts/release/release.mjs index f50ee07ea..78c989213 100755 --- a/scripts/release/release.mjs +++ b/scripts/release/release.mjs @@ -414,42 +414,49 @@ function fetchReleaseState(root) { "git", [ "fetch", + "--prune", "--no-tags", "origin", "refs/heads/main:refs/remotes/origin/main", - "refs/tags/v*:refs/tags/v*", + "+refs/tags/v*:refs/remotes/berd-release-tags/v*", ], { cwd: root }, ); } -function remoteReleaseTags(root) { - const output = run( +function describeRemoteReleaseTag(root, stableOnly) { + const tags = run( "git", - ["ls-remote", "--tags", "--refs", "origin", "refs/tags/v*"], + [ + "for-each-ref", + "--format=%(refname:strip=3)", + "refs/remotes/berd-release-tags/v*", + ], { cwd: root }, + ) + .split("\n") + .filter((tag) => tag && (!stableOnly || !tag.includes("-"))); + if (tags.length === 0) return null; + + const args = [ + "describe", + "--all", + "--long", + "--abbrev=40", + "--match", + "berd-release-tags/v*", + ]; + if (stableOnly) args.push("--exclude", "berd-release-tags/v*-*"); + const result = commandResult("git", args, { cwd: root }); + if (result.status !== 0) return null; + const describedRef = result.stdout.trim().replace(/-\d+-g[0-9a-f]+$/, ""); + return ( + tags.find((tag) => + [`remotes/berd-release-tags/${tag}`, `tags/${tag}`].includes( + describedRef, + ), + ) ?? null ); - return new Set( - output - .split("\n") - .filter(Boolean) - .map((line) => line.split("\t", 2)[1]?.replace("refs/tags/", "")) - .filter(Boolean), - ); -} - -function describeRemoteReleaseTag(root, stableOnly) { - const remoteTags = remoteReleaseTags(root); - const args = ["describe", "--tags", "--abbrev=0", "--match", "v*"]; - if (stableOnly) args.push("--exclude", "v*-*"); - - while (true) { - const result = commandResult("git", args, { cwd: root }); - if (result.status !== 0) return null; - const tag = result.stdout.trim(); - if (remoteTags.has(tag)) return tag; - args.push("--exclude", tag); - } } function remoteTagTarget(root, tag) { @@ -524,10 +531,17 @@ export function releaseNotesFrom(root, version, changelog) { return from; } -async function generateReviewedNotes(root, version, changelog) { - const from = releaseNotesFrom(root, version, changelog); +function releaseNotesRef(from) { + return from.startsWith("v") ? `refs/remotes/berd-release-tags/${from}` : from; +} + +async function generateReviewedNotes(root, from) { process.stderr.write(`generating release notes from ${from}...\n`); - const notes = run("just", ["release-notes", from], { cwd: root }); + const notes = run( + "just", + ["release-notes", releaseNotesRef(from), "HEAD", from], + { cwd: root }, + ); if (!notes) fail("generated release notes are empty"); process.stdout.write(`\n${notes}\n\n`); const prompt = createInterface({ @@ -550,30 +564,32 @@ async function generateReviewedCurrentNotes(root, version, read) { const reviewedMain = run("git", ["rev-parse", "refs/remotes/origin/main"], { cwd: root, }); - const notes = await generateReviewedNotes( - root, - version, - await read("CHANGELOG.md"), - ); + const changelog = await read("CHANGELOG.md"); + const reviewedFrom = releaseNotesFrom(root, version, changelog); + const notes = await generateReviewedNotes(root, reviewedFrom); fetchReleaseState(root); const currentMain = run("git", ["rev-parse", "refs/remotes/origin/main"], { cwd: root, }); - if (currentMain === reviewedMain) return notes; + const currentFrom = releaseNotesFrom(root, version, changelog); + const mainChanged = currentMain !== reviewedMain; + if (!mainChanged && currentFrom === reviewedFrom) return notes; - const currentBranch = run("git", ["branch", "--show-current"], { - cwd: root, - }); - const head = run("git", ["rev-parse", "HEAD"], { cwd: root }); - if (currentBranch !== "main" || head !== reviewedMain) { - fail("origin/main advanced; rerun release preparation from main"); + if (mainChanged) { + const currentBranch = run("git", ["branch", "--show-current"], { + cwd: root, + }); + const head = run("git", ["rev-parse", "HEAD"], { cwd: root }); + if (currentBranch !== "main" || head !== reviewedMain) { + fail("origin/main advanced; rerun release preparation from main"); + } + run("git", ["merge", "--ff-only", "refs/remotes/origin/main"], { + cwd: root, + visible: true, + }); } - run("git", ["merge", "--ff-only", "refs/remotes/origin/main"], { - cwd: root, - visible: true, - }); process.stderr.write( - "origin/main advanced while release notes were under review; regenerating...\n", + "release state changed while release notes were under review; regenerating...\n", ); } } diff --git a/scripts/release/tests/release-tooling.test.mjs b/scripts/release/tests/release-tooling.test.mjs index 2413525d3..932407e40 100644 --- a/scripts/release/tests/release-tooling.test.mjs +++ b/scripts/release/tests/release-tooling.test.mjs @@ -113,13 +113,23 @@ set -euo pipefail if [[ -n "\${ADVANCE_ORIGIN_MAIN_ONCE:-}" && ! -e "$ADVANCE_ORIGIN_MAIN_ONCE" ]]; then touch "$ADVANCE_ORIGIN_MAIN_ONCE" advance_repo="$(mktemp -d)" - git clone --quiet "$(git remote get-url origin)" "$advance_repo" + git clone --quiet --branch main "$(git remote get-url origin)" "$advance_repo" git -C "$advance_repo" config user.name 'Release Test' git -C "$advance_repo" config user.email 'release@example.test' git -C "$advance_repo" commit --allow-empty -m 'feat: concurrent main change' >/dev/null git -C "$advance_repo" push origin main >/dev/null 2>&1 rm -rf "$advance_repo" fi +if [[ -n "\${PUBLISH_RELEASE_TAG_ONCE:-}" && ! -e "$PUBLISH_RELEASE_TAG_ONCE" ]]; then + touch "$PUBLISH_RELEASE_TAG_ONCE" + tag_repo="$(mktemp -d)" + git clone --quiet --branch main "$(git remote get-url origin)" "$tag_repo" + git -C "$tag_repo" config user.name 'Release Test' + git -C "$tag_repo" config user.email 'release@example.test' + git -C "$tag_repo" tag --annotate v0.6.0-rc.1 --message 'release v0.6.0-rc.1' + git -C "$tag_repo" push origin refs/tags/v0.6.0-rc.1 >/dev/null 2>&1 + rm -rf "$tag_repo" +fi printf '%s\n' '## generated changes' '' '- generated release notes' `, ), @@ -140,10 +150,21 @@ printf '%s\n' '## generated changes' '' '- generated release notes' expect(git(["config", "user.email", "release@example.test"]).status).toBe(0); expect(git(["add", "."]).status).toBe(0); expect(git(["commit", "-m", "initial"]).status).toBe(0); - expect(git(["tag", "v0.5.0"]).status).toBe(0); + expect( + git(["tag", "--annotate", "v0.5.0", "--message", "release v0.5.0"]).status, + ).toBe(0); expect(git(["remote", "add", "origin", remote]).status).toBe(0); expect(git(["push", "--set-upstream", "origin", "main"]).status).toBe(0); expect(git(["push", "origin", "refs/tags/v0.5.0"]).status).toBe(0); + const fetchReleaseTags = () => + git([ + "fetch", + "--prune", + "--no-tags", + "origin", + "+refs/tags/v*:refs/remotes/berd-release-tags/v*", + ]); + expect(fetchReleaseTags().status).toBe(0); const env = { BERD_RELEASE_DATE: "2026-08-12", BERD_RELEASE_REPO_ROOT: repo, @@ -184,7 +205,16 @@ printf '%s\n' '## generated changes' '' '- generated release notes' resolvePromise({ status, stdout, stderr }); }); }); - return { repo, remote, notes, calls, git, release, releaseInteractive }; + return { + repo, + remote, + notes, + calls, + git, + fetchReleaseTags, + release, + releaseInteractive, + }; } describe("release SemVer and changelog", () => { @@ -206,13 +236,23 @@ describe("release SemVer and changelog", () => { expect(f.git(["commit", "-m", "feat: test release candidate"]).status).toBe( 0, ); - expect(f.git(["tag", "v0.6.0-rc.1"]).status).toBe(0); + expect( + f.git([ + "tag", + "--annotate", + "v0.6.0-rc.1", + "--message", + "release v0.6.0-rc.1", + ]).status, + ).toBe(0); expect(f.git(["push", "origin", "refs/tags/v0.6.0-rc.1"]).status).toBe(0); + expect(f.fetchReleaseTags().status).toBe(0); expect(releaseNotesFrom(f.repo, "0.6.0-rc.2")).toBe("v0.6.0-rc.1"); expect(releaseNotesFrom(f.repo, "0.6.0")).toBe("v0.5.0"); - expect(f.git(["tag", "--delete", "v0.5.0"]).status).toBe(0); + expect(f.git(["push", "origin", ":refs/tags/v0.5.0"]).status).toBe(0); + expect(f.fetchReleaseTags().status).toBe(0); const changelog = `# Changelog ## [v0.6.0-rc.1](https://github.com/block/berd/releases/tag/v0.6.0-rc.1) - 2026-08-12 @@ -230,12 +270,17 @@ RC notes. expect(f.git(["add", "feature"]).status).toBe(0); expect(f.git(["commit", "-m", "feat: unreleased change"]).status).toBe(0); expect(f.git(["tag", "v0.5.1"]).status).toBe(0); + expect(f.git(["tag", "--force", "v0.5.0"]).status).toBe(0); + expect(f.fetchReleaseTags().status).toBe(0); expect(releaseNotesFrom(f.repo, "0.6.0-rc.1")).toBe("v0.5.0"); expect(releaseNotesFrom(f.repo, "0.6.0")).toBe("v0.5.0"); expect(f.git(["push", "origin", "refs/tags/v0.5.1"]).status).toBe(0); + expect(f.fetchReleaseTags().status).toBe(0); + expect(releaseNotesFrom(f.repo, "0.6.0-rc.1")).toBe("v0.5.1"); expect(f.git(["push", "origin", ":refs/tags/v0.5.1"]).status).toBe(0); + expect(f.fetchReleaseTags().status).toBe(0); expect(releaseNotesFrom(f.repo, "0.6.0-rc.1")).toBe("v0.5.0"); }); @@ -299,7 +344,7 @@ describe("release preparation", () => { expect(prepared.status, `${prepared.stdout}\n${prepared.stderr}`).toBe(0); expect(prepared.stderr).toContain( - "origin/main advanced while release notes were under review; regenerating", + "release state changed while release notes were under review; regenerating", ); expect(prepared.stdout.match(/generated release notes/g)).toHaveLength(2); expect(f.git(["branch", "--show-current"]).stdout.trim()).toBe( @@ -308,6 +353,32 @@ describe("release preparation", () => { expect(await readFile(f.calls, "utf8")).toContain("pr create"); }); + it("regenerates notes when their release tag baseline changes", async () => { + const f = await fixture(); + await writeFile(join(f.repo, "feature"), "release candidate change\n"); + expect(f.git(["add", "feature"]).status).toBe(0); + expect( + f.git(["commit", "-m", "feat: release candidate change"]).status, + ).toBe(0); + expect(f.git(["push", "origin", "main"]).status).toBe(0); + const marker = join(dirname(f.repo), "published-release-tag"); + const prepared = await f.releaseInteractive( + ["prepare", "0.6.0-rc.2"], + { PUBLISH_RELEASE_TAG_ONCE: marker }, + ["y", "y"], + ); + + expect(prepared.status, `${prepared.stdout}\n${prepared.stderr}`).toBe(0); + expect(prepared.stderr).toContain("generating release notes from v0.5.0"); + expect(prepared.stderr).toContain( + "generating release notes from v0.6.0-rc.1", + ); + expect(prepared.stdout.match(/generated release notes/g)).toHaveLength(2); + expect(f.git(["branch", "--show-current"]).stdout.trim()).toBe( + "release/v0.6.0-rc.2", + ); + }); + it("rejects versions below the configured public minimum", async () => { const f = await fixture(); const prepared = f.release(["prepare", "0.5.0", f.notes]);