From f08ad58f43379e9f3ce45ef718964f3af354b73b Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:28:45 -0500 Subject: [PATCH 1/3] fix(release): judge the release anchor against the commit its notes were built from A release PR carries release-please's next boundary (last-release-sha) in its branch. Both checks compared it with a commit that keeps moving, so one docs, test, or chore commit on main turned a correct release PR red, and no step of the pipeline could repair it. - anchor-check: last-release-sha must be the main commit release-please built the branch on, read from the branch's own first-parent chain. It was main's tip at check time. - boundary-check: last-release-sha must be the newest release merge or a commit of the cycle that merge closed. It was the merge or its parent. - The hook stamps the commit the branch is built on when that is the run's commit, wherever main's tip is by then. It no longer stands down when main moved, and it refuses a checkout with uncommitted changes to tracked files. - The anchor-check job checks out the release PR's head with its history: the merge ref at depth 1 does not hold the branch's chain. - Each refusal names its repair: for the release PR the anchor command with the commit filled in, for main the value to set by PR. A hand stamp at main's tip no longer passes anchor-check. With a changelog-visible commit that landed after the notes were built and was never refreshed for, that boundary drops the commit from every changelog. --- .github/scripts/release-pipeline/anchor.ts | 183 ++++++---- .github/workflows/checks.yml | 16 +- .github/workflows/update-release-pr.yml | 2 +- test/scripts/release-pipeline.test.ts | 402 +++++++++++++++++++-- 4 files changed, 506 insertions(+), 97 deletions(-) diff --git a/.github/scripts/release-pipeline/anchor.ts b/.github/scripts/release-pipeline/anchor.ts index 9ca3bbb5..37f71c00 100644 --- a/.github/scripts/release-pipeline/anchor.ts +++ b/.github/scripts/release-pipeline/anchor.ts @@ -1,7 +1,7 @@ /** * release-please's boundary: last-release-sha in release-please-config.json, anchored inside the release PR branch - * so the squash merge lands it on main, checked fresh on main and present on the release PR. The config is read - * once here and refused when malformed, never repaired. + * so the squash merge lands it on main. The release PR is held to the commit its notes were built from, main to the + * cycle its newest release merge closed. The config is read once here and refused when malformed, never repaired. */ import { readFileSync, writeFileSync } from "node:fs"; @@ -29,6 +29,12 @@ export const RELEASE_PR_BRANCH_PREFIX = "release-please--"; /** release-please's branch for main's release PR. */ const RELEASE_PR_BRANCH = `${RELEASE_PR_BRANCH_PREFIX}branches--main`; +/** Main as a full-history checkout of the release PR's head holds it. */ +const MAIN = "origin/main"; + +/** The repair of a release PR branch whose base cannot be read: release-please builds a new one once this one is gone. */ +const REBUILD = `Delete the branch, and the next green push to main has release-please rebuild the release PR and the hook anchor it:\n git push origin --delete ${RELEASE_PR_BRANCH}`; + /** release-please's config with the one key this pipeline reads and writes; the rest is release-please's. */ type ReleaseConfig = Record & { "last-release-sha"?: string }; @@ -91,7 +97,7 @@ export interface AnchorOptions { /** The checkout the release PR branch is fetched into and the anchor commit made in. */ cwd: string; - /** Main's head this run tested; the merge parent the boundary records. */ + /** The main commit this run judged; the branch is anchored only when release-please built it on this commit. */ sourceSha: string; /** How many pushes a release-please refresh may overtake before the run gives up; 3 by default. */ @@ -103,9 +109,51 @@ export interface AnchorResult { reason: string; } -/** The anchor commit on the checked-out branch: last-release-sha set to `sourceSha`, committed under the bot identity. */ -function commitAnchor(cwd: string, config: ReleaseConfig, sourceSha: string): void { - config["last-release-sha"] = sourceSha; +/** The newest release commit `git log` lists for `revs` (HEAD when none), or "" for none. The grep only narrows (it + * matches any message line with the prefix); RELEASE_SUBJECT decides, so "chore(main): release pipeline documentation" + * can neither become the boundary nor hide the real newest release commit. */ +function newestRelease(cwd: string, ...revs: string[]): string { + const listed = git(cwd, "log", "--grep", "^chore(main): release ", "--format=%H%x09%s", ...revs); + for (const line of listed.split("\n")) { + const [sha, subject] = line.split("\t"); + if (sha !== undefined && subject !== undefined && RELEASE_SUBJECT.test(subject)) { + return sha; + } + } + return ""; +} + +/** The commit a release PR branch is built on: the first parent of the newest release commit on the branch's + * first-parent chain above `main`. The bound matters: a walk into main's own history would read main's last release + * merge on a branch that holds no release commit. */ +function releaseBase(cwd: string, main: string, branch: string): string { + const release = newestRelease(cwd, "--first-parent", `${main}..${branch}`); + if (release === "") { + throw new Error( + `the release PR branch holds no release commit ("chore(main): release X.Y.Z") on its first-parent chain above ${main}, ` + + "so the commit its notes were built from cannot be read: main was merged in with the branch as the second parent, " + + `or release-please did not build this branch.\n${REBUILD}`, + ); + } + return git(cwd, "rev-parse", `${release}^`); +} + +/** The anchor is also a command an operator pastes into their own clone, where the forced checkout would discard work. */ +function refuseUncommittedChanges(cwd: string): void { + const dirty = git(cwd, "status", "--porcelain", "--untracked-files=no") + .split("\n") + .filter(Boolean); + if (dirty.length > 0) { + throw new Error( + `this checkout has uncommitted changes to tracked files (${dirty.join("; ")}), and anchoring checks the release PR branch out over them; ` + + "commit them, or run it from a clean checkout.", + ); + } +} + +/** The anchor commit on the checked-out branch: last-release-sha set to `base`, committed under the bot identity. */ +function commitAnchor(cwd: string, config: ReleaseConfig, base: string): void { + config["last-release-sha"] = base; writeFileSync(join(cwd, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`); git(cwd, "add", CONFIG_FILE); gitWithEnv( @@ -115,41 +163,39 @@ function commitAnchor(cwd: string, config: ReleaseConfig, sourceSha: string): vo "-m", "chore: anchor release-please to this release cycle's base", "-m", - "last-release-sha records the merge parent so the squash merge itself lands the next cycle's boundary on main: version tags live on packaged commits that are not on main, so release-please cannot find the boundary by tag.", + "last-release-sha records the main commit this release's notes were built from, so the squash merge itself lands the next cycle's boundary on main: " + + "release-please lists the commits after it, and cannot find the boundary by tag, because version tags live on packaged commits that are not on main.", ); } -/** One attempt: the release PR branch fetched and checked out detached, anchored, and pushed. Undefined when a - * release-please refresh overtook the push, so the caller re-reads the branch; a push refused with the branch - * unmoved is thrown. */ +/** One attempt: the release PR branch fetched and, when it is built on `sourceSha`, checked out detached, anchored, + * and pushed. Undefined when a release-please refresh overtook the push, so the caller re-reads the branch; a push + * refused with the branch unmoved is thrown. */ function anchorAttempt(cwd: string, sourceSha: string): AnchorResult | undefined { const branchRef = `refs/heads/${RELEASE_PR_BRANCH}`; const observed = git(cwd, "ls-remote", "origin", branchRef).split("\t")[0] ?? ""; if (observed === "") { return { changed: false, reason: "no release PR branch to anchor" }; } - // Detached, never a local branch: a retry after an overtaken push re-fetches, and git refuses to fetch into a checked-out ref. git(cwd, "fetch", "--quiet", "--force", "origin", branchRef); - git(cwd, "checkout", "--quiet", "--force", "--detach", "FETCH_HEAD"); - // A branch still built from an older main must not be stamped with this head. A shallow history that cannot - // prove ancestry no-ops the same way; the refresh that follows anchors. - if (!isAncestor(cwd, sourceSha, "FETCH_HEAD")) { + + const base = releaseBase(cwd, sourceSha, "FETCH_HEAD"); + if (base !== sourceSha) { return { changed: false, - reason: "the release PR branch is not built on this head; its own refresh anchors", + reason: `the release PR branch is built on ${base}, not on ${sourceSha}; the anchor is the commit the branch is built on, so nothing is stamped`, }; } + + refuseUncommittedChanges(cwd); + // Detached, never a local branch: a retry after an overtaken push re-fetches, and git refuses to fetch into a checked-out ref. + git(cwd, "checkout", "--quiet", "--force", "--detach", "FETCH_HEAD"); const config = readReleaseConfig(cwd); if (config["last-release-sha"] === sourceSha) { return { changed: false, reason: "already anchored" }; } + commitAnchor(cwd, config, sourceSha); - // A newer push's run may already have refreshed and anchored the branch before the fetch above: this run's commit - // then descends from that anchor, pushes cleanly, and regresses the boundary to the older head. - const headNow = git(cwd, "ls-remote", "origin", "refs/heads/main").split("\t")[0]; - if (headNow !== sourceSha) { - return { changed: false, reason: `main moved to ${headNow ?? "?"}; the newer run anchors` }; - } const refused = push(cwd, "origin", `HEAD:${branchRef}`); if (refused === null) { return { changed: true, reason: `${RELEASE_PR_BRANCH}: anchored at ${sourceSha}` }; @@ -160,17 +206,16 @@ function anchorAttempt(cwd: string, sourceSha: string): AnchorResult | undefined return undefined; } -/** The boundary rides INSIDE the release PR as last-release-sha = main's current head, the future merge commit's - * PARENT (known now, unlike the merge sha), so the squash merge lands it on main with no push to main and no admin credential. - * the parent, not the merge -> the changelog walk then also includes the merge itself, a chore commit it hides anyway - * a stale parent -> ruled out by the managed release-freshness gate: a PR must contain main's tip to merge */ +/** The boundary rides INSIDE the release PR as last-release-sha = the main commit release-please built the branch + * on, so the squash merge lands it on main with no push to main and no admin credential. release-please lists the + * commits after it, so the next cycle starts where this release's notes end, whatever lands on main before the merge. + * + * Stamped only when that commit is `sourceSha`: release-please reads main's history for the notes, then main's head + * for the branch, and the platform proposes only while main stands at the run's commit. + * built on the run's commit -> nothing landed between the two reads, so the notes end at it + * built on another commit -> no stamp: this run cannot tell where the notes end */ export function anchorReleasePr(options: AnchorOptions): AnchorResult { const { cwd, sourceSha, attempts = 3 } = options; - // If main moved, the newer push's run refreshes the branch and anchors the newer head. - const head = git(cwd, "ls-remote", "origin", "refs/heads/main").split("\t")[0]; - if (head !== sourceSha) { - return { changed: false, reason: `main moved to ${head ?? "?"}; the newer run anchors` }; - } for (let attempt = 1; attempt <= attempts; attempt++) { const result = anchorAttempt(cwd, sourceSha); if (result !== undefined) { @@ -186,20 +231,6 @@ export function anchorReleasePr(options: AnchorOptions): AnchorResult { ); } -/** The newest release merge reachable from HEAD, or "" for none. The grep only narrows (it matches any message line - * with the prefix); RELEASE_SUBJECT decides, so "chore(main): release pipeline documentation" can neither become the - * boundary nor hide the real newest merge. */ -function newestReleaseMerge(cwd: string): string { - const listed = git(cwd, "log", "--grep", "^chore(main): release ", "--format=%H%x09%s"); - for (const line of listed.split("\n")) { - const [sha, subject] = line.split("\t"); - if (sha !== undefined && subject !== undefined && RELEASE_SUBJECT.test(subject)) { - return sha; - } - } - return ""; -} - /** The verdict with no release merge on the history: the pre-first-release state when nothing is recorded, else a * refusal saying why the recorded boundary has no merge. */ function noMergeVerdict(cwd: string, recorded: string | undefined): { boundary: string } { @@ -214,9 +245,21 @@ function noMergeVerdict(cwd: string, recorded: string | undefined): { boundary: ); } -/** On main, last-release-sha must be the newest release merge or its parent; anything else means an anchor was lost - * or a release merge slipped past the pipeline, and every release PR refresh would compute from a stale boundary. - * A boundary NEWER than every recognized merge is a missed merge or a hand edit; either way it is never rolled back. */ +/** Whether `sha` is a commit of the cycle a release merge closed: on main at or before `parent`, the merge's parent, + * and after the release merge before it. The first release merge on a history has none before it, so its cycle + * reaches back to the root. */ +function inCycle(cwd: string, sha: string, parent: string): boolean { + if (!isAncestor(cwd, sha, parent)) { + return false; + } + const previous = newestRelease(cwd, parent); + return previous === "" || (sha !== previous && isAncestor(cwd, previous, sha)); +} + +/** On main, last-release-sha must be the newest release merge or a commit of the cycle that merge closed. Which + * commit of the cycle is the release PR's anchor-check to judge: a squash merge no longer shows it. + * outside the cycle -> an anchor was lost or a release merge slipped past the pipeline: refused + * NEWER than every recognized merge -> a missed merge or a hand edit: refused, and never rolled back */ export function boundaryCheck(cwd: string): { boundary: string } { assertFullHistory( cwd, @@ -224,13 +267,13 @@ export function boundaryCheck(cwd: string): { boundary: string } { "a release merge or the recorded boundary can sit beyond its depth, and no verdict on a truncated history holds.", ); const recorded = readReleaseConfig(cwd)["last-release-sha"]; - const latest = newestReleaseMerge(cwd); + const latest = newestRelease(cwd); if (latest === "") { return noMergeVerdict(cwd, recorded); } const parent = tryGit(cwd, "rev-parse", `${latest}^`); if (recorded !== undefined) { - if (recorded === latest || (parent !== null && recorded === parent)) { + if (recorded === latest || (parent !== null && inCycle(cwd, recorded, parent))) { return { boundary: recorded }; } if (isAncestor(cwd, latest, recorded) && isAncestor(cwd, recorded, "HEAD")) { @@ -243,27 +286,37 @@ export function boundaryCheck(cwd: string): { boundary: string } { } } throw new Error( - `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, but the newest release ` + - `merge on main is ${latest} (parent ${parent}); release PR refreshes would be computed ` + - `from a stale boundary. Fix by PR: set last-release-sha to ${parent ?? latest}.`, + `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, which is not in the cycle the newest release merge on main closed ` + + `(${latest}, parent ${parent}): an anchor was lost or the value was edited, and release PR refreshes would be computed from a stale boundary. ` + + `Fix by PR: set last-release-sha to ${parent ?? latest}.`, ); } -/** Run on the release PR's own checkout. The managed release-freshness gate proves the PR contains main's tip, not - * that the anchor commit survived a release-please force-push, so requiring last-release-sha to equal origin's - * CURRENT main tip makes an unanchored release PR unmergeable instead of parking the pipeline after its merge. */ +/** Run on the release PR's head with its history. last-release-sha must be the main commit release-please built the + * branch on, whatever landed on main since and however often main was merged into the branch, so an unanchored or + * wrongly anchored release PR is unmergeable instead of parking the pipeline after its merge. */ export function anchorCheck(cwd: string): { boundary: string } { + assertFullHistory( + cwd, + "anchor-check", + "the commit release-please built the branch on is read from the branch's own history.", + ); const recorded = readReleaseConfig(cwd)["last-release-sha"]; - const tip = git(cwd, "ls-remote", "origin", "refs/heads/main").split("\t")[0] ?? ""; - if (recorded !== tip) { + const base = releaseBase(cwd, MAIN, "HEAD"); + if (!isAncestor(cwd, base, MAIN)) { + throw new Error( + `release-please's commit on this release PR sits on ${base}, which is not on main: the branch was rebased or a commit was ` + + `stacked under it, so the commit its notes were built from cannot be read.\n${REBUILD}`, + ); + } + if (recorded !== base) { throw new Error( - `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, but main's tip is ` + - `${tip}; the anchor is missing or stale, so merging would land a wrong boundary. ` + - `The release pipeline's update-release-pr hook re-applies it on every release-PR refresh ` + - `- wait for (or dispatch) the next green main run, then close/reopen the PR so its ` + - `checks run on the anchored head (the anchor is pushed with the default token, which ` + - `triggers no new checks).`, + `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, but release-please built this release PR on ${base}, ` + + "the commit its notes were built from; the anchor is missing or names another commit, so merging would land a wrong boundary.\n" + + "Repair it with the command below, from a clean checkout: it leaves HEAD detached on the release PR branch, and its push re-runs the checks. " + + 'When it answers "already anchored", the branch was anchored after this run began: close and reopen the PR to run the checks on its head.\n' + + ` GITHUB_SHA=${base} bun .github/scripts/release-pipeline.ts anchor`, ); } - return { boundary: recorded }; + return { boundary: base }; } diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 2022bdf6..fd73ea03 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -35,8 +35,8 @@ jobs: # anchor would have every release PR refresh computed from a stale boundary, and gating here parks the release job # before release-please can build a garbage release PR. Each half is its own job, gated on its event: a skipped job # leaves the checks caller, and so all-green, green. - # on main -> boundary-check: last-release-sha is the newest release merge or its parent - # on a release PR -> anchor-check: the PR carries the anchored NEXT boundary, which the main-side rule would reject + # on main -> boundary-check: last-release-sha is the newest release merge, or a commit of the cycle it closed + # on a release PR -> anchor-check: last-release-sha is the main commit release-please built the PR's branch on boundary-check: if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest @@ -49,20 +49,26 @@ jobs: - uses: ./.github/actions/setup with: install: "false" - - name: Confirm last-release-sha matches the newest release merge + - name: Confirm last-release-sha sits in the cycle the newest release merge closed run: bun .github/scripts/release-pipeline.ts boundary-check - # An unanchored release PR must be unmergeable here, not park the pipeline after its merge. + # An unanchored release PR must be unmergeable here, not park the pipeline after its merge. The checkout is the + # PR's head, so this job runs the BRANCH's copy of the script, not main's. anchor-check: if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'release-please--') runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The commit the branch is built on is read from the branch's own chain, which the default checkout (the + # merge ref, at depth 1) does not hold. + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} - uses: ./.github/actions/setup with: install: "false" - - name: Require the release PR to carry this cycle's anchor + - name: Require the release PR to be anchored at the commit its notes were built from run: bun .github/scripts/release-pipeline.ts anchor-check # The packed tarball is imported from a fresh consumer project under Node and under tsc with skipLibCheck off, so a diff --git a/.github/workflows/update-release-pr.yml b/.github/workflows/update-release-pr.yml index 599fc233..20251e83 100644 --- a/.github/workflows/update-release-pr.yml +++ b/.github/workflows/update-release-pr.yml @@ -12,7 +12,7 @@ # build, ci, refactor) refreshes nothing and publishes nothing; no check of this repository's own decides a publish. # # The anchor is pushed with the default github.token, which starts no workflows, so the PR's checks do not re-run -# on the anchored head; anchor-check's failure message says to close and reopen the PR. +# on the anchored head by themselves; anchor-check's failure message says how to start them. name: Update Release PR diff --git a/test/scripts/release-pipeline.test.ts b/test/scripts/release-pipeline.test.ts index 4ad437ab..611df2b7 100644 --- a/test/scripts/release-pipeline.test.ts +++ b/test/scripts/release-pipeline.test.ts @@ -50,6 +50,7 @@ import { rivalPackage, seedFixture, shallowClone, + subcommand, withPushPlans, write, writeBuild, @@ -703,10 +704,111 @@ function releasePrConfig(fx: Fixture, name: string): { boundary: string; manifes }; } +/** release-please's branch for main's release PR. */ +const RELEASE_BRANCH = "release-please--branches--main"; + +/** The boundary the seed fixture's config records: no commit. */ +const PLACEHOLDER = "0".repeat(40); + +/** What every refusal of an unreadable release PR branch ends with: the repair, its command on a line of its own. */ +const REBUILD = + "Delete the branch, and the next green push to main has release-please rebuild the release PR and the hook anchor it:\n" + + " git push origin --delete release-please--branches--main"; + +/** The fixture's release-please config recording `boundary`, or no boundary. */ +function configRecording(boundary?: string): string { + const recorded = boundary === undefined ? {} : { "last-release-sha": boundary }; + const packages = { ".": { "release-type": "simple", draft: true } }; + return `${JSON.stringify({ ...recorded, packages }, null, 2)}\n`; +} + +/** A commit of `subject` on origin's main, as a merged PR lands one. */ +function landOnMain(fx: Fixture, name: string, subject: string): string { + const dir = clone(fx.root, fx.origin, name); + write(dir, `docs/${name}.md`, `${subject}\n`); + const sha = commitAll(dir, subject); + git(dir, "push", "--quiet", "origin", "HEAD:refs/heads/main"); + return sha; +} + +/** GitHub's "Update branch": origin's main merged into the release PR branch as the second parent. */ +function updateReleasePrBranch(fx: Fixture, name: string): void { + const dir = clone(fx.root, fx.origin, name); + git(dir, "checkout", "--quiet", RELEASE_BRANCH); + const subject = `Merge branch 'main' into ${RELEASE_BRANCH}`; + git(dir, "merge", "--quiet", "--no-ff", "-m", subject, "origin/main"); + git(dir, "push", "--quiet", "origin", `HEAD:refs/heads/${RELEASE_BRANCH}`); +} + +/** A commit on the release PR branch that sets last-release-sha to `boundary`: an anchor made by hand. */ +function anchorByHand(fx: Fixture, name: string, boundary: string): void { + const dir = clone(fx.root, fx.origin, name); + git(dir, "checkout", "--quiet", RELEASE_BRANCH); + write(dir, "release-please-config.json", configRecording(boundary)); + commitAll(dir, "chore: anchor by hand"); + git(dir, "push", "--quiet", "origin", `HEAD:refs/heads/${RELEASE_BRANCH}`); +} + +/** The release PR's head with its full history, detached: the checkout the anchor-check job makes. */ +function releasePrHead(fx: Fixture, name: string): string { + const dir = clone(fx.root, fx.origin, name); + git(dir, "checkout", "--quiet", "--detach", `origin/${RELEASE_BRANCH}`); + return dir; +} + +/** The release PR squash-merged onto origin's main, as GitHub lands it; a checkout of main at the merge. */ +function squashMergeReleasePr(fx: Fixture, name: string, version: string): string { + const dir = clone(fx.root, fx.origin, name); + git(dir, "merge", "--quiet", "--squash", `origin/${RELEASE_BRANCH}`); + git(dir, "commit", "--quiet", "-m", `chore(main): release ${version} (#60)`); + git(dir, "push", "--quiet", "origin", "HEAD:refs/heads/main"); + return dir; +} + +interface AnchoredPr { + /** A changelog-visible commit of the open cycle, older than `base`. */ + early: string; + + /** The main commit release-please built the branch on. */ + base: string; +} + +/** A release PR as a green run leaves one: two changelog-visible commits past the 2.1.0 merge, release-please's + * branch built on the second, and the hook's anchor on it. */ +function anchoredReleasePr(fx: Fixture): AnchoredPr { + const early = pushGreenCommit(fx, "early", "bundle-early\n").sha; + const base = pushGreenCommit(fx, "notes-base", "bundle-base\n").sha; + createReleasePrBranch(fx, base, "2.2.0"); + const hook = clone(fx.root, fx.origin, "hook"); + expect(anchorReleasePr({ cwd: hook, sourceSha: base }).changed).toBe(true); + return { early, base }; +} + +/** anchor-check's refusal of a release PR that records `recorded` on a branch built on `base`. */ +function notAnchored(recorded: string, base: string): Error { + return new Error( + `last-release-sha in release-please-config.json is ${JSON.stringify(recorded)}, but release-please built this release PR on ${base}, ` + + "the commit its notes were built from; the anchor is missing or names another commit, so merging would land a wrong boundary.\n" + + "Repair it with the command below, from a clean checkout: it leaves HEAD detached on the release PR branch, and its push re-runs the checks. " + + 'When it answers "already anchored", the branch was anchored after this run began: close and reopen the PR to run the checks on its head.\n' + + ` GITHUB_SHA=${base} bun .github/scripts/release-pipeline.ts anchor`, + ); +} + +/** The refusal of a release PR branch with no release commit on its first-parent chain above `main`. */ +function noReleaseCommit(main: string): Error { + return new Error( + `the release PR branch holds no release commit ("chore(main): release X.Y.Z") on its first-parent chain above ${main}, ` + + "so the commit its notes were built from cannot be read: main was merged in with the branch as the second parent, " + + `or release-please did not build this branch.\n${REBUILD}`, + ); +} + describe("anchorReleasePr", () => { test("the boundary lands inside the release PR branch and merges onto main", () => { const fx = seedFixture(); - const mainHead = fx.mergeSha; + // The platform's release job proposes on every green push but a release merge's own, so no release PR is built on the merge itself. + const mainHead = pushGreenCommit(fx, "notes-base", "bundle-base\n").sha; createReleasePrBranch(fx, mainHead, "2.2.0"); const worker = clone(fx.root, fx.origin, "anchor-worker"); const result = anchorReleasePr({ cwd: worker, sourceSha: mainHead }); @@ -739,28 +841,88 @@ describe("anchorReleasePr", () => { expect(result).toMatchObject({ changed: false, reason: "no release PR branch to anchor" }); }); - test("a moved main makes the anchor defer to the newer run", () => { + test("a hidden-type commit landing on main between the refresh and the hook job leaves the hook anchoring the commit the branch is built on", () => { const fx = seedFixture(); createReleasePrBranch(fx, fx.mergeSha, "2.2.0"); - const mover = clone(fx.root, fx.origin, "anchor-mover"); - write(mover, "src/marker.ts", "export const marker = 9;\n"); - commitAll(mover, "fix: land after the anchor run started"); - git(mover, "push", "--quiet", "origin", "HEAD:refs/heads/main"); - const worker = clone(fx.root, fx.origin, "anchor-late"); - const result = anchorReleasePr({ cwd: worker, sourceSha: fx.mergeSha }); - expect(result.changed).toBe(false); - expect(result.reason).toContain("the newer run anchors"); + // Depth 1 at the run's commit, as the hook job checks out, before main moves on. + const worker = shallowClone(fx, "anchor-late"); + landOnMain(fx, "hidden", "docs: land after the refresh, before the hook job"); + expect(anchorReleasePr({ cwd: worker, sourceSha: fx.mergeSha })).toEqual({ + changed: true, + reason: `release-please--branches--main: anchored at ${fx.mergeSha}`, + }); + expect(releasePrConfig(fx, "anchor-late-check").boundary).toBe(fx.mergeSha); }); - test("a branch built on an older head is left for its own refresh to anchor", () => { + test.each<[string, (fx: Fixture) => { run: string; builtOn: string }]>([ + [ + "an older commit than this run's", + (fx) => { + createReleasePrBranch(fx, fx.seedSha, "2.2.0"); + return { run: fx.mergeSha, builtOn: fx.seedSha }; + }, + ], + [ + "a newer commit than this run's, by a newer run's refresh", + (fx) => { + createReleasePrBranch(fx, fx.mergeSha, "2.2.0"); + const newer = pushGreenCommit(fx, "newer", "bundle-newer\n").sha; + createReleasePrBranch(fx, newer, "2.2.0"); + return { run: fx.mergeSha, builtOn: newer }; + }, + ], + [ + "an older commit, with this run's commit merged in by Update branch", + (fx) => { + createReleasePrBranch(fx, fx.mergeSha, "2.2.0"); + const tip = landOnMain(fx, "hidden", "docs: land after the refresh"); + updateReleasePrBranch(fx, "update-branch"); + return { run: tip, builtOn: fx.mergeSha }; + }, + ], + ])("a release PR branch built on %s is left unstamped", (_name, build) => { const fx = seedFixture(); - // The branch was refreshed from the SEED while main moved on to the 2.1.0 merge; anchoring mergeSha would record a boundary its content was not - // computed from. - createReleasePrBranch(fx, fx.seedSha, "2.2.0"); - const worker = clone(fx.root, fx.origin, "anchor-stale-branch"); - const result = anchorReleasePr({ cwd: worker, sourceSha: fx.mergeSha }); - expect(result.changed).toBe(false); - expect(result.reason).toContain("not built on this head"); + const { run, builtOn } = build(fx); + const before = git(fx.origin, "rev-parse", `refs/heads/${RELEASE_BRANCH}`); + const worker = clone(fx.root, fx.origin, "anchor-other-base"); + expect(anchorReleasePr({ cwd: worker, sourceSha: run })).toEqual({ + changed: false, + reason: `the release PR branch is built on ${builtOn}, not on ${run}; the anchor is the commit the branch is built on, so nothing is stamped`, + }); + expect(git(fx.origin, "rev-parse", `refs/heads/${RELEASE_BRANCH}`)).toBe(before); + }); + + test("a branch with no release commit above the run's commit is refused naming the shape", () => { + const fx = seedFixture(); + const dir = clone(fx.root, fx.origin, "not-release-please"); + write(dir, "src/marker.ts", "export const marker = 5;\n"); + commitAll(dir, "chore: not a release commit"); + git(dir, "push", "--quiet", "origin", `HEAD:refs/heads/${RELEASE_BRANCH}`); + const worker = clone(fx.root, fx.origin, "anchor-no-release"); + expect(() => anchorReleasePr({ cwd: worker, sourceSha: fx.mergeSha })).toThrow( + noReleaseCommit(fx.mergeSha), + ); + }); + + test("a checkout with uncommitted changes to tracked files is refused before the branch is checked out over them", () => { + const fx = seedFixture(); + createReleasePrBranch(fx, fx.mergeSha, "2.2.0"); + const work = "export const marker = 'uncommitted work';\n"; + const dirty = clone(fx.root, fx.origin, "operator-dirty"); + write(dirty, "src/marker.ts", work); + expect(() => anchorReleasePr({ cwd: dirty, sourceSha: fx.mergeSha })).toThrow( + new Error( + "this checkout has uncommitted changes to tracked files (M src/marker.ts), and anchoring checks the release PR branch out over them; " + + "commit them, or run it from a clean checkout.", + ), + ); + expect(readFileSync(join(dirty, "src/marker.ts"), "utf8")).toBe(work); + expect(git(dirty, "symbolic-ref", "--short", "HEAD")).toBe("main"); + // The control: the same command from a clean checkout anchors, and leaves HEAD detached as its repair text says. + const clean = clone(fx.root, fx.origin, "operator-clean"); + expect(anchorReleasePr({ cwd: clean, sourceSha: fx.mergeSha }).changed).toBe(true); + expect(git(clean, "rev-parse", "--abbrev-ref", "HEAD")).toBe("HEAD"); + expect(releasePrConfig(fx, "operator-check").boundary).toBe(fx.mergeSha); }); test("a release-please refresh between anchors re-anchors from the same checkout", () => { @@ -880,6 +1042,67 @@ describe("boundaryCheck", () => { expect(boundaryCheck(dir).boundary).toBe(fx.mergeSha); }); + test("a release PR merged with hidden-type commits between its anchor and the merge lands a boundary inside the cycle the merge closed", () => { + const fx = seedFixture(); + const { base } = anchoredReleasePr(fx); + landOnMain(fx, "hidden-1", "docs: land after the release PR was anchored"); + landOnMain(fx, "hidden-2", "chore: land after it too"); + updateReleasePrBranch(fx, "update-branch"); + expect(boundaryCheck(squashMergeReleasePr(fx, "main-after", "2.2.0")).boundary).toBe(base); + }); + + test.each<[string, (fx: Fixture) => string]>([ + ["the previous release merge's parent", (fx) => fx.seedSha], + ["the previous release merge itself", (fx) => fx.mergeSha], + ])( + "a lost anchor leaves the previous cycle's boundary, %s, on main and is refused; the value the refusal names passes", + (_name, previousBoundary) => { + const fx = seedFixture(); + const previous = previousBoundary(fx); + const align = clone(fx.root, fx.origin, "align"); + write(align, "release-please-config.json", configRecording(previous)); + commitAll(align, "chore: record the boundary the 2.1.0 release landed"); + git(align, "push", "--quiet", "origin", "HEAD:refs/heads/main"); + // The release PR is never anchored, so its merge carries main's own value. + const base = pushGreenCommit(fx, "notes-base", "bundle-base\n").sha; + createReleasePrBranch(fx, base, "2.2.0"); + const main = squashMergeReleasePr(fx, "main-after", "2.2.0"); + const merge = git(main, "rev-parse", "HEAD"); + expect(() => boundaryCheck(main)).toThrow( + new Error( + `last-release-sha in release-please-config.json is ${JSON.stringify(previous)}, which is not in the cycle the newest release merge on main closed ` + + `(${merge}, parent ${base}): an anchor was lost or the value was edited, and release PR refreshes would be computed from a stale boundary. ` + + `Fix by PR: set last-release-sha to ${base}.`, + ), + ); + write(main, "release-please-config.json", configRecording(base)); + commitAll(main, "fix(release): restore the boundary"); + expect(boundaryCheck(main).boundary).toBe(base); + }, + ); + + test("the first release merge on a history closes a cycle that reaches back to the root, and an absent boundary beside it is still refused", () => { + const fx = seedFixture(); + const dir = clone(fx.root, fx.origin, "boundary-first"); + git(dir, "checkout", "--quiet", fx.seedSha); + write(dir, "src/marker.ts", "export const marker = 2;\n"); + const base = commitAll(dir, "feat: the commit the first release's notes were built from"); + write(dir, "docs/hidden.md", "landed before the merge\n"); + const hidden = commitAll(dir, "docs: land before the first release merge"); + write(dir, "release-please-config.json", configRecording(base)); + const merge = commitAll(dir, "chore(main): release 2.1.0 (#42)"); + expect(boundaryCheck(dir).boundary).toBe(base); + write(dir, "release-please-config.json", configRecording()); + commitAll(dir, "chore: drop the boundary"); + expect(() => boundaryCheck(dir)).toThrow( + new Error( + "last-release-sha in release-please-config.json is undefined, which is not in the cycle the newest release merge on main closed " + + `(${merge}, parent ${hidden}): an anchor was lost or the value was edited, and release PR refreshes would be computed from a stale boundary. ` + + `Fix by PR: set last-release-sha to ${hidden}.`, + ), + ); + }); + test("a stale boundary fails loudly with the repair value", () => { const fx = seedFixture(); // The seed fixture's config records a placeholder, not the 2.1.0 merge. @@ -1021,14 +1244,6 @@ describe("boundaryCheck", () => { }); describe("anchorCheck", () => { - test("a release PR whose anchor is missing is unmergeable", () => { - const fx = seedFixture(); - createReleasePrBranch(fx, fx.mergeSha, "2.2.0"); - const pr = clone(fx.root, fx.origin, "anchor-check-missing"); - git(pr, "checkout", "--quiet", "release-please--branches--main"); - expect(() => anchorCheck(pr)).toThrow(/anchor is missing or stale/); - }); - test("an anchored release PR passes", () => { const fx = seedFixture(); createReleasePrBranch(fx, fx.mergeSha, "2.2.0"); @@ -1038,6 +1253,141 @@ describe("anchorCheck", () => { git(pr, "checkout", "--quiet", "release-please--branches--main"); expect(anchorCheck(pr).boundary).toBe(fx.mergeSha); }); + + test("an anchored release PR stays anchored when a hidden-type commit lands on main, and when main is merged into its branch", () => { + const fx = seedFixture(); + const { base } = anchoredReleasePr(fx); + landOnMain(fx, "hidden", "docs: land after the release PR was anchored"); + expect(anchorCheck(releasePrHead(fx, "pr-behind")).boundary).toBe(base); + updateReleasePrBranch(fx, "update-branch"); + expect(anchorCheck(releasePrHead(fx, "pr-updated")).boundary).toBe(base); + }); + + test.each<[string, (fx: Fixture, pr: AnchoredPr) => Error]>([ + [ + "a refresh dropped the anchor, so the file holds main's own boundary", + (fx, { base }) => { + createReleasePrBranch(fx, base, "2.3.0"); + return notAnchored(PLACEHOLDER, base); + }, + ], + [ + "the anchor was edited to main's tip after main was merged in", + (fx, { base }) => { + const tip = landOnMain(fx, "hidden", "docs: land after the release PR was anchored"); + updateReleasePrBranch(fx, "update-branch"); + anchorByHand(fx, "by-hand", tip); + return notAnchored(tip, base); + }, + ], + [ + "the anchor was edited to an older commit of the cycle", + (fx, { early, base }) => { + anchorByHand(fx, "by-hand", early); + return notAnchored(early, base); + }, + ], + [ + "the anchor was edited to a sha that is on no history", + (fx, { base }) => { + anchorByHand(fx, "by-hand", "1".repeat(40)); + return notAnchored("1".repeat(40), base); + }, + ], + [ + "the branch was rebased by hand onto main's tip, which reads as a rebuild there", + (fx, { base }) => { + const tip = landOnMain(fx, "hidden", "docs: land after the release PR was anchored"); + const dir = clone(fx.root, fx.origin, "rebase"); + git(dir, "checkout", "--quiet", RELEASE_BRANCH); + git(dir, "rebase", "--quiet", "origin/main"); + git(dir, "push", "--quiet", "--force", "origin", `HEAD:refs/heads/${RELEASE_BRANCH}`); + return notAnchored(base, tip); + }, + ], + [ + "main was merged in with the branch as the second parent", + (fx) => { + landOnMain(fx, "hidden", "docs: land after the release PR was anchored"); + const dir = clone(fx.root, fx.origin, "wrong-way"); + const subject = "Merge the release branch into main's tip"; + git(dir, "merge", "--quiet", "--no-ff", "-m", subject, `origin/${RELEASE_BRANCH}`); + git(dir, "push", "--quiet", "origin", `HEAD:refs/heads/${RELEASE_BRANCH}`); + return noReleaseCommit("origin/main"); + }, + ], + [ + "release-please's commit sits on a commit that never reached main, anchored there", + (fx) => { + const dir = clone(fx.root, fx.origin, "off-main"); + write(dir, "src/marker.ts", "export const marker = 8;\n"); + const offMain = commitAll(dir, "feat: never merged"); + git(dir, "push", "--quiet", "origin", "HEAD:refs/heads/side"); + createReleasePrBranch(fx, offMain, "2.3.0"); + anchorByHand(fx, "by-hand", offMain); + return new Error( + `release-please's commit on this release PR sits on ${offMain}, which is not on main: the branch was rebased or a commit was ` + + `stacked under it, so the commit its notes were built from cannot be read.\n${REBUILD}`, + ); + }, + ], + ])("a release PR is unmergeable once %s", (_name, mutate) => { + const fx = seedFixture(); + const pr = anchoredReleasePr(fx); + // The control: the same release PR passes before the change. + expect(anchorCheck(releasePrHead(fx, "pr-control")).boundary).toBe(pr.base); + const refusal = mutate(fx, pr); + expect(() => anchorCheck(releasePrHead(fx, "pr-judged"))).toThrow(refusal); + }); + + test('the command the refusal names, run from a clean checkout after main moved, anchors the release PR, and answers "already anchored" once it is', async () => { + const fx = seedFixture(); + const base = pushGreenCommit(fx, "notes-base", "bundle-base\n").sha; + // A refresh whose hook never ran, then a hidden-type commit: no later run of the pipeline anchors this PR. + createReleasePrBranch(fx, base, "2.2.0"); + landOnMain(fx, "hidden", "docs: land after the refresh"); + expect(() => anchorCheck(releasePrHead(fx, "pr-unanchored"))).toThrow( + notAnchored(PLACEHOLDER, base), + ); + const operator = clone(fx.root, fx.origin, "operator"); + const repair = await subcommand(operator, { GITHUB_SHA: base }, "anchor"); + expect({ status: repair.status, said: repair.stderr.trim().split("\n").at(-1) }).toEqual({ + status: 0, + said: `release-please--branches--main: anchored at ${base}`, + }); + expect(anchorCheck(releasePrHead(fx, "pr-repaired")).boundary).toBe(base); + // The same command against the anchored branch pushes nothing and gives the answer the refusal quotes. + const anchored = git(fx.origin, "rev-parse", `refs/heads/${RELEASE_BRANCH}`); + const again = await subcommand(operator, { GITHUB_SHA: base }, "anchor"); + expect({ + status: again.status, + said: again.stderr.trim(), + head: git(fx.origin, "rev-parse", `refs/heads/${RELEASE_BRANCH}`), + }).toEqual({ status: 0, said: "already anchored", head: anchored }); + }); + + test("a shallow checkout is refused before any verdict", () => { + const fx = seedFixture(); + anchoredReleasePr(fx); + // Depth 1, as the job's checkout was before it asked for the history: the branch's own chain is not in it. + const dir = join(fx.root, "pr-shallow"); + const branch = ["--branch", RELEASE_BRANCH]; + execFileSync("git", [ + "clone", + "--quiet", + "--depth", + "1", + ...branch, + `file://${fx.origin}`, + dir, + ]); + expect(() => anchorCheck(dir)).toThrow( + new Error( + "anchor-check needs the full history (fetch-depth: 0) and this checkout is shallow: the commit release-please built the " + + "branch on is read from the branch's own history.", + ), + ); + }); }); describe("the release hook's reads of the draft", () => { From 8f3a414ffc5ef961850e16572f458fd8f4929e3b Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:48:11 -0500 Subject: [PATCH 2/3] fix(release): refuse to stamp a release PR branch that moved after its anchor, and name the newest listed commit as main's boundary Two repairs the checks printed could drop a changelog entry when followed as printed. Each now names a repair that drops and repeats nothing. - anchor-check and the anchor command read one state for a release PR that is not anchored. No anchor commit on the branch, or a value that is neither main's own nor an older commit: the stamp at the commit the branch is built on repairs it, as before. An anchor older than that commit means the release commit moved after it was stamped (a rebase onto a newer commit) or the value was edited back: the notes may lack the commits between the two, so the check names the rebuild alone, and the command refuses and pushes nothing. A shallow checkout that would have to tell an older commit from another one is refused. - boundary-check keeps its verdict and changes the value its refusal names: the newest commit the merged release's notes list, read from the commit links the merge added to CHANGELOG.md, inside the cycle the merge closed. It named the merge's parent, which drops a visible commit that landed after the notes were built. With no readable link it names no sha and says where to read the notes. - The anchor command checks the release PR branch out without --force and with --no-overwrite-ignore, so git refuses to overwrite a file of the operator's, untracked or ignored, at a path the branch tracks. - Every refusal of the two checks and the hook has one shape: what is wrong on the first line, then one repair per line, a command alone on its own line. The refusal of a release commit that is not on origin/main says "as fetched here" and puts the fetch first. --- .github/scripts/release-pipeline/anchor.ts | 318 ++++++++++++++------ test/scripts/release-pipeline.test.ts | 325 ++++++++++++++++----- 2 files changed, 488 insertions(+), 155 deletions(-) diff --git a/.github/scripts/release-pipeline/anchor.ts b/.github/scripts/release-pipeline/anchor.ts index 37f71c00..e050ba20 100644 --- a/.github/scripts/release-pipeline/anchor.ts +++ b/.github/scripts/release-pipeline/anchor.ts @@ -20,6 +20,12 @@ import { /** release-please's config at the checkout root, the file the boundary is read from and written into. */ const CONFIG_FILE = "release-please-config.json"; +/** release-please's changelog at the checkout root: what a release merge adds to it is that release's notes. */ +const CHANGELOG_FILE = "CHANGELOG.md"; + +/** A commit as release-please's notes link it. */ +const COMMIT_LINK = /\/commit\/([0-9a-f]{40})\b/g; + /** A squash-merged release-please PR's subject on main, anchored at both ends wherever release merges are recognized: a prefix match would let "chore(main): release pipeline documentation" impersonate one and park the boundary check. */ const RELEASE_SUBJECT = /^chore\(main\): release (\d+\.\d+\.\d+)(?: \(#\d+\))?$/; @@ -32,12 +38,41 @@ const RELEASE_PR_BRANCH = `${RELEASE_PR_BRANCH_PREFIX}branches--main`; /** Main as a full-history checkout of the release PR's head holds it. */ const MAIN = "origin/main"; -/** The repair of a release PR branch whose base cannot be read: release-please builds a new one once this one is gone. */ +/** The repair of a release PR branch whose base cannot be read or trusted: release-please builds a new one once this one is gone. */ const REBUILD = `Delete the branch, and the next green push to main has release-please rebuild the release PR and the hook anchor it:\n git push origin --delete ${RELEASE_PR_BRANCH}`; /** release-please's config with the one key this pipeline reads and writes; the rest is release-please's. */ type ReleaseConfig = Record & { "last-release-sha"?: string }; +/** A release commit: release-please's own on a release PR branch, its squash merge on main. */ +interface Release { + sha: string; + + /** The X.Y.Z its subject names. */ + version: string; +} + +/** How a release PR branch's last-release-sha stands against the commit the branch is built on. + * anchored -> it is that commit + * missing -> main's own value: the branch has no anchor commit + * moved -> an older commit: the release commit moved after its stamp, or the value was edited back + * edited -> any other value */ +type AnchorState = "anchored" | "missing" | "moved" | "edited"; + +/** A refusal as an operator reads one in a CI log: what is wrong on the first line, then one repair per line, a + * command alone on the line under its repair. */ +function refusal(...lines: string[]): Error { + return new Error(lines.join("\n")); +} + +/** The refusal of a config that `where`, a checkout or a commit, does not hold. */ +function missingConfig(where: string): Error { + return refusal( + `${CONFIG_FILE} is missing from ${where}: the release pipeline reads release-please's boundary (last-release-sha) from it.`, + "Restore it by PR.", + ); +} + /** The config's text; a missing file is refused naming it, any other read failure is thrown as it came. */ function readConfigText(cwd: string): string { try { @@ -46,9 +81,7 @@ function readConfigText(cwd: string): string { if ((error as { code?: unknown }).code !== "ENOENT") { throw error; } - throw new Error( - `${CONFIG_FILE} is missing from ${cwd}; the release pipeline reads release-please's boundary (last-release-sha) from it. Restore it by PR.`, - ); + throw missingConfig(cwd); } } @@ -69,30 +102,46 @@ function parseConfigObject(text: string): Record { try { parsed = JSON.parse(text); } catch (error) { - throw new Error( - `${CONFIG_FILE} is not valid JSON (${error instanceof Error ? error.message : String(error)}); fix it by PR.`, + throw refusal( + `${CONFIG_FILE} is not valid JSON (${error instanceof Error ? error.message : String(error)}).`, + "Fix it by PR.", ); } if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { - throw new Error(`${CONFIG_FILE} holds ${jsonKind(parsed)}, not a JSON object; fix it by PR.`); + throw refusal(`${CONFIG_FILE} holds ${jsonKind(parsed)}, not a JSON object.`, "Fix it by PR."); } return parsed as Record; } -/** The config as the checkout holds it. The file is the user's and release-please's, so nothing here is repaired: a - * missing or malformed one, or a boundary that is no commit sha, is refused naming the file (an absent boundary is - * the pre-first-release state and passes). */ -function readReleaseConfig(cwd: string): ReleaseConfig { - const config = parseConfigObject(readConfigText(cwd)); +/** `text` as release-please's config. The file is the user's and release-please's, so nothing here is repaired: a + * malformed one, or a boundary that is no commit sha, is refused naming the file (an absent boundary is the + * pre-first-release state and passes). */ +function parseReleaseConfig(text: string): ReleaseConfig { + const config = parseConfigObject(text); const boundary = config["last-release-sha"]; if (boundary !== undefined && (typeof boundary !== "string" || !FULL_SHA.test(boundary))) { - throw new Error( - `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(boundary)}, not a 40-hex commit sha; fix it by PR.`, + throw refusal( + `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(boundary)}, not a 40-hex commit sha.`, + "Fix it by PR.", ); } return config as ReleaseConfig; } +/** The config as the checkout holds it. */ +function readReleaseConfig(cwd: string): ReleaseConfig { + return parseReleaseConfig(readConfigText(cwd)); +} + +/** The config as the commit `rev` holds it. */ +function releaseConfigAt(cwd: string, rev: string): ReleaseConfig { + const text = tryGit(cwd, "show", `${rev}:${CONFIG_FILE}`); + if (text === null) { + throw missingConfig(`commit ${rev}`); + } + return parseReleaseConfig(text); +} + export interface AnchorOptions { /** The checkout the release PR branch is fetched into and the anchor commit made in. */ cwd: string; @@ -109,18 +158,19 @@ export interface AnchorResult { reason: string; } -/** The newest release commit `git log` lists for `revs` (HEAD when none), or "" for none. The grep only narrows (it - * matches any message line with the prefix); RELEASE_SUBJECT decides, so "chore(main): release pipeline documentation" - * can neither become the boundary nor hide the real newest release commit. */ -function newestRelease(cwd: string, ...revs: string[]): string { +/** The newest release commit `git log` lists for `revs` (HEAD when none), or undefined for none. The grep only + * narrows (it matches any message line with the prefix); RELEASE_SUBJECT decides, so "chore(main): release pipeline + * documentation" can neither become the boundary nor hide the real newest release commit. */ +function newestRelease(cwd: string, ...revs: string[]): Release | undefined { const listed = git(cwd, "log", "--grep", "^chore(main): release ", "--format=%H%x09%s", ...revs); for (const line of listed.split("\n")) { const [sha, subject] = line.split("\t"); - if (sha !== undefined && subject !== undefined && RELEASE_SUBJECT.test(subject)) { - return sha; + const version = RELEASE_SUBJECT.exec(subject ?? "")?.[1]; + if (sha !== undefined && version !== undefined) { + return { sha, version }; } } - return ""; + return undefined; } /** The commit a release PR branch is built on: the first parent of the newest release commit on the branch's @@ -128,25 +178,81 @@ function newestRelease(cwd: string, ...revs: string[]): string { * merge on a branch that holds no release commit. */ function releaseBase(cwd: string, main: string, branch: string): string { const release = newestRelease(cwd, "--first-parent", `${main}..${branch}`); - if (release === "") { - throw new Error( - `the release PR branch holds no release commit ("chore(main): release X.Y.Z") on its first-parent chain above ${main}, ` + - "so the commit its notes were built from cannot be read: main was merged in with the branch as the second parent, " + - `or release-please did not build this branch.\n${REBUILD}`, + if (release === undefined) { + throw refusal( + `the release PR branch holds no release commit ("chore(main): release X.Y.Z") on its first-parent chain above ${main}.`, + "The commit its notes were built from cannot be read: main was merged in with the branch as the second parent, or release-please did not build this branch.", + REBUILD, ); } - return git(cwd, "rev-parse", `${release}^`); + return git(cwd, "rev-parse", `${release.sha}^`); } -/** The anchor is also a command an operator pastes into their own clone, where the forced checkout would discard work. */ +/** Where `recorded`, a release PR branch's last-release-sha, stands against `base`, the commit the branch is built + * on. The check and the command both ask here, so neither stamps what the other refuses. Telling an older commit + * from any other needs the history: a shallow checkout that must tell is refused, never guessed for. */ +function anchorState(cwd: string, recorded: string | undefined, base: string): AnchorState { + if (recorded === base) { + return "anchored"; + } + + if (recorded === releaseConfigAt(cwd, base)["last-release-sha"]) { + return "missing"; + } + + if (recorded === undefined) { + return "edited"; + } + + if (git(cwd, "rev-parse", "--is-shallow-repository") === "true") { + throw refusal( + `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, and this shallow checkout cannot tell whether that is an older commit than ${base}.`, + "Run the command from a clone that holds the full history:", + ` GITHUB_SHA=${base} bun .github/scripts/release-pipeline.ts anchor`, + ); + } + + return isAncestor(cwd, recorded, base) ? "moved" : "edited"; +} + +/** What an unanchored release PR is refused with. The moved state names the rebuild alone: stamping `base` there + * would leave every commit between the two out of this release's notes and out of the next cycle. */ +function notAnchored( + state: Exclude, + recorded: string | undefined, + base: string, +): Error { + const value = `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}`; + if (state === "moved") { + return refusal( + `${value}, an older commit than ${base}, which this release PR is built on.`, + "The branch moved after it was anchored, or the anchor was edited: the notes may lack the commits between the two, so no stamp is offered.", + REBUILD, + ); + } + + const cause = { + missing: "the branch carries no anchor commit", + edited: "the anchor names another commit", + }; + return refusal( + `${value}, but release-please built this release PR on ${base}: ${cause[state]}.`, + "Stamp it from a clean checkout; the command leaves HEAD detached on the release PR branch, and its push re-runs the checks:", + ` GITHUB_SHA=${base} bun .github/scripts/release-pipeline.ts anchor`, + 'If it answers "already anchored", close and reopen the PR to run the checks on its head.', + ); +} + +/** The anchor is also a command an operator pastes into their own clone, where the checkout would carry uncommitted + * work along, and staged work into the anchor commit. */ function refuseUncommittedChanges(cwd: string): void { const dirty = git(cwd, "status", "--porcelain", "--untracked-files=no") .split("\n") .filter(Boolean); if (dirty.length > 0) { - throw new Error( - `this checkout has uncommitted changes to tracked files (${dirty.join("; ")}), and anchoring checks the release PR branch out over them; ` + - "commit them, or run it from a clean checkout.", + throw refusal( + `this checkout has uncommitted changes to tracked files (${dirty.join("; ")}), and anchoring checks the release PR branch out.`, + "Run the command from a clean checkout: commit the changes first, or use another clone.", ); } } @@ -168,9 +274,9 @@ function commitAnchor(cwd: string, config: ReleaseConfig, base: string): void { ); } -/** One attempt: the release PR branch fetched and, when it is built on `sourceSha`, checked out detached, anchored, - * and pushed. Undefined when a release-please refresh overtook the push, so the caller re-reads the branch; a push - * refused with the branch unmoved is thrown. */ +/** One attempt: the release PR branch fetched and, when it is built on `sourceSha` and its anchor is missing or + * edited, checked out detached, anchored, and pushed. Undefined when a release-please refresh overtook the push, so + * the caller re-reads the branch; a push refused with the branch unmoved is thrown. */ function anchorAttempt(cwd: string, sourceSha: string): AnchorResult | undefined { const branchRef = `refs/heads/${RELEASE_PR_BRANCH}`; const observed = git(cwd, "ls-remote", "origin", branchRef).split("\t")[0] ?? ""; @@ -187,14 +293,19 @@ function anchorAttempt(cwd: string, sourceSha: string): AnchorResult | undefined }; } - refuseUncommittedChanges(cwd); - // Detached, never a local branch: a retry after an overtaken push re-fetches, and git refuses to fetch into a checked-out ref. - git(cwd, "checkout", "--quiet", "--force", "--detach", "FETCH_HEAD"); - const config = readReleaseConfig(cwd); - if (config["last-release-sha"] === sourceSha) { + const config = releaseConfigAt(cwd, "FETCH_HEAD"); + const state = anchorState(cwd, config["last-release-sha"], sourceSha); + if (state === "anchored") { return { changed: false, reason: "already anchored" }; } + if (state === "moved") { + throw notAnchored(state, config["last-release-sha"], sourceSha); + } + refuseUncommittedChanges(cwd); + // Detached, never a local branch: a retry after an overtaken push re-fetches, and git refuses to fetch into a checked-out ref. + // Unforced, ignored files included: git then refuses to overwrite a file of the operator's that the branch tracks. + git(cwd, "checkout", "--quiet", "--detach", "--no-overwrite-ignore", "FETCH_HEAD"); commitAnchor(cwd, config, sourceSha); const refused = push(cwd, "origin", `HEAD:${branchRef}`); if (refused === null) { @@ -226,70 +337,108 @@ export function anchorReleasePr(options: AnchorOptions): AnchorResult { `anchor push attempt ${attempt}/${attempts} overtaken by a refresh; re-reading the branch`, ); } - throw new Error( - `could not anchor ${RELEASE_PR_BRANCH} after ${attempts} attempts; something keeps rewriting the branch - rerun this job once the branch settles.`, + throw refusal( + `could not anchor ${RELEASE_PR_BRANCH} after ${attempts} attempts: something keeps rewriting the branch.`, + "Rerun this job once the branch settles.", ); } /** The verdict with no release merge on the history: the pre-first-release state when nothing is recorded, else a - * refusal saying why the recorded boundary has no merge. */ + * refusal saying why the recorded boundary has no merge. A recorded boundary is never read as that state. */ function noMergeVerdict(cwd: string, recorded: string | undefined): { boundary: string } { if (recorded === undefined) { return { boundary: "none (no release merge on this history yet)" }; } - const cause = isAncestor(cwd, recorded, "HEAD") - ? `this history holds it, so release-please's merge subject no longer matches ${RELEASE_SUBJECT} (investigate RELEASE_SUBJECT)` - : "it is not on this history at all (not main, or a boundary that never landed)"; - throw new Error( - `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, but no release merge is reachable from HEAD: ${cause}. Refusing to read a recorded boundary as a pre-first-release history.`, + + const wrong = `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, but no release merge is reachable from HEAD.`; + if (isAncestor(cwd, recorded, "HEAD")) { + throw refusal( + wrong, + `This history holds that commit, so release-please's merge subject no longer matches ${RELEASE_SUBJECT}.`, + "Investigate RELEASE_SUBJECT in .github/scripts/release-pipeline/anchor.ts.", + ); + } + throw refusal( + wrong, + "That commit is not on this history at all: this is not main, or the boundary never landed.", + "Fix by PR when it never landed: remove last-release-sha, which a history with no release merge does not record.", ); } -/** Whether `sha` is a commit of the cycle a release merge closed: on main at or before `parent`, the merge's parent, - * and after the release merge before it. The first release merge on a history has none before it, so its cycle - * reaches back to the root. */ -function inCycle(cwd: string, sha: string, parent: string): boolean { - if (!isAncestor(cwd, sha, parent)) { - return false; - } +/** The cycle a release merge closed, newest first: main's commits at or before `parent`, the merge's parent, and + * after the release merge before it. The first release merge on a history has none before it, so its cycle reaches + * back to the root. */ +function cycleOf(cwd: string, parent: string): string[] { const previous = newestRelease(cwd, parent); - return previous === "" || (sha !== previous && isAncestor(cwd, previous, sha)); + const range = + previous === undefined ? [parent] : ["--ancestry-path", `${previous.sha}..${parent}`]; + return git(cwd, "rev-list", "--topo-order", ...range) + .split("\n") + .filter(Boolean); +} + +/** The newest commit a release merge's notes list, or undefined when none reads so: of the commits its changelog + * diff links, the one of `cycle` that every other is an ancestor of. As the boundary it drops and repeats nothing: + * the notes hold every changelog-visible commit up to it, and none after it. */ +function newestListed(cwd: string, merge: string, cycle: string[]): string | undefined { + const diff = tryGit(cwd, "diff", "--unified=0", `${merge}^`, merge, "--", CHANGELOG_FILE) ?? ""; + const added = diff.split("\n").filter((line) => line.startsWith("+") && !line.startsWith("+++ ")); + const linked = new Set( + added.flatMap((line) => [...line.matchAll(COMMIT_LINK)].map((link) => link[1])), + ); + const listed = cycle.filter((sha) => linked.has(sha)); + const newest = listed[0]; + if (newest === undefined) { + return undefined; + } + + const beside = git(cwd, "rev-list", "--max-count=1", ...listed, `^${newest}`); + return beside === "" ? newest : undefined; } -/** On main, last-release-sha must be the newest release merge or a commit of the cycle that merge closed. Which - * commit of the cycle is the release PR's anchor-check to judge: a squash merge no longer shows it. - * outside the cycle -> an anchor was lost or a release merge slipped past the pipeline: refused - * NEWER than every recognized merge -> a missed merge or a hand edit: refused, and never rolled back */ +/** On main, last-release-sha must be the newest release merge or a commit of the cycle it closed. Which commit is + * anchor-check's to judge: a squash merge no longer shows it. + * outside the cycle -> a lost anchor or a missed merge: refused, naming the newest commit the + * release's notes list + * NEWER than every recognized merge -> a missed merge or a hand edit: refused, never rolled back */ export function boundaryCheck(cwd: string): { boundary: string } { assertFullHistory( cwd, "boundary-check", - "a release merge or the recorded boundary can sit beyond its depth, and no verdict on a truncated history holds.", + "a release merge or the recorded boundary can sit beyond its depth.", ); const recorded = readReleaseConfig(cwd)["last-release-sha"]; const latest = newestRelease(cwd); - if (latest === "") { + if (latest === undefined) { return noMergeVerdict(cwd, recorded); } - const parent = tryGit(cwd, "rev-parse", `${latest}^`); + + const parent = tryGit(cwd, "rev-parse", `${latest.sha}^`); + const cycle = parent === null ? [] : cycleOf(cwd, parent); if (recorded !== undefined) { - if (recorded === latest || (parent !== null && inCycle(cwd, recorded, parent))) { + if (recorded === latest.sha || cycle.includes(recorded)) { return { boundary: recorded }; } - if (isAncestor(cwd, latest, recorded) && isAncestor(cwd, recorded, "HEAD")) { - throw new Error( - `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, NEWER than ` + - `${latest}, the newest release merge whose subject matches ${RELEASE_SUBJECT}: either a ` + - `newer release merge's subject stopped matching (investigate RELEASE_SUBJECT) or the ` + - `boundary was edited by hand. It must not be rolled back to ${parent ?? latest}.`, + if (isAncestor(cwd, latest.sha, recorded) && isAncestor(cwd, recorded, "HEAD")) { + throw refusal( + `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, NEWER than ${latest.sha}, the newest release merge this check recognizes.`, + `A newer release merge's subject stopped matching ${RELEASE_SUBJECT}, or the boundary was edited by hand.`, + `Investigate RELEASE_SUBJECT, and never roll the boundary back to ${latest.sha} or into the cycle it closed.`, ); } } - throw new Error( - `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, which is not in the cycle the newest release merge on main closed ` + - `(${latest}, parent ${parent}): an anchor was lost or the value was edited, and release PR refreshes would be computed from a stale boundary. ` + - `Fix by PR: set last-release-sha to ${parent ?? latest}.`, - ); + + const wrong = `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, outside the cycle the newest release merge ${latest.sha} closed: an anchor was lost, or the value was edited.`; + const notes = `the newest commit release ${latest.version}'s notes list`; + const listed = newestListed(cwd, latest.sha, cycle); + if (listed === undefined) { + throw refusal( + wrong, + `Fix by PR: set last-release-sha to ${notes}; none could be read here, so find it in:`, + ` git show ${latest.sha} -- ${CHANGELOG_FILE}`, + ); + } + throw refusal(wrong, `Fix by PR: set last-release-sha to ${listed}, ${notes}.`); } /** Run on the release PR's head with its history. last-release-sha must be the main commit release-please built the @@ -304,19 +453,18 @@ export function anchorCheck(cwd: string): { boundary: string } { const recorded = readReleaseConfig(cwd)["last-release-sha"]; const base = releaseBase(cwd, MAIN, "HEAD"); if (!isAncestor(cwd, base, MAIN)) { - throw new Error( - `release-please's commit on this release PR sits on ${base}, which is not on main: the branch was rebased or a commit was ` + - `stacked under it, so the commit its notes were built from cannot be read.\n${REBUILD}`, + throw refusal( + `release-please's commit on this release PR sits on ${base}, which is not on ${MAIN} as fetched here.`, + "First fetch main and run the check again:", + " git fetch origin", + "Only when it still fails: the branch was rebased, or a commit was stacked under its release commit.", + REBUILD, ); } - if (recorded !== base) { - throw new Error( - `last-release-sha in ${CONFIG_FILE} is ${JSON.stringify(recorded)}, but release-please built this release PR on ${base}, ` + - "the commit its notes were built from; the anchor is missing or names another commit, so merging would land a wrong boundary.\n" + - "Repair it with the command below, from a clean checkout: it leaves HEAD detached on the release PR branch, and its push re-runs the checks. " + - 'When it answers "already anchored", the branch was anchored after this run began: close and reopen the PR to run the checks on its head.\n' + - ` GITHUB_SHA=${base} bun .github/scripts/release-pipeline.ts anchor`, - ); + + const state = anchorState(cwd, recorded, base); + if (state !== "anchored") { + throw notAnchored(state, recorded, base); } return { boundary: base }; } diff --git a/test/scripts/release-pipeline.test.ts b/test/scripts/release-pipeline.test.ts index 611df2b7..e4137e3c 100644 --- a/test/scripts/release-pipeline.test.ts +++ b/test/scripts/release-pipeline.test.ts @@ -663,20 +663,26 @@ describe("retagMajor", () => { ); }); -/** release-please's PR branch: manifest and changelog bumped to `version` on `from`; left unpushed for a competitor plan when `push` is false. */ +/** release-please's PR branch: manifest and changelog bumped to `version` on `from`, the notes linking the commits `listed` as release-please + * links them (none: one entry with a short sha, which names no commit); left unpushed for a competitor plan when `push` is false. */ function createReleasePrBranch( fx: Fixture, from: string, version: string, push = true, + listed: string[] = [], ): { dir: string; sha: string } { const dir = clone(fx.root, fx.origin, `rp-branch-${version}-${from.slice(0, 7)}`); git(dir, "checkout", "--quiet", "-B", "release-please--branches--main", from); write(dir, ".release-please-manifest.json", `${JSON.stringify({ ".": version }, null, 2)}\n`); + const links = listed.length === 0 ? ["abc1234"] : listed; + const entries = links.map( + (sha) => `* the fix ([${sha.slice(0, 7)}](https://example.invalid/commit/${sha}))\n`, + ); write( dir, "CHANGELOG.md", - `# Changelog\n\n## [${version}](https://example.invalid/compare) (2026-08-14)\n\n### Bug Fixes\n\n* the fix ([abc1234](https://example.invalid/commit/abc1234))\n${CHANGELOG_21}`, + `# Changelog\n\n## [${version}](https://example.invalid/compare) (2026-08-14)\n\n### Bug Fixes\n\n${entries.join("")}${CHANGELOG_21}`, ); const sha = commitAll(dir, `chore(main): release ${version}`); if (push) { @@ -740,6 +746,14 @@ function updateReleasePrBranch(fx: Fixture, name: string): void { git(dir, "push", "--quiet", "origin", `HEAD:refs/heads/${RELEASE_BRANCH}`); } +/** GitHub's "Update with rebase": the release PR branch replayed onto origin's main, so release-please's commit sits on main's tip. */ +function rebaseReleasePrBranch(fx: Fixture, name: string): void { + const dir = clone(fx.root, fx.origin, name); + git(dir, "checkout", "--quiet", RELEASE_BRANCH); + git(dir, "rebase", "--quiet", "origin/main"); + git(dir, "push", "--quiet", "--force", "origin", `HEAD:refs/heads/${RELEASE_BRANCH}`); +} + /** A commit on the release PR branch that sets last-release-sha to `boundary`: an anchor made by hand. */ function anchorByHand(fx: Fixture, name: string, boundary: string): void { const dir = clone(fx.root, fx.origin, name); @@ -784,23 +798,63 @@ function anchoredReleasePr(fx: Fixture): AnchoredPr { return { early, base }; } -/** anchor-check's refusal of a release PR that records `recorded` on a branch built on `base`. */ -function notAnchored(recorded: string, base: string): Error { +/** What `run` refuses with: its message, whole. */ +function refusalOf(run: () => unknown): string { + try { + run(); + } catch (error) { + return (error as Error).message; + } + return "no refusal"; +} + +/** The start of every refusal of a release PR's anchor: the value the branch records. */ +const recording = (recorded: string): string => + `last-release-sha in release-please-config.json is ${JSON.stringify(recorded)}`; + +/** anchor-check's refusal of a release PR built on `base` whose anchor the stamp at `base` repairs: none on the branch, or another commit. */ +function notAnchored(state: "missing" | "edited", recorded: string, base: string): Error { + const cause = { + missing: "the branch carries no anchor commit", + edited: "the anchor names another commit", + }; return new Error( - `last-release-sha in release-please-config.json is ${JSON.stringify(recorded)}, but release-please built this release PR on ${base}, ` + - "the commit its notes were built from; the anchor is missing or names another commit, so merging would land a wrong boundary.\n" + - "Repair it with the command below, from a clean checkout: it leaves HEAD detached on the release PR branch, and its push re-runs the checks. " + - 'When it answers "already anchored", the branch was anchored after this run began: close and reopen the PR to run the checks on its head.\n' + + [ + `${recording(recorded)}, but release-please built this release PR on ${base}: ${cause[state]}.`, + "Stamp it from a clean checkout; the command leaves HEAD detached on the release PR branch, and its push re-runs the checks:", ` GITHUB_SHA=${base} bun .github/scripts/release-pipeline.ts anchor`, + 'If it answers "already anchored", close and reopen the PR to run the checks on its head.', + ].join("\n"), + ); +} + +/** The refusal, by the check and by the command alike, of a release PR whose anchor is older than the `base` it is built on: no stamp, the rebuild alone. */ +function movedAfterAnchoring(recorded: string, base: string): Error { + return new Error( + [ + `${recording(recorded)}, an older commit than ${base}, which this release PR is built on.`, + "The branch moved after it was anchored, or the anchor was edited: the notes may lack the commits between the two, so no stamp is offered.", + REBUILD, + ].join("\n"), ); } /** The refusal of a release PR branch with no release commit on its first-parent chain above `main`. */ function noReleaseCommit(main: string): Error { return new Error( - `the release PR branch holds no release commit ("chore(main): release X.Y.Z") on its first-parent chain above ${main}, ` + - "so the commit its notes were built from cannot be read: main was merged in with the branch as the second parent, " + - `or release-please did not build this branch.\n${REBUILD}`, + [ + `the release PR branch holds no release commit ("chore(main): release X.Y.Z") on its first-parent chain above ${main}.`, + "The commit its notes were built from cannot be read: main was merged in with the branch as the second parent, or release-please did not build this branch.", + REBUILD, + ].join("\n"), + ); +} + +/** boundary-check's refusal of a boundary outside the cycle `merge` closed; `repair` is the value to set, or how to find it. */ +function outsideCycle(recorded: string | undefined, merge: string, repair: string): Error { + return new Error( + `last-release-sha in release-please-config.json is ${JSON.stringify(recorded)}, outside the cycle the newest release merge ${merge} closed: ` + + `an anchor was lost, or the value was edited.\nFix by PR: set last-release-sha to ${repair}`, ); } @@ -912,8 +966,8 @@ describe("anchorReleasePr", () => { write(dirty, "src/marker.ts", work); expect(() => anchorReleasePr({ cwd: dirty, sourceSha: fx.mergeSha })).toThrow( new Error( - "this checkout has uncommitted changes to tracked files (M src/marker.ts), and anchoring checks the release PR branch out over them; " + - "commit them, or run it from a clean checkout.", + "this checkout has uncommitted changes to tracked files (M src/marker.ts), and anchoring checks the release PR branch out.\n" + + "Run the command from a clean checkout: commit the changes first, or use another clone.", ), ); expect(readFileSync(join(dirty, "src/marker.ts"), "utf8")).toBe(work); @@ -925,6 +979,31 @@ describe("anchorReleasePr", () => { expect(releasePrConfig(fx, "operator-check").boundary).toBe(fx.mergeSha); }); + test.each<[string, string]>([ + ["untracked", ""], + ["untracked and ignored by the clone's exclude file", "docs/guide.md\n"], + ])( + "the operator's own file at a path the release PR branch tracks, %s, survives: git refuses the checkout, and nothing is pushed", + (_name, excluded) => { + const fx = seedFixture(); + // The operator's clone predates a file main gained since, and holds a file of their own at its path. + const operator = clone(fx.root, fx.origin, "operator-untracked"); + const base = landOnMain(fx, "guide", "docs: add a guide"); + const branch = createReleasePrBranch(fx, base, "2.2.0"); + const work = "the operator's own notes, in no commit\n"; + write(operator, "docs/guide.md", work); + write(operator, ".git/info/exclude", excluded); + expect(() => anchorReleasePr({ cwd: operator, sourceSha: base })).toThrow( + /^git checkout --quiet --detach --no-overwrite-ignore FETCH_HEAD failed: [\s\S]*docs\/guide\.md/, + ); + expect({ + file: readFileSync(join(operator, "docs/guide.md"), "utf8"), + head: git(operator, "symbolic-ref", "--short", "HEAD"), + branch: git(fx.origin, "rev-parse", `refs/heads/${RELEASE_BRANCH}`), + }).toEqual({ file: work, head: "main", branch: branch.sha }); + }, + ); + test("a release-please refresh between anchors re-anchors from the same checkout", () => { const fx = seedFixture(); createReleasePrBranch(fx, fx.mergeSha, "2.2.0"); @@ -985,7 +1064,10 @@ describe("anchorReleasePr", () => { })), () => { expect(() => anchorReleasePr({ cwd: worker, sourceSha: fx.mergeSha })).toThrow( - "could not anchor release-please--branches--main after 3 attempts; something keeps rewriting the branch - rerun this job once the branch settles.", + new Error( + "could not anchor release-please--branches--main after 3 attempts: something keeps rewriting the branch.\n" + + "Rerun this job once the branch settles.", + ), ); }, ); @@ -1055,7 +1137,7 @@ describe("boundaryCheck", () => { ["the previous release merge's parent", (fx) => fx.seedSha], ["the previous release merge itself", (fx) => fx.mergeSha], ])( - "a lost anchor leaves the previous cycle's boundary, %s, on main and is refused; the value the refusal names passes", + "a lost anchor leaves the previous cycle's boundary, %s, on main and is refused naming the newest commit the release's notes list, which keeps a visible commit the notes lack for the next cycle", (_name, previousBoundary) => { const fx = seedFixture(); const previous = previousBoundary(fx); @@ -1063,21 +1145,62 @@ describe("boundaryCheck", () => { write(align, "release-please-config.json", configRecording(previous)); commitAll(align, "chore: record the boundary the 2.1.0 release landed"); git(align, "push", "--quiet", "origin", "HEAD:refs/heads/main"); - // The release PR is never anchored, so its merge carries main's own value. - const base = pushGreenCommit(fx, "notes-base", "bundle-base\n").sha; - createReleasePrBranch(fx, base, "2.2.0"); + // The release PR is never anchored, so its merge carries main's own value; a visible commit lands after its notes were built. + const listed = pushGreenCommit(fx, "listed", "bundle-listed\n").sha; + createReleasePrBranch(fx, listed, "2.2.0", true, [listed]); + const unlisted = pushGreenCommit(fx, "unlisted", "bundle-unlisted\n").sha; const main = squashMergeReleasePr(fx, "main-after", "2.2.0"); const merge = git(main, "rev-parse", "HEAD"); + /** What the next cycle reads from main with `boundary` recorded: the commits after it. */ + const nextCycle = (boundary: string): string[] => + git(main, "rev-list", `${boundary}..${merge}`).split("\n"); + + const refused = refusalOf(() => boundaryCheck(main)); + expect(refused).toContain(`set last-release-sha to ${listed},`); + expect(refused).toBe( + outsideCycle(previous, merge, `${listed}, the newest commit release 2.2.0's notes list.`) + .message, + ); + write(main, "release-please-config.json", configRecording(listed)); + commitAll(main, "fix(release): restore the boundary"); + expect(boundaryCheck(main).boundary).toBe(listed); + expect(nextCycle(listed)).toContain(unlisted); + // The control: the merge's parent passes the check too, and no cycle would ever list the commit. + expect(git(main, "rev-parse", `${merge}^`)).toBe(unlisted); + write(main, "release-please-config.json", configRecording(unlisted)); + commitAll(main, "fix(release): record the merge's parent instead"); + expect(boundaryCheck(main).boundary).toBe(unlisted); + expect(nextCycle(unlisted)).not.toContain(unlisted); + }, + ); + + test.each<[string, (fx: Fixture) => { main: string; version: string }]>([ + [ + "link no commit by its full sha", + // The seed fixture: its config records a placeholder, and its 2.1.0 notes link a short sha. + (fx) => ({ main: fx.work, version: "2.1.0" }), + ], + [ + "link only a commit of an older cycle", + (fx) => { + const base = pushGreenCommit(fx, "notes-base", "bundle-base\n").sha; + createReleasePrBranch(fx, base, "2.2.0", true, [fx.seedSha]); + return { main: squashMergeReleasePr(fx, "main-after", "2.2.0"), version: "2.2.0" }; + }, + ], + ])( + "a lost anchor whose release notes %s is refused naming no sha, and where to read the notes", + (_name, build) => { + const fx = seedFixture(); + const { main, version } = build(fx); + const merge = git(main, "rev-parse", "HEAD"); expect(() => boundaryCheck(main)).toThrow( - new Error( - `last-release-sha in release-please-config.json is ${JSON.stringify(previous)}, which is not in the cycle the newest release merge on main closed ` + - `(${merge}, parent ${base}): an anchor was lost or the value was edited, and release PR refreshes would be computed from a stale boundary. ` + - `Fix by PR: set last-release-sha to ${base}.`, + outsideCycle( + PLACEHOLDER, + merge, + `the newest commit release ${version}'s notes list; none could be read here, so find it in:\n git show ${merge} -- CHANGELOG.md`, ), ); - write(main, "release-please-config.json", configRecording(base)); - commitAll(main, "fix(release): restore the boundary"); - expect(boundaryCheck(main).boundary).toBe(base); }, ); @@ -1088,27 +1211,22 @@ describe("boundaryCheck", () => { write(dir, "src/marker.ts", "export const marker = 2;\n"); const base = commitAll(dir, "feat: the commit the first release's notes were built from"); write(dir, "docs/hidden.md", "landed before the merge\n"); - const hidden = commitAll(dir, "docs: land before the first release merge"); + commitAll(dir, "docs: land before the first release merge"); write(dir, "release-please-config.json", configRecording(base)); + write( + dir, + "CHANGELOG.md", + `# Changelog\n\n## 2.1.0\n\n* the first ([${base.slice(0, 7)}](https://example.invalid/commit/${base}))\n`, + ); const merge = commitAll(dir, "chore(main): release 2.1.0 (#42)"); expect(boundaryCheck(dir).boundary).toBe(base); write(dir, "release-please-config.json", configRecording()); commitAll(dir, "chore: drop the boundary"); expect(() => boundaryCheck(dir)).toThrow( - new Error( - "last-release-sha in release-please-config.json is undefined, which is not in the cycle the newest release merge on main closed " + - `(${merge}, parent ${hidden}): an anchor was lost or the value was edited, and release PR refreshes would be computed from a stale boundary. ` + - `Fix by PR: set last-release-sha to ${hidden}.`, - ), + outsideCycle(undefined, merge, `${base}, the newest commit release 2.1.0's notes list.`), ); }); - test("a stale boundary fails loudly with the repair value", () => { - const fx = seedFixture(); - // The seed fixture's config records a placeholder, not the 2.1.0 merge. - expect(() => boundaryCheck(fx.work)).toThrow(/stale boundary|set last-release-sha/); - }); - test("a history without release merges and no recorded boundary is pre-first-release", () => { const fx = seedFixture(); const dir = clone(fx.root, fx.origin, "boundary-none"); @@ -1127,7 +1245,13 @@ describe("boundaryCheck", () => { const dir = clone(fx.root, fx.origin, "boundary-foreign"); // The seed commit records a placeholder boundary and has no release merge behind it. git(dir, "checkout", "--quiet", fx.seedSha); - expect(() => boundaryCheck(dir)).toThrow(/not on this history at all/); + expect(() => boundaryCheck(dir)).toThrow( + new Error( + `last-release-sha in release-please-config.json is "${PLACEHOLDER}", but no release merge is reachable from HEAD.\n` + + "That commit is not on this history at all: this is not main, or the boundary never landed.\n" + + "Fix by PR when it never landed: remove last-release-sha, which a history with no release merge does not record.", + ), + ); }); test("a shallow checkout is refused before any verdict", () => { @@ -1169,7 +1293,13 @@ describe("boundaryCheck", () => { )}\n`, ); commitAll(dir, "chore(main): release: 2.1.0 (#42)"); - expect(() => boundaryCheck(dir)).toThrow(/history holds it.*RELEASE_SUBJECT/); + expect(refusalOf(() => boundaryCheck(dir)).split("\n")).toEqual([ + `last-release-sha in release-please-config.json is "${fx.seedSha}", but no release merge is reachable from HEAD.`, + expect.stringMatching( + /^This history holds that commit, so release-please's merge subject no longer matches \/.+\/\.$/, + ), + "Investigate RELEASE_SUBJECT in .github/scripts/release-pipeline/anchor.ts.", + ]); }); test("a boundary newer than the newest recognized merge is drift, not a rollback", () => { @@ -1193,7 +1323,13 @@ describe("boundaryCheck", () => { )}\n`, ); commitAll(dir, "chore(main): release v2.2.0 (#43)"); - expect(() => boundaryCheck(dir)).toThrow(/NEWER than.*must not be rolled back/); + expect(refusalOf(() => boundaryCheck(dir)).split("\n")).toEqual([ + `last-release-sha in release-please-config.json is "${between}", NEWER than ${fx.mergeSha}, the newest release merge this check recognizes.`, + expect.stringMatching( + /^A newer release merge's subject stopped matching \/.+\/, or the boundary was edited by hand\.$/, + ), + `Investigate RELEASE_SUBJECT, and never roll the boundary back to ${fx.mergeSha} or into the cycle it closed.`, + ]); }); test("a boundary on an unmerged branch is stale, not newer", () => { @@ -1217,7 +1353,7 @@ describe("boundaryCheck", () => { )}\n`, ); commitAll(dir, "chore: record a boundary from the wrong branch"); - expect(() => boundaryCheck(dir)).toThrow(/stale boundary.*set last-release-sha/); + expect(() => boundaryCheck(dir)).toThrow(/outside the cycle[\s\S]*set last-release-sha/); }); test("a subject that only shares the release prefix is not a release merge", () => { @@ -1265,44 +1401,33 @@ describe("anchorCheck", () => { test.each<[string, (fx: Fixture, pr: AnchoredPr) => Error]>([ [ - "a refresh dropped the anchor, so the file holds main's own boundary", + "a refresh dropped the anchor, so the file holds main's own boundary: the stamp repairs it", (fx, { base }) => { createReleasePrBranch(fx, base, "2.3.0"); - return notAnchored(PLACEHOLDER, base); + return notAnchored("missing", PLACEHOLDER, base); }, ], [ - "the anchor was edited to main's tip after main was merged in", + "the anchor was edited to main's tip after main was merged in: the stamp repairs it", (fx, { base }) => { const tip = landOnMain(fx, "hidden", "docs: land after the release PR was anchored"); updateReleasePrBranch(fx, "update-branch"); anchorByHand(fx, "by-hand", tip); - return notAnchored(tip, base); - }, - ], - [ - "the anchor was edited to an older commit of the cycle", - (fx, { early, base }) => { - anchorByHand(fx, "by-hand", early); - return notAnchored(early, base); + return notAnchored("edited", tip, base); }, ], [ - "the anchor was edited to a sha that is on no history", + "the anchor was edited to a sha that is on no history: the stamp repairs it", (fx, { base }) => { anchorByHand(fx, "by-hand", "1".repeat(40)); - return notAnchored("1".repeat(40), base); + return notAnchored("edited", "1".repeat(40), base); }, ], [ - "the branch was rebased by hand onto main's tip, which reads as a rebuild there", - (fx, { base }) => { - const tip = landOnMain(fx, "hidden", "docs: land after the release PR was anchored"); - const dir = clone(fx.root, fx.origin, "rebase"); - git(dir, "checkout", "--quiet", RELEASE_BRANCH); - git(dir, "rebase", "--quiet", "origin/main"); - git(dir, "push", "--quiet", "--force", "origin", `HEAD:refs/heads/${RELEASE_BRANCH}`); - return notAnchored(base, tip); + "the anchor was edited to an older commit of the cycle, which reads as a branch that moved: no stamp is offered", + (fx, { early, base }) => { + anchorByHand(fx, "by-hand", early); + return movedAfterAnchoring(early, base); }, ], [ @@ -1326,8 +1451,13 @@ describe("anchorCheck", () => { createReleasePrBranch(fx, offMain, "2.3.0"); anchorByHand(fx, "by-hand", offMain); return new Error( - `release-please's commit on this release PR sits on ${offMain}, which is not on main: the branch was rebased or a commit was ` + - `stacked under it, so the commit its notes were built from cannot be read.\n${REBUILD}`, + [ + `release-please's commit on this release PR sits on ${offMain}, which is not on origin/main as fetched here.`, + "First fetch main and run the check again:", + " git fetch origin", + "Only when it still fails: the branch was rebased, or a commit was stacked under its release commit.", + REBUILD, + ].join("\n"), ); }, ], @@ -1340,6 +1470,51 @@ describe("anchorCheck", () => { expect(() => anchorCheck(releasePrHead(fx, "pr-judged"))).toThrow(refusal); }); + test("a release PR branch rebased onto a newer visible commit after it was anchored is refused by the check and by the command, which stamp nothing: its notes lack that commit", async () => { + const fx = seedFixture(); + const { base } = anchoredReleasePr(fx); + const unlisted = pushGreenCommit(fx, "unlisted", "bundle-unlisted\n").sha; + // The control: the anchored release PR passes while it sits on the commit its notes were built from. + expect(anchorCheck(releasePrHead(fx, "pr-control")).boundary).toBe(base); + rebaseReleasePrBranch(fx, "update-with-rebase"); + const moved = git(fx.origin, "rev-parse", `refs/heads/${RELEASE_BRANCH}`); + const refusal = movedAfterAnchoring(base, unlisted).message; + + const checked = refusalOf(() => anchorCheck(releasePrHead(fx, "pr-moved"))); + expect(checked).not.toContain("release-pipeline.ts anchor"); + expect(checked).toBe(refusal); + // The stamp the check no longer offers, run anyway: from a full clone, and from the hook job's depth-1 checkout, which cannot tell. + const byHand = await subcommand( + clone(fx.root, fx.origin, "operator"), + { GITHUB_SHA: unlisted }, + "anchor", + ); + const shallow = refusalOf(() => + anchorReleasePr({ cwd: shallowClone(fx, "hook-rerun"), sourceSha: unlisted }), + ); + expect({ + status: byHand.status, + said: byHand.stderr.trim(), + shallow, + head: git(fx.origin, "rev-parse", `refs/heads/${RELEASE_BRANCH}`), + }).toEqual({ + status: 1, + said: `release-pipeline anchor: ${refusal}`, + shallow: [ + `${recording(base)}, and this shallow checkout cannot tell whether that is an older commit than ${unlisted}.`, + "Run the command from a clone that holds the full history:", + ` GITHUB_SHA=${unlisted} bun .github/scripts/release-pipeline.ts anchor`, + ].join("\n"), + head: moved, + }); + + // The repair the refusal names: release-please's rebuild lists the commit and carries no anchor, so the same command stamps it. + createReleasePrBranch(fx, unlisted, "2.2.0", true, [base, unlisted]); + const rebuilt = clone(fx.root, fx.origin, "hook-after-rebuild"); + expect(anchorReleasePr({ cwd: rebuilt, sourceSha: unlisted }).changed).toBe(true); + expect(anchorCheck(releasePrHead(fx, "pr-rebuilt")).boundary).toBe(unlisted); + }); + test('the command the refusal names, run from a clean checkout after main moved, anchors the release PR, and answers "already anchored" once it is', async () => { const fx = seedFixture(); const base = pushGreenCommit(fx, "notes-base", "bundle-base\n").sha; @@ -1347,7 +1522,7 @@ describe("anchorCheck", () => { createReleasePrBranch(fx, base, "2.2.0"); landOnMain(fx, "hidden", "docs: land after the refresh"); expect(() => anchorCheck(releasePrHead(fx, "pr-unanchored"))).toThrow( - notAnchored(PLACEHOLDER, base), + notAnchored("missing", PLACEHOLDER, base), ); const operator = clone(fx.root, fx.origin, "operator"); const repair = await subcommand(operator, { GITHUB_SHA: base }, "anchor"); @@ -1544,12 +1719,20 @@ describe("the release hook's reads of the draft", () => { describe("the release-please config", () => { const malformed: [string, string, RegExp][] = [ - ["not JSON", "{ not json\n", /^release-please-config\.json is not valid JSON \(/], - ["a JSON array", "[]\n", /^release-please-config\.json holds an array, not a JSON object;/], + [ + "not JSON", + "{ not json\n", + /^release-please-config\.json is not valid JSON \(.+\)\.\nFix it by PR\.$/, + ], + [ + "a JSON array", + "[]\n", + /^release-please-config\.json holds an array, not a JSON object\.\nFix it by PR\.$/, + ], [ "a boundary that is not a commit sha", `${JSON.stringify({ "last-release-sha": 42, packages: {} })}\n`, - /^last-release-sha in release-please-config\.json is 42, not a 40-hex commit sha;/, + /^last-release-sha in release-please-config\.json is 42, not a 40-hex commit sha\.\nFix it by PR\.$/, ], ]; test.each(malformed)( @@ -1579,8 +1762,10 @@ describe("the release-please config", () => { const checker = clone(fx.root, fx.origin, "config-missing"); rmSync(join(checker, "release-please-config.json")); expect(() => boundaryCheck(checker)).toThrow( - `release-please-config.json is missing from ${checker}; the release pipeline reads ` + - "release-please's boundary (last-release-sha) from it. Restore it by PR.", + new Error( + `release-please-config.json is missing from ${checker}: the release pipeline reads release-please's boundary (last-release-sha) from it.\n` + + "Restore it by PR.", + ), ); }); }); From 7e573a684a42e2e690564595116dc6dadea85018 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:24:30 -0500 Subject: [PATCH 3/3] chore(release): say what the sha member of Release holds --- .github/scripts/release-pipeline/anchor.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/scripts/release-pipeline/anchor.ts b/.github/scripts/release-pipeline/anchor.ts index e050ba20..9b4938ae 100644 --- a/.github/scripts/release-pipeline/anchor.ts +++ b/.github/scripts/release-pipeline/anchor.ts @@ -46,6 +46,7 @@ type ReleaseConfig = Record & { "last-release-sha"?: string }; /** A release commit: release-please's own on a release PR branch, its squash merge on main. */ interface Release { + /** Its full sha, as `git log` prints %H. */ sha: string; /** The X.Y.Z its subject names. */