|
16 | 16 | # secret was never provisioned is the shape this repo keeps having to fix |
17 | 17 | # (#4449: written, tested, exported, called by nothing). Missing credentials |
18 | 18 | # 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. |
19 | 54 | name: Cross-repo Issue Closer |
20 | 55 |
|
21 | 56 | # `pull_request_target` (not `pull_request`) because the job needs repository |
|
52 | 87 | # resolvable from the script scope (`MODULE_NOT_FOUND`). Falling back |
53 | 88 | # to GITHUB_TOKEN keeps the report path able to comment on this PR. |
54 | 89 | 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 |
55 | 119 | script: | |
56 | 120 | const body = context.payload.pull_request.body || ''; |
57 | 121 | const prUrl = context.payload.pull_request.html_url; |
@@ -95,23 +159,78 @@ jobs: |
95 | 159 | // Degrade VISIBLY. Someone has to close these by hand, and this |
96 | 160 | // comment is the only thing that will tell them so. |
97 | 161 | 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 = |
103 | 177 | `### ⚠️ 跨仓库 issue 未被自动关闭\n\n` + |
104 | 178 | `本 PR 的正文声明了跨仓库关闭关键字,但 GitHub 的关闭关键字**只在同仓库内生效**,` + |
105 | 179 | `因此以下 issue 仍处于 open 状态,需要**手工关闭**:\n\n${list}\n\n` + |
106 | 180 | `自动关闭需要仓库 secret \`CROSS_REPO_ISSUE_TOKEN\`(对目标仓库具备 \`issues: write\` 的` + |
107 | 181 | ` fine-grained PAT 或 GitHub App token)。\`GITHUB_TOKEN\` 只对当前仓库有写权限,无法胜任。\n\n` + |
108 | 182 | `配置该 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 | + } |
115 | 234 | return; |
116 | 235 | } |
117 | 236 |
|
|
0 commit comments