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/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 dc7425a6c..78c989213 100755 --- a/scripts/release/release.mjs +++ b/scripts/release/release.mjs @@ -409,19 +409,56 @@ function refExists(root, ref) { }); } -function fetchOriginMain(root) { +function fetchReleaseState(root) { run( "git", [ "fetch", + "--prune", "--no-tags", "origin", "refs/heads/main:refs/remotes/origin/main", + "+refs/tags/v*:refs/remotes/berd-release-tags/v*", ], { cwd: root }, ); } +function describeRemoteReleaseTag(root, stableOnly) { + const tags = run( + "git", + [ + "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 + ); +} + function remoteTagTarget(root, tag) { const result = commandResult( "git", @@ -462,9 +499,49 @@ 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 parsed = parseSemver(version); + if (parsed.prerelease.length > 0) { + const previousRelease = describeRemoteReleaseTag(root, false); + if (!previousRelease) fail("no previous release tag found"); + return previousRelease; + } + const previousStable = describeRemoteReleaseTag(root, true); + if (previousStable) return previousStable; + + 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; +} + +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", releaseNotesRef(from), "HEAD", from], + { cwd: root }, + ); if (!notes) fail("generated release notes are empty"); process.stdout.write(`\n${notes}\n\n`); const prompt = createInterface({ @@ -482,6 +559,41 @@ async function generateReviewedNotes(root) { return notes; } +async function generateReviewedCurrentNotes(root, version, read) { + while (true) { + const reviewedMain = run("git", ["rev-parse", "refs/remotes/origin/main"], { + cwd: root, + }); + 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, + }); + const currentFrom = releaseNotesFrom(root, version, changelog); + const mainChanged = currentMain !== reviewedMain; + if (!mainChanged && currentFrom === reviewedFrom) return notes; + + 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, + }); + } + process.stderr.write( + "release state changed while release notes were under review; regenerating...\n", + ); + } +} + function releasePrs(root, repository, branch) { const json = run( "gh", @@ -549,13 +661,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 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"); - run("gh", ["auth", "status", "--hostname", "github.com"], { cwd: root }); - fetchOriginMain(root); const remoteBranchOutput = commandResult( "git", @@ -744,7 +856,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..932407e40 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,9 +8,9 @@ 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 { updateChangelog } from "../release.mjs"; +import { releaseNotesFrom, updateChangelog } from "../release.mjs"; import { compareSemver, parseSemver } from "../version.mjs"; const sourceRepo = resolve(import.meta.dirname, "../../.."); @@ -110,6 +110,26 @@ 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 --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' `, ), @@ -130,8 +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", "--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, @@ -145,7 +178,43 @@ 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, + fetchReleaseTags, + release, + releaseInteractive, + }; } describe("release SemVer and changelog", () => { @@ -159,6 +228,62 @@ 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", + "--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(["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 + +RC notes. + +**Full Changelog**: https://github.com/block/berd/compare/${initial}...HEAD +`; + 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(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"); + }); + it("replaces only the top same-version RC during stable promotion", () => { const rc1 = updateChangelog( "# Changelog\n", @@ -190,9 +315,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", @@ -206,6 +333,52 @@ 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( + "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( + "release/v0.6.0-rc.1", + ); + 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]); @@ -220,7 +393,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 +408,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();