From e4348298896392ff6b9a4427532846e80708513b Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 18 Aug 2026 14:51:58 -0700 Subject: [PATCH] fix(release): draft-notes baseline survives candidate-cut stables (#11647) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The release workflow drafts the upcoming stable's notes skeleton at beta publish, ranging from the last stable to the beta's source commit > - Stables cut from candidate branches leave their tag off master's lineage, so the ancestor-only baseline rule skips them and falls back a whole release too far > - The first live draft did exactly that: it ranged from v2026.722.0 instead of the just-shipped v2026.817.0 and re-included everything already released > - This pull request replaces the baseline rule with a merge-base walk over stable tags and adds a candidate-branch regression fixture > - The benefit is a correct draft range for every stable lineage: master-cut, candidate-cut, and promoted-older-source ## Linked Issues or Issue Description **What happened?** The first `draft_stable_notes` run (for beta `2026.818.0-beta.1`) generated `releases/beta/v2026.818.0-beta.1.md` with the range `v2026.722.0..664052f8e` — 311-commits-worth of already-shipped v2026.817.0 content re-included. **Expected behavior** The draft ranges from the point the shipped stable's content diverges from the beta source. For v2026.817.0 (cut from `candidate/release-2026.817.0`) that is the promoted source commit `8f7b8b3fd`, giving the 172 commits of genuinely-new work. **Steps to reproduce** Ship a stable from a candidate branch (tag lands off master's lineage), then publish a beta from master and read the generated skeleton's range line. **Paperclip version or commit** master at `664052f8e`. Related (not duplicates): #11567 introduced the generator; its ancestor-only rule was itself a review fix for the newest-by-version rule, and this PR is the second iteration with the lineage case the first fix missed. ## What Changed - `scripts/draft-stable-notes.sh`: the range start comes from walking stable tags newest-first and taking the first whose merge-base with the beta source is a proper ancestor of the source. Candidate-cut stables resolve to the promoted commit, master-lineage stables to the tag itself, and tags containing the source are skipped (an older-source promotion cannot produce an empty range). The skeleton header prints a runnable short-sha range with the stable tag as a labeled baseline. - `scripts/draft-stable-notes.test.mjs`: new regression fixture with the stable tag on an unmerged candidate branch; the existing older-source and fallback fixtures still pass unchanged. ## Verification - `node --test scripts/draft-stable-notes.test.mjs` — 8 pass, including the new fixture. - Regenerated the live `2026.818.0-beta.1` draft against the real repository: range start resolves to `v2026.817.0 (merge-base 8f7b8b3fd)`, 172 commits, shipped-in-stable subjects absent, post-beta subjects present. - `bash -n` on the script. ## Risks - Low risk: the script only produces a draft file; publishes are untouched. The live skeleton branch for `2026.818.0-beta.1` is regenerated with the corrected output (machine-owned branch, force-push by design). ## Model Used Claude Fable 5 (Claude Code) ## Pre-submission checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template --- scripts/draft-stable-notes.sh | 44 +++++++++++++++++++++++------ scripts/draft-stable-notes.test.mjs | 20 +++++++++++++ 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/scripts/draft-stable-notes.sh b/scripts/draft-stable-notes.sh index 1ba75033d7d..5bd42e9ba11 100755 --- a/scripts/draft-stable-notes.sh +++ b/scripts/draft-stable-notes.sh @@ -70,28 +70,54 @@ if ! git -C "$repo_dir" rev-parse --verify "refs/tags/${beta_tag}" >/dev/null 2> release_fail "beta tag ${beta_tag} does not exist. Draft notes are generated from a published beta." fi source_sha="$(git -C "$repo_dir" rev-parse "${beta_tag}^{commit}")" +source_short="$(git -C "$repo_dir" rev-parse --short "$source_sha")" if [ -z "$out_file" ]; then out_file="${repo_dir}/releases/beta/v${beta_version}.md" fi -# Range start: the newest stable tag reachable from the source commit — -# not the newest by version, which can sit on a divergent lineage (a -# stable cut from a candidate branch, or a source that predates it) and -# would produce an empty or wrong range. Before the first reachable -# stable, fall back to the nearest beta tag strictly before the source; -# with no marker at all, cover the source commit's full history. -range_start="$(git -C "$repo_dir" describe --tags --match 'v[0-9]*' --abbrev=0 "$source_sha" 2>/dev/null || true)" -range_label="$range_start" +# Range start: walk stable tags newest-first and take the first whose +# merge-base with the source is a proper ancestor of the source. A stable +# cut from a candidate branch is not itself an ancestor of master, but +# its merge-base with the source is the promoted source commit — exactly +# the point the shipped stable's content diverges from. A stable that +# already contains the source (promoting an older commit) is skipped so +# the range never collapses to empty. Before the first stable, fall back +# to the nearest beta tag strictly before the source; with no marker at +# all, cover the source commit's full history. +range_start="" +range_label="" +range_cmd_start="" +while IFS= read -r stable_tag; do + [ -n "$stable_tag" ] || continue + mb="$(git -C "$repo_dir" merge-base "$stable_tag" "$source_sha" 2>/dev/null || true)" + [ -n "$mb" ] || continue + if [ "$mb" != "$source_sha" ]; then + range_start="$mb" + if [ "$mb" = "$(git -C "$repo_dir" rev-parse "${stable_tag}^{commit}")" ]; then + range_label="$stable_tag" + range_cmd_start="$stable_tag" + else + # rev-parse --short picks an abbreviation that is unambiguous in + # this repository, unlike a fixed nine-character truncation. + mb_short="$(git -C "$repo_dir" rev-parse --short "$mb")" + range_label="${stable_tag} (merge-base ${mb_short})" + range_cmd_start="$mb_short" + fi + break + fi +done < <(git -C "$repo_dir" tag -l 'v[0-9]*' --sort=-v:refname) if [ -z "$range_start" ]; then range_start="$(git -C "$repo_dir" describe --tags --match 'beta/v*' --abbrev=0 "${source_sha}^" 2>/dev/null || true)" range_label="$range_start" + range_cmd_start="$range_start" fi if [ -n "$range_start" ]; then range="${range_start}..${source_sha}" else range="$source_sha" range_label="the beginning of history" + range_cmd_start="the beginning of history" release_info "No stable or prior beta tag found; drafting from full history." fi @@ -114,7 +140,7 @@ conventional='^(feat|fix)(\([^)]*\))?!?: ' mkdir -p "$(dirname "$out_file")" { printf '# Paperclip stable draft — from beta %s\n\n' "$beta_version" - printf '> Auto-generated at beta publish from `git log %s..%s`.\n' "${range_label}" "${source_sha:0:9}" + printf '> Auto-generated at beta publish from `git log %s..%s` (baseline: %s).\n' "${range_cmd_start}" "${source_short}" "${range_label}" printf '> Edit freely during the soak: rewrite for release-notes voice,\n' printf '> fold noise, and call out anything a self-hoster must act on.\n' printf '> The stable promotion reads this file from master and publishes\n' diff --git a/scripts/draft-stable-notes.test.mjs b/scripts/draft-stable-notes.test.mjs index c5bb01dfc0f..53479a87739 100644 --- a/scripts/draft-stable-notes.test.mjs +++ b/scripts/draft-stable-notes.test.mjs @@ -79,6 +79,26 @@ test("uses the nearest ancestor stable tag, not the newest by version", () => { assert.doesNotMatch(body, /new work/); }); +test("starts from the merge-base when the last stable was cut from a candidate branch", () => { + const dir = makeFixtureRepo(); + commit(dir, "feat: shipped in the stable (#1)"); + git(dir, "checkout", "-q", "-b", "candidate"); + commit(dir, "docs: release notes only (#2)"); + git(dir, "tag", "v2026.200.0"); + git(dir, "checkout", "-q", "master"); + commit(dir, "feat: next-release work (#3)"); + git(dir, "tag", "beta/v2026.201.0-beta.0"); + + // The stable tag sits on the unmerged candidate branch, so it is not an + // ancestor of the beta source. The range must start at its merge-base + // with the source (the promoted commit), not fall back past it. + const { body } = runDraft(dir, "2026.201.0-beta.0"); + + assert.match(body, /- feat: next-release work \(#3\)/); + assert.doesNotMatch(body, /shipped in the stable/); + assert.doesNotMatch(body, /release notes only/); +}); + test("falls back to the previous beta tag when no stable tag exists", () => { const dir = makeFixtureRepo(); commit(dir, "feat: first-train work (#1)");