fix(ci): trust only gate-authored BLOCK/fix-request comments, keep held-out PRs reachable

- latestMergeGateVerdictComment: ignore verdicts not posted by gate automation
  (anyone could forge MERGE_GATE_VERDICT: BLOCK and hold a PR out for 24h).
- blockFixRequestDue: count only maintainer/github-actions comments carrying
  the exact HTML-comment marker, so forged markers cannot burn the cap.
- listPrNumbersForMergeGateScan: also scan pipeline/blocked:merge-gate PRs;
  label sync swaps merge-ready for that label, which hid them from fix requests.
- Targeted dispatch path now carries head_sha/block_fix_due.
- Fix-request step re-evaluates the PR (HEAD unchanged, still blocked) before
  posting and skips with a warning when GH_TOKEN is missing instead of posting
  an inert request that consumes the per-HEAD slot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
MervinPraisonandClaude Opus 5.5 committed 2026-10-13 14:12:50 +00:00
1 parent 11e2927367
commit e65d1793c7
3 files changed
+63 -27

No files matched your search

+21
View File
@@ -138,6 +138,14 @@ assert('PR #5772 shape: still stale if HEAD moves after the review',
const other = (s) => ({ user: { login: 'MervinPraison' }, body: mg.buildBlockFixRequestBody(s), created_at: isoAt(now) });
assert('fix request due again on a new HEAD', mg.blockFixRequestDue([posted], 'f'.repeat(40)));
assert('fix requests capped per PR', !mg.blockFixRequestDue([other('1'.repeat(40)), other('2'.repeat(40)), other('3'.repeat(40))], 'f'.repeat(40)));
// Untrusted commenters cannot forge gate state.
const forgedBlock = { ...block, user: { login: 'random-user' } };
assert('BLOCK from an untrusted commenter is ignored', !mg.blockedOnUnchangedHead([forgedBlock], head, oldChecks, now));
const forgedMarker = { ...posted, user: { login: 'random-user' } };
assert('fix-request marker from an untrusted commenter is ignored', mg.blockFixRequestDue([forgedMarker], sha));
const quoted = { ...posted, body: 'see ' + mg.BLOCK_FIX_MARKER + sha.slice(0, 12) };
assert('bare marker text (not the HTML comment) is ignored', mg.blockFixRequestDue([quoted], sha));
}
assert('cancelled detect-and-trigger does not block', mg.OPTIONAL_CANCELLED_CHECKS.has('detect-and-trigger'));
@@ -682,6 +690,19 @@ assert(
);
assert('rate limit low skips dispatch', skipLow === true);
const byLabel = {
'pipeline/merge-ready': [{ number: 5803, pull_request: {}, labels: [] }],
'pipeline/blocked:merge-gate': [
{ number: 5766, pull_request: {}, labels: [] },
{ number: 5803, pull_request: {}, labels: [] },
],
};
const scanList = await mg.listPrNumbersForMergeGateScan(
{ rest: { issues: { listForRepo: async ({ labels }) => ({ data: byLabel[labels] || [] }) } } },
'owner', 'repo', { info: () => {} }
);
assert('held-out PRs stay in the scan list (deduped)', JSON.stringify(scanList) === '[5766,5803]');
process.exit(failed ? 1 : 0);
})().catch((err) => {
console.error(err);
+23 -21
View File
@@ -1301,11 +1301,15 @@ const BLOCK_RECHECK_AFTER_MS = 24 * 60 * 60 * 1000;
const BLOCK_FIX_MARKER = 'merge-gate-fix-request:';
const MAX_BLOCK_FIX_REQUESTS = 3;
const BLOCKED_ON_HEAD_REASON = 'merge gate blocked this HEAD (waiting for a fix commit)';
const BLOCKED_MERGE_GATE_LABEL = 'pipeline/blocked:merge-gate';
// Only verdicts posted by the gate's own automation may hold a PR out; anyone
// can comment "MERGE_GATE_VERDICT: BLOCK".
function latestMergeGateVerdictComment(comments, headPushedAt) {
const headTime = headPushedAt ? new Date(headPushedAt).getTime() - 60000 : 0;
let latest = null;
for (const c of comments || []) {
if (!isClaudeAutomationLogin(c.user?.login)) continue;
if (!isMergeGateVerdictComment(c.body)) continue;
if (isAutomatedFallbackVerdict(c.body)) continue;
const t = new Date(c.created_at).getTime();
@@ -1343,34 +1347,31 @@ function buildBlockFixRequestBody(headSha) {
].join('\n');
}
/** One fix request per HEAD, at most MAX_BLOCK_FIX_REQUESTS per PR. */
/** One fix request per HEAD, at most MAX_BLOCK_FIX_REQUESTS per PR (trusted posters only). */
function blockFixRequestDue(comments, headSha) {
const requests = (comments || []).filter((c) => (c.body || '').includes(BLOCK_FIX_MARKER));
const requests = (comments || []).filter(
(c) => CLAUDE_TRIGGER_LOGINS.includes(c.user?.login) && (c.body || '').includes(`<!-- ${BLOCK_FIX_MARKER}`)
);
if (requests.length >= MAX_BLOCK_FIX_REQUESTS) return false;
const marker = BLOCK_FIX_MARKER + headSha.slice(0, 12);
const marker = `<!-- ${BLOCK_FIX_MARKER}${headSha.slice(0, 12)} -->`;
return !requests.some((c) => (c.body || '').includes(marker));
}
async function listPrNumbersForMergeGateScan(github, owner, repo, core) {
let mergeReadyIssues;
async function listOpenIssuesWithLabel(github, owner, repo, label) {
const params = { owner, repo, state: 'open', labels: label, per_page: 100 };
if (typeof github.paginate === 'function') {
mergeReadyIssues = await github.paginate(github.rest.issues.listForRepo, {
owner,
repo,
state: 'open',
labels: MERGE_READY_LABEL,
per_page: 100,
});
} else {
({ data: mergeReadyIssues } = await github.rest.issues.listForRepo({
owner,
repo,
state: 'open',
labels: MERGE_READY_LABEL,
per_page: 100,
}));
return github.paginate(github.rest.issues.listForRepo, params);
}
const mergeReady = mergeReadyIssues
return (await github.rest.issues.listForRepo(params)).data;
}
async function listPrNumbersForMergeGateScan(github, owner, repo, core) {
const mergeReadyIssues = await listOpenIssuesWithLabel(github, owner, repo, MERGE_READY_LABEL);
// Label sync swaps merge-ready for the merge-gate blocker on held-out PRs;
// keep scanning them (they never take a slot while held) so the fix request
// is still posted and they re-enter once a fix lands.
const heldOut = await listOpenIssuesWithLabel(github, owner, repo, BLOCKED_MERGE_GATE_LABEL);
const mergeReady = [...mergeReadyIssues, ...heldOut]
.filter((issue) => issue.pull_request)
.filter((issue) => {
const labels = (issue.labels || []).map((l) => (typeof l === 'string' ? l : l.name));
@@ -1383,6 +1384,7 @@ async function listPrNumbersForMergeGateScan(github, owner, repo, core) {
return true;
})
.map((issue) => issue.number)
.filter((n, i, all) => all.indexOf(n) === i)
.sort((a, b) => a - b);
if (mergeReady.length > 0) {
+19 -6
View File
@@ -176,7 +176,12 @@ jobs:
if (result.ready) {
candidates.push({ pr_number: n, head_sha: result.headSha });
} else {
skipped.push({ pr: n, reasons: result.reasons });
skipped.push({
pr: n,
reasons: result.reasons,
head_sha: result.headSha,
block_fix_due: !!result.blockFixDue,
});
}
} else {
const prNumbers = await mergeGate.listPrNumbersForMergeGateScan(
@@ -240,21 +245,29 @@ jobs:
uses: actions/github-script@v7
env:
FIX_REQUESTS: ${{ steps.scan.outputs.fix_requests }}
HAS_GH_TOKEN: ${{ secrets.GH_TOKEN != '' }}
with:
# Posted as the maintainer (like FINAL triggers) so claude.yml accepts it;
# comments from the app bot are filtered out there.
github-token: ${{ secrets.GH_TOKEN != '' && secrets.GH_TOKEN || github.token }}
github-token: ${{ secrets.GH_TOKEN || github.token }}
script: |
// github.token comments cannot start claude.yml, and posting the
// marker anyway would use up this HEAD's request.
if (process.env.HAS_GH_TOKEN !== 'true') {
core.warning('GH_TOKEN secret missing — not posting merge gate fix requests');
return;
}
const path = require('path');
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
const owner = context.repo.owner;
const repo = context.repo.repo;
const requests = JSON.parse(process.env.FIX_REQUESTS || '[]');
for (const { pr, head_sha: headSha } of requests) {
// Re-check against fresh comments so parallel scans cannot double-post.
const comments = await mergeGate.listAllComments(github, owner, repo, pr);
if (!mergeGate.blockFixRequestDue(comments, headSha)) {
core.info(`PR #${pr}: fix request already posted for ${headSha.slice(0, 12)} (or cap reached)`);
// Re-evaluate on fresh state: a commit may have landed since the
// scan, and parallel scans must not double-post.
const fresh = await mergeGate.evaluatePipelineQuiescent(github, owner, repo, pr, core);
if (fresh.headSha !== headSha || !fresh.blockFixDue) {
core.info(`PR #${pr}: fix request no longer due for ${headSha.slice(0, 12)}`);
continue;
}
await github.rest.issues.createComment({