Skip to content

Commit 9647f89

Browse files
committed
fix(ci): merge-queue-triage retries delivery, and a refused post no longer destroys the triage
The triage comment is the machine-readable signal the PM dispatch loop keys on, and the cross-PR flake evidence lives in it — but it was posted through a bare `github-script` call with no retry and no handler, so a transient GitHub API failure killed the job and took the diagnosis with it: `body` exists nowhere but the failed request, and the run page was left showing `Unhandled error: HttpError` and nothing else. Declare the retry policy on the step (`retries: 3`, with 403 removed from the exempt list because GitHub answers a secondary rate limit with 403 as well as 429, and this job paginates jobs, logs and 24 h of runs). Nothing is swallowed: a spent retry still throws, so detection failures fail exactly as before. On a refused delivery, write the whole computed triage into the job summary — where it outlives the request and can be pasted onto the PR — and then fail. Failing is deliberate and diverges from the sibling fix in docs-drift-check.yml (#9373/#9423): this is a `workflow_run` job whose conclusion is on no check list, in no required context and gates nothing, so red costs nothing while a green that quietly delivered nothing recreates the silence one layer further in. That buys the invariant: green ⇔ the triage comment is on the PR. The de-duplication listing degrades to at-least-once instead: its marker is scoped to one run id, so a duplicate is inert while a miss loses the signal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
1 parent e6ee690 commit 9647f89

1 file changed

Lines changed: 120 additions & 6 deletions

File tree

‎.github/workflows/merge-queue-triage.yml‎

Lines changed: 120 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,24 @@ name: Merge Queue Triage
3232
# main before it fires, it never checks out or runs PR code, and it holds the
3333
# minimum permissions (actions: read for logs, pull-requests: write for the
3434
# comment).
35+
#
36+
# Delivery is retried, never assumed (#9424). A transient GitHub API failure used
37+
# to kill this job outright — github-script hands any throw from the script to
38+
# `main().catch(handleError)` → `core.setFailed` — and it took the diagnosis with
39+
# it, because the comment body exists NOWHERE but the failed request. The step now
40+
# retries the transient class (declared in its `retries:` inputs below) and, when
41+
# delivery is refused anyway, writes the whole triage into the run's job summary
42+
# before failing. That leaves one invariant worth keying on:
43+
#
44+
# this job is green ⇔ the triage comment is on the PR
45+
#
46+
# It is deliberately NOT green-on-undelivered, which is what the sibling fix in
47+
# docs-drift-check.yml (#9373) chose. There the job's conclusion is a check on the
48+
# PR and its comment is a courtesy, so a red costs a reader's attention for nothing.
49+
# Here the job is `workflow_run`: its conclusion is on no check list, gates nothing
50+
# and is in no required context, so a red costs nothing — while a green that
51+
# quietly delivered nothing is the same silence this workflow exists to break, one
52+
# layer further in.
3553

3654
on:
3755
workflow_run:
@@ -55,6 +73,25 @@ jobs:
5573
- name: Post the triage comment
5674
uses: actions/github-script@v9
5775
with:
76+
# The transient-retry policy, declared rather than hand-written (#9424):
77+
# octokit's retry plugin re-issues any request whose status is NOT exempt
78+
# below, plus network-level failures. Nothing here is swallowed — a spent
79+
# retry still throws, so this widens no verdict, only the number of times
80+
# the request is asked.
81+
#
82+
# 403 is REMOVED from the action's default exempt list
83+
# (400,401,403,404,422) on purpose: GitHub answers a SECONDARY rate limit
84+
# with 403 as well as with 429, and this job is rate-limit-shaped — it
85+
# paginates the run's jobs, pulls up to four job logs, then paginates 24 h
86+
# of merge_group runs. The price is that a genuine permission denial (a
87+
# wrong `permissions:` block) now takes four attempts to fail instead of
88+
# one. It still fails.
89+
#
90+
# 400/401/404/422 stay exempt: a malformed request, a wrong target, or a
91+
# body past GitHub's 65536-character comment limit is this repo's own bug,
92+
# answered on the first try and not improved by asking again.
93+
retries: 3
94+
retry-exempt-status-codes: 400,401,404,422
5895
script: |
5996
const run = context.payload.workflow_run;
6097
const { owner, repo } = context.repo;
@@ -68,11 +105,44 @@ jobs:
68105
const prNumber = Number(m[1]);
69106
const marker = `<!-- merge-queue-triage:${run.id} -->`;
70107
108+
// `HTTP 503: No server is currently available to service your request`,
109+
// `ECONNRESET: ...` — enough for a reader to tell platform weather from
110+
// a 403 that means the `permissions:` block above is wrong.
111+
const describe = (error) => {
112+
const kind = typeof error?.status === 'number'
113+
? `HTTP ${error.status}`
114+
: (error?.code || 'error');
115+
return `${kind}: ${String(error?.message || '').replace(/\s*\.\s*$/, '')}`;
116+
};
117+
71118
// Idempotency: workflow_run deliveries can repeat; one comment per run.
72-
const existing = await github.rest.issues.listComments({
73-
owner, repo, issue_number: prNumber, per_page: 100,
74-
});
75-
if (existing.data.some((c) => (c.body ?? '').includes(marker))) {
119+
// The marker is scoped to THIS run id, so the only thing this listing can
120+
// prevent is a second copy of this very comment.
121+
//
122+
// When the listing cannot be read at all, this degrades to AT-LEAST-ONCE
123+
// on purpose and posts without knowing. The two mistakes are not
124+
// symmetric here: a duplicate is inert — same run, same text, and the
125+
// shared marker makes the pair self-evident — while a miss is the whole
126+
// defect, since this comment IS the machine-readable signal the PM
127+
// dispatch loop keys on and the cross-PR flake evidence lives in it.
128+
// #9423 chose the opposite for docs-drift-check.yml, and correctly: that
129+
// marker is STABLE across runs, so posting blind there strands a second
130+
// advisory which the dedup then updates forever alongside the first.
131+
let alreadyPosted = false;
132+
try {
133+
const existing = await github.rest.issues.listComments({
134+
owner, repo, issue_number: prNumber, per_page: 100,
135+
});
136+
alreadyPosted = existing.data.some((c) => (c.body ?? '').includes(marker));
137+
} catch (error) {
138+
core.warning(
139+
`Could not read #${prNumber}'s comments to check for an existing triage `
140+
+ `comment (${describe(error)}). Posting anyway: a duplicate triage comment `
141+
+ `is inert, a missing one loses the queue-failure signal.`,
142+
{ title: 'Triage comment de-duplication skipped' },
143+
);
144+
}
145+
if (alreadyPosted) {
76146
core.info('triage comment for this run already exists — skipping.');
77147
return;
78148
}
@@ -159,5 +229,49 @@ jobs:
159229
'_Generated by [Claude Code](https://claude.ai/code) · merge-queue-triage workflow (#4859)_',
160230
].join('\n');
161231
162-
await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body });
163-
core.info(`triage comment posted on #${prNumber}.`);
232+
// Everything above is the DIAGNOSIS; the call below only carries it to
233+
// the PR. The step's `retries:` have already absorbed a blip by the time
234+
// anything lands here, so what is left is a refusal that outlived them —
235+
// and `body` exists nowhere else, so letting it throw (which is what this
236+
// step did until #9424) loses the entire queue-failure triage and leaves
237+
// the run page showing `Unhandled error: HttpError` and nothing more.
238+
//
239+
// So the diagnosis is written where it outlives the request, and THEN the
240+
// job fails. Failing is the point: see the invariant in this file's header
241+
// — green means delivered, and nothing about this workflow_run job's
242+
// conclusion costs anything to anybody. Tolerance is scoped to the retry,
243+
// never to the outcome, and it never reaches detection: a failure above
244+
// this line still fails exactly as it always did.
245+
try {
246+
await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body });
247+
core.info(`triage comment posted on #${prNumber}.`);
248+
} catch (error) {
249+
const reason = describe(error);
250+
try {
251+
await core.summary.addRaw([
252+
`## ⚠️ 队列失败分诊已生成,但没能发到 PR #${prNumber}`,
253+
'',
254+
`\`issues.createComment\` 最终被拒绝:\`${reason}\`。`,
255+
'',
256+
`- 这是**投递**失败,不是分诊失败。下面就是本次运行算出来的完整分诊内容 —— PR #${prNumber} 上没有它。`,
257+
'- 可以直接复制到 PR 上;也可以在 API 恢复后 re-run 本 job,重复投递会被评论里的 marker 挡掉。',
258+
'- 怎么分辨:5xx / 429 / 网络错误码是平台抖动,re-run 即可;4xx(422 正文超长、403 权限)是本仓自己的 bug,re-run 不会变绿,去修它。',
259+
'- 本 job 判红是有意的:这个 workflow 的结论不出现在任何 check 列表上,红的代价是零,而绿会让「信号丢了」跟「信号送到了」长得一模一样。',
260+
'',
261+
'---',
262+
'',
263+
body,
264+
'',
265+
].join('\n')).write();
266+
} catch (summaryError) {
267+
// The summary is the richer channel, the annotation the reliable one.
268+
// Losing the richer one must not restore the silence this prevents.
269+
core.info(`Could not write the job summary: ${summaryError.message}`);
270+
}
271+
core.setFailed(
272+
`The queue-failure triage for PR #${prNumber} was computed but could NOT be `
273+
+ `posted (${reason}). It is reproduced in full in this run's job summary: copy `
274+
+ `it onto the PR, or re-run this job to retry delivery once the API recovers. `
275+
+ `Queue run ${run.id}: ${run.html_url}`,
276+
);
277+
}

0 commit comments

Comments
 (0)