Skip to content

fix(runtime): resume refuses a type-mismatched value on an accepted key instead of dropping it (#9416) #2314

fix(runtime): resume refuses a type-mismatched value on an accepted key instead of dropping it (#9416)

fix(runtime): resume refuses a type-mismatched value on an accepted key instead of dropping it (#9416) #2314

# GitHub's closing keywords (`Fixes #123`) only work WITHIN a repository. A PR
# here that says `Fixes objectstack-ai/objectui#456` reads exactly like a
# same-repo close to a human, merges, and leaves that issue open forever — with
# no reference to the PR on the issue's own page either, so the next reader has
# no way to find the fix.
#
# That gap is why v17 verification (#4482) left #4475 and #4478 open after their
# fixes shipped in objectui; both had to be closed by hand.
#
# This job closes the loop. It deliberately has TWO modes and BOTH are visible:
#
# token present -> close the foreign issue and comment with the PR link
# token absent -> comment ON THIS PR naming what still needs closing by hand
#
# The second mode is the point. A workflow that quietly does nothing because a
# secret was never provisioned is the shape this repo keeps having to fix
# (#4449: written, tested, exported, called by nothing). Missing credentials
# must announce themselves.
#
# That second mode is a single `issues.createComment`, and its DELIVERY is
# retried, never assumed (#9575). Until #9575 a transient answer from the
# comments endpoint threw, github-script handed the throw to
# `main().catch(handleError)` -> `core.setFailed`, and the run lost BOTH the
# notice AND — because it sat after the `await` — the `core.warning` that named
# what had been left open. All that remained was `Unhandled error: HttpError` on
# a job nobody opens, and a set of foreign issues no longer on anyone's list. So
# now: the transient class is retried (declared in the step's `retries:` inputs,
# not hand-classified), the work order is announced BEFORE delivery is
# attempted, and a refusal that outlives the retries writes the notice into the
# run's job summary and then FAILS the job.
#
# Failing is deliberate, and it is the opposite of what docs-drift-check.yml
# (#9373) chose for its advisory comment. The difference is a property of this
# job, measured rather than inherited:
#
# - its conclusion is in no required set — the context name `Close issues
# referenced in other repositories` is absent from the registry in
# scripts/check-required-contexts.mjs, and could not be enrolled there
# anyway: that pin's assertions 6 and 7 want a `merge_group:` trigger and an
# unfiltered `pull_request:` trigger, and this file has neither;
# - it runs only AFTER the merge (`pull_request_target: [closed]` plus
# `merged == true`), so its conclusion gates nothing that has not already
# happened;
# - no `workflow_run:` listener in this repo watches it (the only two listen
# for `CI` and `Release`), so a red starts no fan-out.
#
# A red therefore costs one X on an already-merged PR. A green that delivered
# nothing costs the foreign issues this workflow exists to stop losing — and
# looks exactly like the 2000+ green runs where there was simply nothing to
# report. Same trade merge-queue-triage.yml (#9424) made, for the same reason;
# docs-drift-check.yml's opposite choice is right THERE because its conclusion
# is a check on a live PR and its comment is a courtesy, neither of which is
# true here.
name: Cross-repo Issue Closer
# `pull_request_target` (not `pull_request`) because the job needs repository
# secrets, which `pull_request` withholds from fork-originated runs. The usual
# hazard of `pull_request_target` — running untrusted PR code with write
# credentials — does not apply: this job never checks out the head ref and
# never executes anything from the PR. It reads the PR body and calls the
# issues API, nothing else.
on:
pull_request_target:
types: [closed]
permissions:
contents: read
pull-requests: write
jobs:
close-foreign-issues:
name: Close issues referenced in other repositories
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Close (or report) cross-repo closing keywords
uses: actions/github-script@v9
env:
# A fine-grained PAT or GitHub App token with `issues: write` on the
# sibling repositories. `GITHUB_TOKEN` cannot do this — it is scoped
# to the repository running the workflow, which is the whole problem.
CROSS_REPO_TOKEN: ${{ secrets.CROSS_REPO_ISSUE_TOKEN }}
with:
# Hand the cross-repo token to the action itself, so `github` IS the
# cross-repo client. `require('@actions/github')` does NOT work here:
# github-script bundles its dependencies and the module is not
# resolvable from the script scope (`MODULE_NOT_FOUND`). Falling back
# to GITHUB_TOKEN keeps the report path able to comment on this PR.
github-token: ${{ secrets.CROSS_REPO_ISSUE_TOKEN || secrets.GITHUB_TOKEN }}
# The transient-retry policy, DECLARED rather than hand-written (#9575,
# and the point #9576 records): github-script has accepted these two
# inputs since v6 and every run of this job already echoes their
# defaults into its own log — `retries: 0`,
# `retry-exempt-status-codes: 400,401,403,404,422`. They drive octokit's
# retry plugin, which re-issues any request whose status is not exempt,
# plus network-level failures. Nothing is swallowed: a spent retry still
# throws. This widens how many times a request is ASKED and no verdict
# anywhere.
#
# It is a step input, so it covers the per-target loop below as well as
# the notice. The loop keeps its own `try`/`catch` unchanged — retries
# change how often a target is asked, never whether one unreachable
# target may take the rest down with it.
#
# 403 is dropped from the action's default exempt list on purpose:
# GitHub answers a SECONDARY rate limit with 403 as well as with 429, so
# the status alone cannot separate weather from a permission denial. The
# price is that a genuine denial — a cross-repo PAT without
# `issues: write` on a sibling repo — now takes four attempts per target
# to fail instead of one. It still fails, and the loop still names the
# target it failed on.
#
# 400/401/404/422 stay exempt: a malformed request, a wrong target, or a
# body past GitHub's 65536-character comment limit is this repo's own
# bug. It is answered correctly on the first try and asking again only
# spends runner minutes.
retries: 3
retry-exempt-status-codes: 400,401,404,422
script: |
const body = context.payload.pull_request.body || '';
const prUrl = context.payload.pull_request.html_url;
const thisRepo = `${context.repo.owner}/${context.repo.repo}`;
// GitHub's own keyword set, restricted to the qualified
// `owner/repo#N` form — the bare `#N` form already works natively
// and must not be touched here.
const KEYWORDS = 'close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved';
const pattern = new RegExp(
`\\b(?:${KEYWORDS})\\s+([\\w.-]+)\\/([\\w.-]+)#(\\d+)\\b`,
'gi',
);
// Report credential state on EVERY run, before any early return.
// Otherwise a repository with the secret and one without look
// identical until a cross-repo reference happens to show up —
// which can be days — and "is it configured?" stays unanswerable.
// Presence only; the value is never read into the log.
const token = process.env.CROSS_REPO_TOKEN;
core.info(
`CROSS_REPO_ISSUE_TOKEN: ${token ? 'configured' : 'ABSENT — cross-repo closes will be reported, not performed'}`,
);
const targets = new Map();
for (const [, owner, repo, number] of body.matchAll(pattern)) {
const key = `${owner}/${repo}#${number}`;
// Skip same-repo references: GitHub already closed those, and
// closing them again would be a no-op comment on every merge.
if (`${owner}/${repo}`.toLowerCase() === thisRepo.toLowerCase()) continue;
targets.set(key, { owner, repo, number: Number(number) });
}
if (targets.size === 0) {
core.info('No cross-repository closing keywords in this PR body.');
return;
}
core.info(`Cross-repo targets: ${[...targets.keys()].join(', ')}`);
if (!token) {
// Degrade VISIBLY. Someone has to close these by hand, and this
// comment is the only thing that will tell them so.
const list = [...targets.keys()].map((k) => `- \`${k}\``).join('\n');
const keys = [...targets.keys()].join(', ');
// The work order is announced BEFORE it is delivered, and it names
// the TARGETS rather than only how many there are (#9575). This
// annotation used to sit after the `await` below, which is the same
// as not existing on the one run where it matters: a throw from the
// post skipped it, so a refused notice lost the list AND the count.
// It costs one line of log when delivery succeeds.
core.warning(
`CROSS_REPO_ISSUE_TOKEN is not configured — ${targets.size} issue(s) must be `
+ `closed BY HAND: ${keys}.`,
{ title: 'Cross-repo issues left open' },
);
const notice =
`### ⚠️ 跨仓库 issue 未被自动关闭\n\n` +
`本 PR 的正文声明了跨仓库关闭关键字,但 GitHub 的关闭关键字**只在同仓库内生效**,` +
`因此以下 issue 仍处于 open 状态,需要**手工关闭**:\n\n${list}\n\n` +
`自动关闭需要仓库 secret \`CROSS_REPO_ISSUE_TOKEN\`(对目标仓库具备 \`issues: write\` 的` +
` fine-grained PAT 或 GitHub App token)。\`GITHUB_TOKEN\` 只对当前仓库有写权限,无法胜任。\n\n` +
`配置该 secret 后本条提示会自动消失,改为直接关闭目标 issue。\n\n` +
`---\n_Generated by [Claude Code](https://claude.ai/code)_`;
// This comment IS the deliverable, not a courtesy about one — see
// the header. The step's `retries:` have already absorbed a blip by
// the time anything reaches the catch, so what is left there is a
// refusal that outlived them.
try {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: notice,
});
core.info(
`Reported ${targets.size} unclosed cross-repo issue(s) on this pull request.`,
);
} catch (error) {
const kind = typeof error?.status === 'number'
? `HTTP ${error.status}`
: (error?.code || 'error');
// Octokit messages usually end in a full stop; ours supplies its own.
const reason = `${kind}: ${String(error?.message || '').replace(/\s*\.\s*$/, '')}`;
try {
await core.summary.addRaw([
'## ⚠️ 跨仓库关闭提示没能发到 PR 上',
'',
`\`issues.createComment\` 最终被拒绝:\`${reason}\`。`,
'',
`- 下面就是本次运行算出的完整提示 —— PR #${context.payload.pull_request.number} 上没有它。`,
'- 可以直接复制到 PR 上;也可以在 API 恢复后 re-run 本 job。',
'- 真正要做的事情是上面列出的那几个 issue:它们仍是 open,需要手工关闭。',
'- 根治是配置仓库 secret `CROSS_REPO_ISSUE_TOKEN`,之后本分支不再运行。',
'',
'---',
'',
notice,
'',
].join('\n')).write();
} catch (summaryError) {
// The summary is the richer channel, the annotation the reliable
// one. Losing the richer one must not restore the silence this
// whole branch exists to break.
core.info(`Could not write the job summary: ${summaryError.message}`);
}
core.setFailed(
`${targets.size} cross-repo issue(s) were left open by this merge and the notice `
+ `saying so could NOT be posted on PR #${context.payload.pull_request.number} `
+ `(${reason}). Close these by hand: ${keys}. The full notice is reproduced in `
+ `this run's job summary; re-run this job to retry delivery.`,
);
}
return;
}
for (const [key, t] of targets) {
try {
const { data: issue } = await github.rest.issues.get({
owner: t.owner, repo: t.repo, issue_number: t.number,
});
if (issue.state === 'closed') {
core.info(`${key} is already closed — skipping.`);
continue;
}
await github.rest.issues.createComment({
owner: t.owner, repo: t.repo, issue_number: t.number,
body:
`已由 ${thisRepo} 的 ${prUrl} 修复并合并。\n\n` +
`(跨仓库的关闭关键字不会自动生效,本条由 \`cross-repo-issue-closer\` 工作流代为收口。)\n\n` +
`---\n_Generated by [Claude Code](https://claude.ai/code)_`,
});
await github.rest.issues.update({
owner: t.owner, repo: t.repo, issue_number: t.number,
state: 'closed', state_reason: 'completed',
});
core.info(`Closed ${key}.`);
} catch (error) {
// One unreachable target must not swallow the rest, and a
// failure here must not read as success.
core.warning(`Could not close ${key}: ${error.message}`);
}
}