Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1262bb8
fix(release): record the head each regeneration discards
lamemustafa Sep 10, 2026
d3bc069
fix(release): make an interrupted rewrite record recoverable
lamemustafa Sep 10, 2026
bba8115
fix(release): read every page, and refuse a list entry that cannot be…
lamemustafa Sep 11, 2026
1137278
refactor(release): keep who may write a marker in one place
lamemustafa Sep 11, 2026
80adf4a
fix(release): refuse three readings that let a rewrite go unrecorded
lamemustafa Sep 11, 2026
261f140
test(release): pin the three readings a rewrite record depends on
lamemustafa Sep 11, 2026
1f5d4c5
fix(release): name the right pull request, and the head the rewrite c…
lamemustafa Sep 12, 2026
2907570
test(release): pin which pull request, and which head, a record names
lamemustafa Sep 12, 2026
4dc1e4e
fix(release): retain rewrite marker identity
lamemustafa Sep 12, 2026
c823124
fix(release): compare generated branch rewrites
lamemustafa Sep 12, 2026
94d5cd7
fix(release): fail closed on ambiguous rewrites
lamemustafa Sep 12, 2026
abb1736
fix(release): preserve original rewrite heads
lamemustafa Sep 12, 2026
c6aaeff
fix(release): close rewrites from CAS receipts
lamemustafa Sep 12, 2026
cac4f6a
merge: integrate master workflow gate fix
lamemustafa Sep 12, 2026
fa6d564
fix(release): guard first generated branch update
lamemustafa Sep 12, 2026
a07395a
fix(release): fail no-release regeneration errors
lamemustafa Sep 12, 2026
2ec72e7
fix(release): close confirmed rewrite receipts
lamemustafa Sep 12, 2026
4bf1130
fix(release): hold unrecorded retained rewrites
lamemustafa Sep 12, 2026
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
13 changes: 13 additions & 0 deletions scripts/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ Verified against actual file contents and `.github/workflows/*.yml` on 2026-07-0
- `check-pr-review-gate.mjs` — polls `gh` for PR review state, optionally requiring a
named reviewer/strict head review; run by review-gate.yml and locally via
`pnpm review:gate`.
- `publish-review-gate-check.mjs` — publishes the `Review gate (scheduled)` check on
every open PR head, carrying durable review state forward across force-pushes
(review-gate-reconcile.yml).

### Local-only (not invoked by any workflow)

Expand All @@ -45,6 +48,16 @@ Verified against actual file contents and `.github/workflows/*.yml` on 2026-07-0

### Supporting library

- `lib/codex-review-markers.mjs` — the pattern Codex writes on a clean top-level
review, and the reviewed commit it names. Read by `check-pr-review-gate.mjs` (as a
current-head review) and `publish-review-gate-check.mjs` (as a head this PR had); one
definition because two copies disagreeing about which commits were reviewed is the
discontinuity the gate exists to detect.
- `lib/release-branch-rewrite.mjs` — the marker a generated-branch regeneration writes
to record the head it discarded, which GitHub omits (`before_commit_id` is null).
Written by `run-release-please.mjs`, read by `publish-review-gate-check.mjs`. It
supplies a name, not permission: the named commit is still searched, and only a
marker written by `github-actions[bot]` counts.
- `lib/live-run-evidence-redaction-patterns.mjs` (+ `.d.mts`) and
`lib/live-run-evidence-redaction.ts` / `live-run-evidence-types.ts` / `live-run-evidence.ts`
— shared redaction pattern definitions and types used by the live-evidence and
Expand Down
69 changes: 69 additions & 0 deletions scripts/lib/release-branch-rewrite.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// The record a generated-branch rewrite leaves behind so review continuity survives it.
//
// GitHub omits `before_commit_id` on the force-pushes Release Please performs, so the head a
// regeneration discards is named nowhere in the pull request's record: an ordinary push that was
// later rewritten away leaves no timeline entry, and a head that was never reviewed is named by no
// review either. The review gate therefore cannot prove that a finding recorded only on that head
// was carried forward, and refuses.
//
// The workflow that performs the rewrite is ours, so it can record what GitHub does not. This is
// the marker it writes, and the reader both sides share.
//
// It supplies evidence; it does not waive anything. A marker names a commit, and the gate still
// searches that commit for state belonging to that pull request. A marker naming a head with no
// state leaves the gate exactly as unconvinced as it was before.
//
// A record is written in two stages because the rewrite is not atomic with the recording of it.
// The `before` head is written first, while it is still the branch head; the `after` head is added
// once the rewrite has produced one. A record stopped in between names a discarded head and no
// replacement. The gate ignores it until a successful compare-and-swap records the created head;
// timeline events alone cannot safely attribute a later head to this marker.
const MARKER_PATTERN =
/<!--\s*review-gate-rewrite\s+branch=(\S+)\s+before=([0-9a-f]{40})(?:\s+after=([0-9a-f]{40}))?\s*-->/iu;

const SHA_PATTERN = /^[0-9a-f]{40}$/iu;

export function formatReleaseBranchRewriteMarker({ branch, before, after = null }) {
if (!branch || /\s/u.test(branch)) {
throw new Error("A release branch rewrite marker needs a whitespace-free branch name.");
}
if (!SHA_PATTERN.test(before ?? "")) {
throw new Error("A release branch rewrite marker needs a full discarded-head SHA.");
}
if (after !== null && !SHA_PATTERN.test(after)) {
throw new Error("A release branch rewrite marker needs a full created-head SHA or none.");
}
const suffix = after === null ? "" : ` after=${after.toLowerCase()}`;
return `<!-- review-gate-rewrite branch=${branch} before=${before.toLowerCase()}${suffix} -->`;
}

// Only the workflow that performs a rewrite can attest to what it discarded. Anyone who can
// comment can write the marker text, so the author is the whole of its authority: no human can post
// under this login. It lives beside the parser because a parser that cannot check authorship and an
// authority check kept somewhere else is how one of the two readers came to skip it.
const TRUSTED_REWRITE_RECORDER = "github-actions";

export function normaliseGithubLogin(login) {
return String(login ?? "")
.toLowerCase()
.replace(/\[bot\]$/u, "");
}

// A marker is evidence only from this author. Both readers -- the workflow that writes records and
// the gate that reads them -- ask this same question of a comment before trusting its marker.
export function isTrustedRewriteRecord(comment) {
return normaliseGithubLogin(comment?.user?.login) === TRUSTED_REWRITE_RECORDER;
}

// Returns `{ branch, before, after }` for a comment that carries the marker, or null. `after` is
// null for a record whose rewrite had not produced a head yet. This reads the text only: pair it
// with `isTrustedRewriteRecord` before treating a marker as evidence or as recoverable state.
export function readReleaseBranchRewriteMarker(body) {
const match = MARKER_PATTERN.exec(body ?? "");
if (!match) return null;
return {
branch: match[1],
before: match[2].toLowerCase(),
after: match[3] ? match[3].toLowerCase() : null,
};
}
74 changes: 55 additions & 19 deletions scripts/publish-review-gate-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import {
runGhText,
} from "./lib/github-cli-retry.mjs";
import { readCleanTopLevelReviewCommit } from "./lib/codex-review-markers.mjs";
import {
isTrustedRewriteRecord,
normaliseGithubLogin,
readReleaseBranchRewriteMarker,
} from "./lib/release-branch-rewrite.mjs";

const CHECK_RUN_NAME = "Review gate (scheduled)";
const REQUIRED_REVIEW_AUTHOR = "chatgpt-codex-connector";
Expand Down Expand Up @@ -232,11 +237,14 @@ function untraceableRewriteError() {
}

function loadLatestDurableReviewState(pr) {
// Read once and shared: both the rewrite records and the clean top-level review markers live in
// this one list, and fetching it twice was a duplicate of a fact the pull request states once.
const comments = loadIssueComments(pr.number);
const {
priorHeads: forcePushedPriorShas,
hasUntraceableRewrite,
unidentifiedDiscardAt,
} = loadForcePushedPriorShas(pr.number);
} = loadForcePushedPriorShas(pr.number, comments);
// Reject before consulting any reachable state, not after. A state surviving on a current-line
// 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
Expand All @@ -260,7 +268,7 @@ function loadLatestDurableReviewState(pr) {
const priorHeads = dedupePriorHeadShas(
forcePushedPriorShas,
loadReviewedHeadShas(pr.number),
loadTopLevelReviewedHeadShas(pr.number, currentPrShas, forcePushedPriorShas),
loadTopLevelReviewedHeadShas(comments, currentPrShas, forcePushedPriorShas),
);
const discardedLineShas = loadDiscardedLineShas(pr, priorHeads, new Set(currentPrShas));

Expand Down Expand Up @@ -345,17 +353,11 @@ function loadReviewedHeadShas(prNumber) {
// it names was therefore a head of this pull request, so it belongs among the candidate heads --
// otherwise a head reviewed clean and then rewritten away is named in the record but never
// searched.
function loadTopLevelReviewedHeadShas(prNumber, currentPrShas, forcePushedPriorHeads) {
const commentPages = JSON.parse(
runGithub(
["api", "--paginate", "--slurp", `repos/${repo}/issues/${prNumber}/comments?per_page=100`],
"reviewed head marker discovery",
),
);
function loadTopLevelReviewedHeadShas(comments, currentPrShas, forcePushedPriorHeads) {
const known = [...currentPrShas, ...forcePushedPriorHeads.map((head) => head.sha)];
const prefixes = new Set();
for (const comment of flattenPages(commentPages)) {
if (normaliseLogin(comment?.user?.login) !== REQUIRED_REVIEW_AUTHOR) continue;
for (const comment of comments) {
if (normaliseGithubLogin(comment?.user?.login) !== REQUIRED_REVIEW_AUTHOR) continue;
const prefix = readCleanTopLevelReviewCommit(comment.body);
// A marker naming a head already in hand needs no lookup, and the common case is the current
// head naming itself.
Expand Down Expand Up @@ -384,12 +386,6 @@ function resolveCommitSha(prefix) {
return commit.sha;
}

function normaliseLogin(login) {
return String(login ?? "")
.toLowerCase()
.replace(/\[bot\]$/u, "");
}

function cleanReviewState(prNumber) {
return JSON.stringify({ version: 1, prNumber, findings: [] });
}
Expand Down Expand Up @@ -560,7 +556,45 @@ function durableReviewStateBelongsToPr(state, expectedPrNumber) {
return parsed?.version === 1 && parsed.prNumber === expectedPrNumber;
}

function loadForcePushedPriorShas(prNumber) {
// Discarded heads a rewrite recorded out-of-band, keyed by the head it created. GitHub omits
// `before_commit_id` for a generated-branch regeneration, and the workflow performing it records
// the pair instead (#350). This supplies the missing name; it does not waive anything, because a
// head named this way is still searched for state belonging to this pull request.
function loadIssueComments(prNumber) {
return flattenPages(
JSON.parse(
runGithub(
["api", "--paginate", "--slurp", `repos/${repo}/issues/${prNumber}/comments?per_page=100`],
"pull request comment discovery",
),
),
);
}

function loadRecordedRewriteDiscards(comments) {
const discards = new Map();
for (const comment of comments) {
if (!isTrustedRewriteRecord(comment)) continue;
const marker = readReleaseBranchRewriteMarker(comment.body);
// An open record names a discarded head and no replacement, so it pairs with no rewrite and
// identifies nothing. A record whose heads match says the branch did not move, which is a
// recorded non-event rather than a discard.
if (!marker || marker.after === null || marker.after === marker.before) continue;
// A second marker for the same created head is two different claims about one rewrite, and
// there is no basis for preferring either.
if (discards.has(marker.after) && discards.get(marker.after) !== marker.before) {
throw durableStateRejection(
"a rewrite was recorded twice with different discarded heads, so which commit it discarded is not settled",
"conflicting recorded rewrite discards for one created head",
);
}
discards.set(marker.after, marker.before);
}
return discards;
}

function loadForcePushedPriorShas(prNumber, comments) {
const recordedDiscards = loadRecordedRewriteDiscards(comments);
const timelinePages = JSON.parse(
runGithub(
[
Expand Down Expand Up @@ -607,7 +641,9 @@ function loadForcePushedPriorShas(prNumber) {
// leaves no timeline entry at all. Recording when that happened is what lets state older than
// it be recognised as possibly superseded.
if (!/^[0-9a-f]{40}$/iu.test(event.before_commit_id ?? "")) {
unidentifiedDiscardAt = Math.max(unidentifiedDiscardAt ?? -Infinity, createdAt);
const recorded = recordedDiscards.get(String(event.commit_id ?? "").toLowerCase());
if (recorded) shas.push(recorded);
else unidentifiedDiscardAt = Math.max(unidentifiedDiscardAt ?? -Infinity, createdAt);
}
rewrites.push({ createdAt, shas });
}
Expand Down
51 changes: 51 additions & 0 deletions scripts/run-release-please.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,54 @@ export function resolveReleaseTargetBranch(
): string;

export function serializeGitHubOutput(outputs: Record<string, string>): string;

interface ReleaseBranchScope {
env: NodeJS.ProcessEnv | Record<string, string | undefined>;
owner: string;
repo: string;
targetBranch: string;
}

/** The heads and durable marker identities opened for this regeneration. */
interface OpenedRewriteRecords extends ReleaseBranchScope {
headsBeforeRegeneration: Map<
string,
{ head: string; recordId: number; pullRequestNumber: number }
>;
}

export function openBranchRewriteRecords(
options: ReleaseBranchScope,
): Promise<Map<string, { head: string; recordId: number; pullRequestNumber: number }>>;

/** `false` when it could not confirm what the regeneration is about to discard. */
export function refreshBranchRewriteRecords(options: OpenedRewriteRecords): Promise<boolean>;

export function closeBranchRewriteRecords(options: {
env: ReleaseBranchScope["env"];
owner: string;
repo: string;
confirmedRewrites: Map<
string,
{ record: { id: number; marker: { branch: string; before: string } }; after: string }
>;
}): Promise<unknown[]>;

export function withReleaseBranchRewriteCas<T>(
github: {
repository: { owner: string; repo: string };
graphql: (query: string, variables: Record<string, unknown>) => Promise<unknown>;
octokit: {
git: {
createRef: (request: Record<string, unknown>) => Promise<unknown>;
updateRef: (request: Record<string, unknown>) => Promise<unknown>;
};
};
},
expected: OpenedRewriteRecords["headsBeforeRegeneration"],
confirmedRewrites: Map<
string,
{ record: { id: number; marker: { branch: string; before: string } }; after: string }
>,
operation: () => Promise<T>,
): Promise<T>;
Loading
Loading