diff --git a/scripts/publish-review-gate-check.mjs b/scripts/publish-review-gate-check.mjs index 62515612..f44bb509 100644 --- a/scripts/publish-review-gate-check.mjs +++ b/scripts/publish-review-gate-check.mjs @@ -222,6 +222,30 @@ function untraceableRewriteError() { ); } +// Release Please regenerates its release branch by force-pushing it, and GitHub records +// those rewrites with no `before_commit_id`, so they are untraceable by the check below. +// +// The continuity check exists so a rewrite cannot replace human-reviewed code without a +// trace. A generated release branch has no such state to protect: the bot rebuilds the +// branch from the base branch on every upstream merge, so a rewrite discards only content +// the next run reproduces. Without this, a release pull request becomes unmergeable as +// soon as anything lands on the base branch, which blocked v0.6.0 entirely (#342). +// +// Deliberately narrow, and evidence-based rather than name-based. A branch name alone is +// not evidence, because anyone who can push may choose one. Both must hold: +// - the head branch is release-please's generated name for *this* pull request's base, +// - the pull request is authored by a bot. +// A human branch named to look generated fails the second condition, and a bot pull +// request from an ordinary branch fails the first. +function isGeneratedReleasePullRequest(pr) { + const baseRef = pr?.base?.ref; + const headRef = pr?.head?.ref; + if (typeof baseRef !== "string" || baseRef.length === 0) return false; + if (typeof headRef !== "string") return false; + if (String(pr?.user?.type ?? "").toLowerCase() !== "bot") return false; + return headRef.startsWith(`release-please--branches--${baseRef}--components--`); +} + function loadLatestDurableReviewState(pr) { const { priorHeads: forcePushedPriorShas, hasUntraceableRewrite } = loadForcePushedPriorShas( pr.number, @@ -230,7 +254,13 @@ function loadLatestDurableReviewState(pr) { // commit cannot contain a finding that was observed and then deleted only on the head this // rewrite discarded, so returning it would publish success while losing that ask. Continuity // across a null `before_commit_id` cannot be proved, so no reachable state is trustworthy here. - if (hasUntraceableRewrite) throw untraceableRewriteError(); + if (hasUntraceableRewrite) { + if (!isGeneratedReleasePullRequest(pr)) throw untraceableRewriteError(); + // Logged, never silent: an exemption nobody can see is one nobody can audit. + console.log( + `Accepting an untraceable rewrite on generated release branch ${pr.head.ref}: its contents are regenerated from ${pr.base.ref} rather than carried across review.`, + ); + } const currentPrShas = loadCurrentPrCommitShas(pr); const currentPrShaSet = new Set(currentPrShas); const pendingShas = [...currentPrShas, ...forcePushedPriorShas]; diff --git a/tests/scripts/publish-review-gate-check.test.ts b/tests/scripts/publish-review-gate-check.test.ts index acaf2d33..0aa7506b 100644 --- a/tests/scripts/publish-review-gate-check.test.ts +++ b/tests/scripts/publish-review-gate-check.test.ts @@ -454,6 +454,63 @@ describe("PR-head Review gate check publisher", () => { expect(publicationText).not.toContain("output[text]"); }); + // release-please regenerates its branch by force-pushing, and GitHub records those + // rewrites with no `before_commit_id`. Failing closed there makes a release pull request + // unmergeable as soon as anything lands on the base branch, which blocked v0.6.0 + // entirely (#342). The branch carries no reviewed history to protect -- the bot rebuilds + // it from the base branch -- so the rewrite is accepted for that case only. + it("accepts an untraceable rewrite on the generated release branch", () => { + const { result, calls } = runScript( + ["--reconcile-open-prs", "--max-prs", "1", "--selection-offset", "0"], + [generatedReleasePull()], + cleanReviewFixture(), + [{ status: 0 }], + cleanDurableState(), + [{ status: 0 }], + null, + [forcePushEvent(null, "2026-08-17T00:00:00Z")], + ); + const publicationText = + calls.find((call) => call.includes("repos/lamemustafa/pack/check-runs"))?.join(" ") ?? ""; + + expect(result.status).toBe(0); + expect(publicationText).toContain("conclusion=success"); + expect(publicationText).not.toContain("GitHub did not record the prior head"); + // The exemption is announced, because one nobody can see is one nobody can audit. + expect(result.stdout).toContain("Accepting an untraceable rewrite on generated release branch"); + }); + + // The exemption is evidence-based, not name-based. Each case below satisfies part of the + // shape and must still be refused, because a branch name is chosen by whoever pushes. + it.each([ + [ + "a human-authored branch wearing the generated name", + generatedReleasePull({ userType: "User" }), + ], + ["a bot pull request from an ordinary branch", pull(1, { userType: "Bot" })], + [ + "a bot branch naming a base this pull request does not target", + generatedReleasePull({ headRef: "release-please--branches--release-1.x--components--pack" }), + ], + ])("still refuses an untraceable rewrite for %s", (_label, pullRequest) => { + const { result, calls } = runScript( + ["--reconcile-open-prs", "--max-prs", "1", "--selection-offset", "0"], + [pullRequest], + cleanReviewFixture(), + [{ status: 0 }], + cleanDurableState(), + [{ status: 0 }], + null, + [forcePushEvent(null, "2026-08-17T00:00:00Z")], + ); + const publicationText = + calls.find((call) => call.includes("repos/lamemustafa/pack/check-runs"))?.join(" ") ?? ""; + + expect(result.status).toBe(0); + expect(publicationText).toContain("conclusion=action_required"); + expect(publicationText).toContain("GitHub did not record the prior head"); + }); + it("never discards an unreachable deleted finding across an untraceable rewrite", () => { const orphanedSha = "b".repeat(40); const { result, calls } = runScript( @@ -744,17 +801,38 @@ else if (text.includes("check-runs")) { function pull( number: number, - { draft = false, state = "open", headRepo = "lamemustafa/pack" } = {}, + { + draft = false, + state = "open", + headRepo = "lamemustafa/pack", + headRef = "tapish-codex/example", + baseRef = "master", + userType = "User", + } = {}, ) { const sha = number === 1 ? headSha : String(number).repeat(40); return { number, state, draft, - head: { sha, repo: { full_name: headRepo } }, + head: { sha, ref: headRef, repo: { full_name: headRepo } }, + base: { ref: baseRef }, + user: { login: userType === "Bot" ? "github-actions[bot]" : "maintainer", type: userType }, }; } +// The shape release-please actually produces, confirmed against #337: +// head.ref release-please--branches--master--components--pack +// user github-actions[bot] (type "Bot") +function generatedReleasePull(overrides: Record = {}) { + return pull(1, { + baseRef: "master", + headRef: "release-please--branches--master--components--pack", + userType: "Bot", + ...overrides, + }); +} + function cleanDurableState(prNumber = 1) { return "review-gate-state/v1\n" + JSON.stringify({ version: 1, prNumber, findings: [] }); }