Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion scripts/publish-review-gate-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Verify the rewrite actor instead of the PR creator

pr.user.type identifies who originally opened the pull request, not who performed a later force-push. Once Release Please creates this PR, a maintainer or unrelated bot can rewrite its generated-named branch and this predicate still grants the exemption; when GitHub omits before_commit_id, the gate can then seed from the rewritten history and lose an orphaned durable finding. Authenticate the rewrite as trusted Release Please automation rather than relying on the PR creator's persistent type.

AGENTS.md reference: AGENTS.md:L90-L92

Useful? React with 👍 / 👎.

return headRef.startsWith(`release-please--branches--${baseRef}--components--`);
}

function loadLatestDurableReviewState(pr) {
const { priorHeads: forcePushedPriorShas, hasUntraceableRewrite } = loadForcePushedPriorShas(
pr.number,
Expand All @@ -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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve findings across legitimate release regeneration

Even when Release Please legitimately performs this rewrite, exempting it discards the exact state the continuity guard protects: if the scheduled check persisted an open finding on the release PR and that comment is subsequently deleted, the null before_commit_id leaves the old check unreachable, this branch proceeds using only the regenerated history, and loadLatestDurableReviewState can seed an empty state and publish success. Regeneration does not prove the prior ask was fixed, stale, or otherwise dispositioned, so generated PRs need a continuity mechanism rather than bypassing the terminal rejection.

AGENTS.md reference: AGENTS.md:L94-L97

Useful? React with 👍 / 👎.

// 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];
Expand Down
82 changes: 80 additions & 2 deletions tests/scripts/publish-review-gate-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<string, unknown> = {}) {
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: [] });
}
Expand Down
Loading