Skip to content
Merged
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
4 changes: 3 additions & 1 deletion .github/scripts/enforce-pr-target.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,9 @@ describe("enforce-pr-target workflow", () => {

it("writes exactly one consolidated comment via a single upsert helper", () => {
assert.match(workflow, /GATE_MARKER,/);
assert.match(workflow, /comment\.body\?\.includes\(GATE_MARKER\)/);
// The gate comment is the bot comment that starts with GATE_MARKER; a
// marker echoed inside another comment does not classify it.
assert.match(workflow, /comments\.find\(isGateComment\)/);
assert.match(workflow, /upsertGateComment/);
assert.match(workflow, /buildGateCommentBody/);
// No legacy two-comment write path remains.
Expand Down
110 changes: 110 additions & 0 deletions .github/scripts/pr-hygiene.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,109 @@ const HYGIENE_GATE_LABELS = [
"attribution-approved",
];

/**
* Exception approvals vouch for one revision of the change. Each counts only
* while the head the hygiene workflow last recorded is still the head being
* evaluated and nothing has rewritten the branch since. Sponsorship
* (`maintainer-sponsored`) is about which surfaces change, not about one
* revision, so it is not head-bound.
*/
const HEAD_BOUND_APPROVAL_LABELS = [
"test-exception-approved",
"suppression-approved",
"generated-change-approved",
"dependency-change-approved",
"attribution-approved",
];

/** Issue events after which the head can point at an earlier commit again. */
const HEAD_REWRITE_EVENTS = ["head_ref_force_pushed", "head_ref_restored"];

const APPROVAL_RECORD_TOKEN = "pr-hygiene-approvals";
const APPROVAL_RECORD_LINE = /^<!-- pr-hygiene-approvals (\{.*\}) -->$/;
const FULL_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;

function isRewriteCount(value) {
return Number.isSafeInteger(value) && value >= 0;
}

/**
* Read the head binding the hygiene workflow wrote as the last line of its
* own section. The token must occur exactly once in `source` and sit on that
* last line, so echoed text (a changed path can contain newlines) can only
* make the record unreadable, never stand in for it.
* @returns {{ head: string, headRewrites: number | null } | null}
*/
function parseApprovalRecord(source) {
if (typeof source !== "string") return null;
if (source.split(APPROVAL_RECORD_TOKEN).length !== 2) return null;
const lines = source.split("\n").map((line) => line.trim()).filter(Boolean);
const match = lines.at(-1)?.match(APPROVAL_RECORD_LINE);
if (!match) return null;
let value;
try {
value = JSON.parse(match[1]);
} catch {
return null;
}
if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
if (Object.keys(value).sort().join(",") !== "head,headRewrites") return null;
if (typeof value.head !== "string" || !FULL_SHA.test(value.head)) return null;
if (value.headRewrites !== null && !isRewriteCount(value.headRewrites)) return null;
return { head: value.head, headRewrites: value.headRewrites };
}

/** `headRewrites` is null when the history read failed: nothing is confirmed. */
function formatApprovalRecord({ head, headRewrites }) {
const value = { head, headRewrites: isRewriteCount(headRewrites) ? headRewrites : null };
return "<!-- " + APPROVAL_RECORD_TOKEN + " " + JSON.stringify(value) + " -->";
}

function countHeadRewrites(events) {
return (events ?? []).filter((entry) => HEAD_REWRITE_EVENTS.includes(entry?.event)).length;
}

function presentHeadBoundApprovals(labels) {
return HEAD_BOUND_APPROVAL_LABELS.filter((label) => labels.includes(label));
}

/** True when a current rewrite count could still confirm an approval. */
function needsHeadRewriteCount({ labels = [], headSha, record = null, eventAction }) {
return (
eventAction !== "synchronize" &&
record !== null &&
record.head === headSha &&
isRewriteCount(record.headRewrites) &&
presentHeadBoundApprovals(labels).length > 0
);
}

/**
* Split the head-bound approvals on the pull request into the ones that still
* describe the evaluated head and the ones that do not. They are kept only
* when the last recorded evaluation saw this same head and the rewrite count,
* read before that evaluation's snapshot and again after this one's, has not
* changed. Without a rewrite, fast-forward pushes cannot bring the recorded
* head back, so the head has not moved since that evaluation, which removed
* every approval it could not confirm. A synchronize event, a missing record,
* or an unknown count confirms nothing.
* @returns {{ kept: string[], stale: string[] }}
*/
function reconcileHeadBoundApprovals({
labels = [],
headSha,
record = null,
eventAction,
headRewriteCount = null,
}) {
const present = presentHeadBoundApprovals(labels);
const current =
needsHeadRewriteCount({ labels, headSha, record, eventAction }) &&
isRewriteCount(headRewriteCount) &&
headRewriteCount === record.headRewrites;
return current ? { kept: present, stale: [] } : { kept: [], stale: present };
}

/**
* Combine patch-hygiene and sponsored-surface failures into one list so the
* hygiene workflow and the PR quality gate cannot disagree about Ready.
Expand Down Expand Up @@ -340,13 +443,20 @@ module.exports = {
addedLines,
assessHygiene,
collectDeterministicHygieneFailures,
countHeadRewrites,
formatApprovalRecord,
HEAD_BOUND_APPROVAL_LABELS,
HEAD_REWRITE_EVENTS,
HYGIENE_FAILURE_HINTS,
HYGIENE_GATE_LABELS,
hasEmptyCatch,
hasDeletions,
isBehaviorPath,
isGeneratedPath,
isTestPath,
needsHeadRewriteCount,
parseApprovalRecord,
reconcileHeadBoundApprovals,
resultLines,
resultLinesByHunk,
};
103 changes: 103 additions & 0 deletions .github/scripts/pr-hygiene.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,15 @@ const {
addedLines,
assessHygiene,
collectDeterministicHygieneFailures,
countHeadRewrites,
formatApprovalRecord,
HEAD_BOUND_APPROVAL_LABELS,
HYGIENE_FAILURE_HINTS,
HYGIENE_GATE_LABELS,
hasEmptyCatch,
needsHeadRewriteCount,
parseApprovalRecord,
reconcileHeadBoundApprovals,
resultLines,
resultLinesByHunk,
} = require("./pr-hygiene.cjs");
Expand Down Expand Up @@ -391,3 +397,100 @@ describe("pr-hygiene workflow trust boundary", () => {
);
});
});

describe("head-bound approvals", () => {
const HEAD = "c".repeat(40);
const OTHER = "d".repeat(40);
const record = (head, headRewrites) =>
"<!-- pr-hygiene-approvals " + JSON.stringify({ head, headRewrites }) + " -->";

it("covers exactly the exception approvals, not sponsorship", () => {
assert.deepEqual(
[...HEAD_BOUND_APPROVAL_LABELS].sort(),
HYGIENE_GATE_LABELS.filter(
(label) => label !== "intake: hygiene-blocked" && label !== "maintainer-sponsored",
).sort(),
);
});

it("round-trips a record, including an unknown rewrite count", () => {
const line = formatApprovalRecord({ head: HEAD, headRewrites: 2 });
assert.equal(line, record(HEAD, 2));
assert.deepEqual(parseApprovalRecord("status\n\n" + line), { head: HEAD, headRewrites: 2 });
assert.deepEqual(parseApprovalRecord(formatApprovalRecord({ head: HEAD, headRewrites: undefined })), {
head: HEAD,
headRewrites: null,
});
assert.deepEqual(parseApprovalRecord(record("e".repeat(64), 0)), { head: "e".repeat(64), headRewrites: 0 });
});

it("rejects anything but one well-formed record on the last line", () => {
for (const source of [
null,
"",
record(HEAD, 0) + "\nlater line",
record(HEAD, 0) + "\n" + record(HEAD, 0),
"mentions pr-hygiene-approvals elsewhere\n" + record(HEAD, 0),
"<!-- pr-hygiene-approvals {not json} -->",
"<!-- pr-hygiene-approvals " + JSON.stringify({ head: HEAD, headRewrites: 0, approvals: [] }) + " -->",
"<!-- pr-hygiene-approvals " + JSON.stringify({ head: HEAD }) + " -->",
"<!-- pr-hygiene-approvals [1] -->",
record("abc", 0),
record(HEAD.toUpperCase(), 0),
record(HEAD, -1),
record(HEAD, 1.5),
record(HEAD, "1"),
]) {
assert.equal(parseApprovalRecord(source), null, String(source));
}
});

it("counts only head rewrite events", () => {
assert.equal(
countHeadRewrites([
{ event: "head_ref_force_pushed" },
{ event: "labeled" },
{ event: "head_ref_restored" },
null,
]),
2,
);
assert.equal(countHeadRewrites(undefined), 0);
});

it("keeps approvals only under an unchanged head and rewrite count", () => {
const labels = ["test-exception-approved", "maintainer-sponsored"];
const base = { labels, headSha: HEAD, record: { head: HEAD, headRewrites: 1 }, eventAction: "edited" };
const kept = { kept: ["test-exception-approved"], stale: [] };
const stale = { kept: [], stale: ["test-exception-approved"] };
assert.deepEqual(reconcileHeadBoundApprovals({ ...base, headRewriteCount: 1 }), kept);
assert.deepEqual(reconcileHeadBoundApprovals({ ...base, headRewriteCount: 2 }), stale);
assert.deepEqual(reconcileHeadBoundApprovals({ ...base, headRewriteCount: null }), stale);
assert.deepEqual(reconcileHeadBoundApprovals({ ...base, headRewriteCount: 1, eventAction: "synchronize" }), stale);
assert.deepEqual(reconcileHeadBoundApprovals({ ...base, headRewriteCount: 1, headSha: OTHER }), stale);
assert.deepEqual(reconcileHeadBoundApprovals({ ...base, headRewriteCount: 1, record: null }), stale);
assert.deepEqual(
reconcileHeadBoundApprovals({ ...base, headRewriteCount: 1, record: { head: HEAD, headRewrites: null } }),
stale,
);
assert.deepEqual(
reconcileHeadBoundApprovals({ ...base, labels: ["maintainer-sponsored"], headRewriteCount: 1 }),
{ kept: [], stale: [] },
);
});

it("asks for a rewrite count only when one could confirm an approval", () => {
const base = {
labels: ["suppression-approved"],
headSha: HEAD,
record: { head: HEAD, headRewrites: 0 },
eventAction: "labeled",
};
assert.equal(needsHeadRewriteCount(base), true);
assert.equal(needsHeadRewriteCount({ ...base, labels: [] }), false);
assert.equal(needsHeadRewriteCount({ ...base, eventAction: "synchronize" }), false);
assert.equal(needsHeadRewriteCount({ ...base, headSha: OTHER }), false);
assert.equal(needsHeadRewriteCount({ ...base, record: null }), false);
assert.equal(needsHeadRewriteCount({ ...base, record: { head: HEAD, headRewrites: null } }), false);
});
});
84 changes: 83 additions & 1 deletion .github/scripts/pr-quality-messages.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ const HYGIENE_BLOCK_RE = new RegExp(
`^[ \\t]*${HYGIENE_BLOCK_START}[ \\t]*\\n([\\s\\S]*?)\\n[ \\t]*${HYGIENE_BLOCK_END}[ \\t]*$`,
"m"
);
/**
* Whole-line delimiter matchers, the same shape HYGIENE_BLOCK_RE anchors on.
* A block is only read when each delimiter occurs on exactly one line: a
* changed filename can contain newlines, so an echoed path can add delimiter
* lines, and an ambiguous block must never be cut at the first one.
*/
const HYGIENE_BLOCK_START_LINE_RE = new RegExp(`^[ \\t]*${HYGIENE_BLOCK_START}[ \\t]*$`, "gm");
const HYGIENE_BLOCK_END_LINE_RE = new RegExp(`^[ \\t]*${HYGIENE_BLOCK_END}[ \\t]*$`, "gm");

function countDelimiterLines(text, pattern) {
return (text.match(pattern) ?? []).length;
}

function inlineCode(value) {
const text = String(value);
Expand All @@ -41,6 +53,37 @@ function inlineCode(value) {
return `${delimiter}${text}${delimiter}`;
}

/**
* Render contributor-supplied text (a changed path) on one line: control
* characters, including newlines, become `\uXXXX` escapes. Without this an
* echoed path could add whole lines, such as delimiter or record lines, to a
* bot comment.
*/
function escapeControlCharacters(value) {
return String(value).replace(
/[\u0000-\u001f\u007f\u2028\u2029]/g,
character => "\\u" + character.charCodeAt(0).toString(16).padStart(4, "0")
);
}

/** The consolidated gate comment: written by the workflow bot, marker first. */
function isGateComment(comment) {
return (
comment?.user?.login === "github-actions[bot]" &&
typeof comment.body === "string" &&
comment.body.startsWith(GATE_MARKER)
);
}

/** The standalone hygiene comment posted before a gate comment exists. */
function isStandaloneHygieneComment(comment) {
return (
comment?.user?.login === "github-actions[bot]" &&
typeof comment.body === "string" &&
comment.body.startsWith(HYGIENE_MARKER)
);
}

function readinessChecklistLines(readiness) {
return REVIEW_READINESS_ITEMS.map(
(item, index) =>
Expand Down Expand Up @@ -121,9 +164,17 @@ function buildGateCommentBody(state, opts) {
* `null` when the comment has none. The gate rebuilds its body from scratch
* every run, so without this round-trip a hygiene update from the separate
* hygiene workflow would be silently dropped on the next gate write.
* An ambiguous block (a delimiter on more than one line) reads as `null`, so
* the gate drops it on rebuild instead of re-wrapping a truncated section.
*/
function extractHygieneSection(body) {
if (typeof body !== "string") return null;
if (
countDelimiterLines(body, HYGIENE_BLOCK_START_LINE_RE) !== 1 ||
countDelimiterLines(body, HYGIENE_BLOCK_END_LINE_RE) !== 1
) {
return null;
}
const match = body.match(HYGIENE_BLOCK_RE);
if (!match) return null;
return match[1]
Expand All @@ -150,13 +201,40 @@ function withHygieneSection(body, hygieneLines) {
].join("\n");

if (HYGIENE_BLOCK_RE.test(base)) {
return base.replace(HYGIENE_BLOCK_RE, block);
// A callback, not a replacement string: echoed paths can contain $&, $1
// and similar tokens that a template would expand into extra lines.
return base.replace(HYGIENE_BLOCK_RE, () => block);
}

// No existing block: append one at the end.
return `${base.replace(/\s+$/, "")}\n\n## Hygiene\n\n${block}\n`;
}

/**
* The text the hygiene workflow last wrote for this pull request: the hygiene
* block of the first bot comment that starts with GATE_MARKER, or the first
* bot comment that starts with HYGIENE_MARKER while that gate comment has no
* block yet (the gate never absorbs it). A marker elsewhere in a comment does
* not classify it. An ambiguous gate block yields `null`; later comments are
* never searched for a more convenient candidate.
*/
function authoritativeHygieneSection(comments) {
const list = comments ?? [];
const gate = list.find(isGateComment);
if (gate) {
const section = extractHygieneSection(gate.body);
if (section !== null) return section;
if (
countDelimiterLines(gate.body, HYGIENE_BLOCK_START_LINE_RE) > 0 ||
countDelimiterLines(gate.body, HYGIENE_BLOCK_END_LINE_RE) > 0
) {
return null;
}
}
const standalone = list.find(isStandaloneHygieneComment);
return standalone ? standalone.body : null;
}

function descriptionFailureLines(reason) {
switch (reason) {
case "empty":
Expand Down Expand Up @@ -323,10 +401,14 @@ module.exports = {
HYGIENE_BLOCK_START,
HYGIENE_BLOCK_END,
inlineCode,
escapeControlCharacters,
isGateComment,
isStandaloneHygieneComment,
readinessChecklistLines,
buildGateCommentBody,
extractHygieneSection,
withHygieneSection,
authoritativeHygieneSection,
descriptionFailureLines,
buildFailureSections,
failureSummary,
Expand Down
Loading
Loading