Skip to content

fix(ci): 跨仓库客户端改名——github-script 本来就声明了 octokit(修 #4553 引入的红检查) #3

fix(ci): 跨仓库客户端改名——github-script 本来就声明了 octokit(修 #4553 引入的红检查)

fix(ci): 跨仓库客户端改名——github-script 本来就声明了 octokit(修 #4553 引入的红检查) #3

# 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.
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:
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');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body:
`### ⚠️ 跨仓库 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)_`,
});
core.warning(
`CROSS_REPO_ISSUE_TOKEN is not configured — ${targets.size} issue(s) left open. ` +
`Reported on the pull request instead.`,
);
return;
}
// A second client: `github` is bound to GITHUB_TOKEN, which has no
// write access outside this repository.
const crossRepo = require('@actions/github').getOctokit(token);
for (const [key, t] of targets) {
try {
const { data: issue } = await crossRepo.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 crossRepo.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 crossRepo.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}`);
}
}