Skip to content

Commit a365e56

Browse files
os-steveclaude
andcommitted
fix(ci): the cross-repo "close these by hand" notice survives a refused post, and a lost one fails the job
The degraded-path notice in cross-repo-issue-closer.yml was a single unguarded `issues.createComment`. github-script routes any throw to `main().catch(handleError)` -> `core.setFailed`, so a transient answer lost the notice AND the `core.warning` that sat after the `await` — the run said `Unhandled error: HttpError` and nothing about the foreign issues left open. - declare the transient-retry policy on the step (`retries: 3`, `retry-exempt-status-codes: 400,401,404,422`) instead of hand-writing a third copy of the classifier (#9576); - announce the work order BEFORE delivery, naming the targets, not the count; - on a refusal that outlives the retries, write the notice into the job summary and then fail the job — this job's conclusion is in no required set, runs post-merge and has no `workflow_run:` listener, so red costs one X while a green that delivered nothing is indistinguishable from having nothing to say. The guarded per-target loop is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
1 parent 1c6da6e commit a365e56

1 file changed

Lines changed: 130 additions & 11 deletions

File tree

‎.github/workflows/cross-repo-issue-closer.yml‎

Lines changed: 130 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,41 @@
1616
# secret was never provisioned is the shape this repo keeps having to fix
1717
# (#4449: written, tested, exported, called by nothing). Missing credentials
1818
# must announce themselves.
19+
#
20+
# That second mode is a single `issues.createComment`, and its DELIVERY is
21+
# retried, never assumed (#9575). Until #9575 a transient answer from the
22+
# comments endpoint threw, github-script handed the throw to
23+
# `main().catch(handleError)` -> `core.setFailed`, and the run lost BOTH the
24+
# notice AND — because it sat after the `await` — the `core.warning` that named
25+
# what had been left open. All that remained was `Unhandled error: HttpError` on
26+
# a job nobody opens, and a set of foreign issues no longer on anyone's list. So
27+
# now: the transient class is retried (declared in the step's `retries:` inputs,
28+
# not hand-classified), the work order is announced BEFORE delivery is
29+
# attempted, and a refusal that outlives the retries writes the notice into the
30+
# run's job summary and then FAILS the job.
31+
#
32+
# Failing is deliberate, and it is the opposite of what docs-drift-check.yml
33+
# (#9373) chose for its advisory comment. The difference is a property of this
34+
# job, measured rather than inherited:
35+
#
36+
# - its conclusion is in no required set — the context name `Close issues
37+
# referenced in other repositories` is absent from the registry in
38+
# scripts/check-required-contexts.mjs, and could not be enrolled there
39+
# anyway: that pin's assertions 6 and 7 want a `merge_group:` trigger and an
40+
# unfiltered `pull_request:` trigger, and this file has neither;
41+
# - it runs only AFTER the merge (`pull_request_target: [closed]` plus
42+
# `merged == true`), so its conclusion gates nothing that has not already
43+
# happened;
44+
# - no `workflow_run:` listener in this repo watches it (the only two listen
45+
# for `CI` and `Release`), so a red starts no fan-out.
46+
#
47+
# A red therefore costs one X on an already-merged PR. A green that delivered
48+
# nothing costs the foreign issues this workflow exists to stop losing — and
49+
# looks exactly like the 2000+ green runs where there was simply nothing to
50+
# report. Same trade merge-queue-triage.yml (#9424) made, for the same reason;
51+
# docs-drift-check.yml's opposite choice is right THERE because its conclusion
52+
# is a check on a live PR and its comment is a courtesy, neither of which is
53+
# true here.
1954
name: Cross-repo Issue Closer
2055

2156
# `pull_request_target` (not `pull_request`) because the job needs repository
@@ -52,6 +87,35 @@ jobs:
5287
# resolvable from the script scope (`MODULE_NOT_FOUND`). Falling back
5388
# to GITHUB_TOKEN keeps the report path able to comment on this PR.
5489
github-token: ${{ secrets.CROSS_REPO_ISSUE_TOKEN || secrets.GITHUB_TOKEN }}
90+
# The transient-retry policy, DECLARED rather than hand-written (#9575,
91+
# and the point #9576 records): github-script has accepted these two
92+
# inputs since v6 and every run of this job already echoes their
93+
# defaults into its own log — `retries: 0`,
94+
# `retry-exempt-status-codes: 400,401,403,404,422`. They drive octokit's
95+
# retry plugin, which re-issues any request whose status is not exempt,
96+
# plus network-level failures. Nothing is swallowed: a spent retry still
97+
# throws. This widens how many times a request is ASKED and no verdict
98+
# anywhere.
99+
#
100+
# It is a step input, so it covers the per-target loop below as well as
101+
# the notice. The loop keeps its own `try`/`catch` unchanged — retries
102+
# change how often a target is asked, never whether one unreachable
103+
# target may take the rest down with it.
104+
#
105+
# 403 is dropped from the action's default exempt list on purpose:
106+
# GitHub answers a SECONDARY rate limit with 403 as well as with 429, so
107+
# the status alone cannot separate weather from a permission denial. The
108+
# price is that a genuine denial — a cross-repo PAT without
109+
# `issues: write` on a sibling repo — now takes four attempts per target
110+
# to fail instead of one. It still fails, and the loop still names the
111+
# target it failed on.
112+
#
113+
# 400/401/404/422 stay exempt: a malformed request, a wrong target, or a
114+
# body past GitHub's 65536-character comment limit is this repo's own
115+
# bug. It is answered correctly on the first try and asking again only
116+
# spends runner minutes.
117+
retries: 3
118+
retry-exempt-status-codes: 400,401,404,422
55119
script: |
56120
const body = context.payload.pull_request.body || '';
57121
const prUrl = context.payload.pull_request.html_url;
@@ -95,23 +159,78 @@ jobs:
95159
// Degrade VISIBLY. Someone has to close these by hand, and this
96160
// comment is the only thing that will tell them so.
97161
const list = [...targets.keys()].map((k) => `- \`${k}\``).join('\n');
98-
await github.rest.issues.createComment({
99-
owner: context.repo.owner,
100-
repo: context.repo.repo,
101-
issue_number: context.payload.pull_request.number,
102-
body:
162+
const keys = [...targets.keys()].join(', ');
163+
164+
// The work order is announced BEFORE it is delivered, and it names
165+
// the TARGETS rather than only how many there are (#9575). This
166+
// annotation used to sit after the `await` below, which is the same
167+
// as not existing on the one run where it matters: a throw from the
168+
// post skipped it, so a refused notice lost the list AND the count.
169+
// It costs one line of log when delivery succeeds.
170+
core.warning(
171+
`CROSS_REPO_ISSUE_TOKEN is not configured — ${targets.size} issue(s) must be `
172+
+ `closed BY HAND: ${keys}.`,
173+
{ title: 'Cross-repo issues left open' },
174+
);
175+
176+
const notice =
103177
`### ⚠️ 跨仓库 issue 未被自动关闭\n\n` +
104178
`本 PR 的正文声明了跨仓库关闭关键字,但 GitHub 的关闭关键字**只在同仓库内生效**,` +
105179
`因此以下 issue 仍处于 open 状态,需要**手工关闭**:\n\n${list}\n\n` +
106180
`自动关闭需要仓库 secret \`CROSS_REPO_ISSUE_TOKEN\`(对目标仓库具备 \`issues: write\` 的` +
107181
` fine-grained PAT 或 GitHub App token)。\`GITHUB_TOKEN\` 只对当前仓库有写权限,无法胜任。\n\n` +
108182
`配置该 secret 后本条提示会自动消失,改为直接关闭目标 issue。\n\n` +
109-
`---\n_Generated by [Claude Code](https://claude.ai/code)_`,
110-
});
111-
core.warning(
112-
`CROSS_REPO_ISSUE_TOKEN is not configured — ${targets.size} issue(s) left open. ` +
113-
`Reported on the pull request instead.`,
114-
);
183+
`---\n_Generated by [Claude Code](https://claude.ai/code)_`;
184+
185+
// This comment IS the deliverable, not a courtesy about one — see
186+
// the header. The step's `retries:` have already absorbed a blip by
187+
// the time anything reaches the catch, so what is left there is a
188+
// refusal that outlived them.
189+
try {
190+
await github.rest.issues.createComment({
191+
owner: context.repo.owner,
192+
repo: context.repo.repo,
193+
issue_number: context.payload.pull_request.number,
194+
body: notice,
195+
});
196+
core.info(
197+
`Reported ${targets.size} unclosed cross-repo issue(s) on this pull request.`,
198+
);
199+
} catch (error) {
200+
const kind = typeof error?.status === 'number'
201+
? `HTTP ${error.status}`
202+
: (error?.code || 'error');
203+
// Octokit messages usually end in a full stop; ours supplies its own.
204+
const reason = `${kind}: ${String(error?.message || '').replace(/\s*\.\s*$/, '')}`;
205+
try {
206+
await core.summary.addRaw([
207+
'## ⚠️ 跨仓库关闭提示没能发到 PR 上',
208+
'',
209+
`\`issues.createComment\` 最终被拒绝:\`${reason}\`。`,
210+
'',
211+
`- 下面就是本次运行算出的完整提示 —— PR #${context.payload.pull_request.number} 上没有它。`,
212+
'- 可以直接复制到 PR 上;也可以在 API 恢复后 re-run 本 job。',
213+
'- 真正要做的事情是上面列出的那几个 issue:它们仍是 open,需要手工关闭。',
214+
'- 根治是配置仓库 secret `CROSS_REPO_ISSUE_TOKEN`,之后本分支不再运行。',
215+
'',
216+
'---',
217+
'',
218+
notice,
219+
'',
220+
].join('\n')).write();
221+
} catch (summaryError) {
222+
// The summary is the richer channel, the annotation the reliable
223+
// one. Losing the richer one must not restore the silence this
224+
// whole branch exists to break.
225+
core.info(`Could not write the job summary: ${summaryError.message}`);
226+
}
227+
core.setFailed(
228+
`${targets.size} cross-repo issue(s) were left open by this merge and the notice `
229+
+ `saying so could NOT be posted on PR #${context.payload.pull_request.number} `
230+
+ `(${reason}). Close these by hand: ${keys}. The full notice is reproduced in `
231+
+ `this run's job summary; re-run this job to retry delivery.`,
232+
);
233+
}
115234
return;
116235
}
117236

0 commit comments

Comments
 (0)