Skip to content

Commit b200720

Browse files
os-zhuangclaude
andauthored
ci: 合并时收口 objectstack 里被 Fixes 声明的 issue(缺凭据时改为出声,不静默) (#3179)
* ci: close issues that a merged PR fixes in another repository Defects are routinely found in objectstack (where verification runs) and fixed here, but GitHub's closing keywords only act within a repository — so those framework issues stay open with no reference to the PR that fixed them. v17 verification hit this twice in one day (#3150 → objectstack#4475, #3163 → objectstack#4478); both were closed by hand. The job has two modes and both are visible: with a cross-repo token it closes the foreign issue and links the PR; without one it comments on the merged PR naming what still needs closing. A silent no-op on a missing secret is the 'declared but never enforced' shape both repos keep having to fix, so the absent credential announces itself instead. * chore: add release-nothing changeset for the cross-repo issue closer * fix(ci): rename the cross-repo client — github-script already declares `octokit` `actions/github-script` injects an `octokit` binding into the script scope, so `const octokit = ...` aborts the run before any logic executes: SyntaxError: Identifier 'octokit' has already been declared Caught on objectstack#4553's own merge — the first time the workflow ever ran. The pre-merge `node --check` missed it because the test wrapper declared only {github, context, core, require}; a wrapper that omits an injected identifier cannot see a collision with it. The wrapper now carries the full injected set, and the old spelling fails that check. * ci: log CROSS_REPO_ISSUE_TOKEN presence on every run Without this the job returns early whenever a PR body carries no cross-repo reference, so a repository that has the secret and one that does not produce byte-identical logs. Presence only; the value is never read into the log. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5426cc7 commit b200720

2 files changed

Lines changed: 175 additions & 0 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
---
3+
4+
ci: close issues that a merged PR fixes in another repository
5+
6+
Release-nothing: adds `.github/workflows/cross-repo-issue-closer.yml` and no
7+
package code.
8+
9+
Defects are routinely found in objectstack, where verification runs, and fixed
10+
here. But GitHub's closing keywords only act within a repository, so a PR here
11+
saying `Fixes objectstack-ai/objectstack#4475` merges and leaves that framework
12+
issue open — with no reference to the PR on the issue's own page either. v17
13+
verification hit this twice in one day: #3150 fixed objectstack#4475 and #3163
14+
fixed objectstack#4478, and both were closed by hand.
15+
16+
The job has two modes and both are visible. With a cross-repo token it closes
17+
the foreign issue and links the PR. Without one it comments on the merged PR
18+
naming what still needs closing by hand — this repository's secrets are
19+
`GITHUB_TOKEN` (scoped to the repository running the workflow, which is the
20+
whole problem), `NPM_TOKEN` and `CODECOV_TOKEN`, so until an admin provisions
21+
`CROSS_REPO_ISSUE_TOKEN` the job cannot perform the close at all.
22+
23+
That second mode is deliberate, not a fallback. A workflow that quietly does
24+
nothing because a secret was never provisioned is the "declared but never
25+
enforced" shape both repositories keep having to fix. A missing credential has
26+
to announce itself.
27+
28+
Matched references are restricted to the qualified `owner/repo#N` form; the
29+
bare `#N` form already works natively and is left alone. Same-repo qualified
30+
references are filtered out, already-closed targets are skipped, and one
31+
unreachable target cannot swallow the rest or read as success.
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# GitHub's closing keywords (`Fixes #123`) only work WITHIN a repository. A PR
2+
# here that says `Fixes objectstack-ai/objectstack#4475` reads exactly like a
3+
# same-repo close to a human, merges, and leaves that issue open forever — with
4+
# no reference to the PR on the issue's own page either, so the next reader has
5+
# no way to find the fix.
6+
#
7+
# This is not hypothetical for this repository: defects are routinely FOUND in
8+
# objectstack (where verification runs) and FIXED here. During v17 verification
9+
# that happened twice in one day — #3150 fixed objectstack#4475 and #3163 fixed
10+
# objectstack#4478 — and both framework issues had to be closed by hand.
11+
#
12+
# This job closes the loop. It deliberately has TWO modes and BOTH are visible:
13+
#
14+
# token present -> close the foreign issue and comment with the PR link
15+
# token absent -> comment ON THIS PR naming what still needs closing by hand
16+
#
17+
# The second mode is the point. A workflow that quietly does nothing because a
18+
# secret was never provisioned is exactly the "declared but never enforced"
19+
# shape both repositories keep having to fix. Missing credentials must announce
20+
# themselves.
21+
name: Cross-repo Issue Closer
22+
23+
# `pull_request_target` (not `pull_request`) because the job needs repository
24+
# secrets, which `pull_request` withholds from fork-originated runs. The usual
25+
# hazard of `pull_request_target` — running untrusted PR code with write
26+
# credentials — does not apply: this job never checks out the head ref and
27+
# never executes anything from the PR. It reads the PR body and calls the
28+
# issues API, nothing else.
29+
on:
30+
pull_request_target:
31+
types: [closed]
32+
33+
permissions:
34+
contents: read
35+
pull-requests: write
36+
37+
jobs:
38+
close-foreign-issues:
39+
name: Close issues referenced in other repositories
40+
if: github.event.pull_request.merged == true
41+
runs-on: ubuntu-latest
42+
steps:
43+
- name: Close (or report) cross-repo closing keywords
44+
uses: actions/github-script@v9
45+
env:
46+
# A fine-grained PAT or GitHub App token with `issues: write` on the
47+
# sibling repositories. `GITHUB_TOKEN` cannot do this — it is scoped
48+
# to the repository running the workflow, which is the whole problem.
49+
CROSS_REPO_TOKEN: ${{ secrets.CROSS_REPO_ISSUE_TOKEN }}
50+
with:
51+
script: |
52+
const body = context.payload.pull_request.body || '';
53+
const prUrl = context.payload.pull_request.html_url;
54+
const thisRepo = `${context.repo.owner}/${context.repo.repo}`;
55+
56+
// GitHub's own keyword set, restricted to the qualified
57+
// `owner/repo#N` form — the bare `#N` form already works natively
58+
// and must not be touched here.
59+
const KEYWORDS = 'close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved';
60+
const pattern = new RegExp(
61+
`\\b(?:${KEYWORDS})\\s+([\\w.-]+)\\/([\\w.-]+)#(\\d+)\\b`,
62+
'gi',
63+
);
64+
65+
// Report credential state on EVERY run, before any early return.
66+
// Otherwise a repository with the secret and one without look
67+
// identical until a cross-repo reference happens to show up —
68+
// which can be days — and "is it configured?" stays unanswerable.
69+
// Presence only; the value is never read into the log.
70+
const token = process.env.CROSS_REPO_TOKEN;
71+
core.info(
72+
`CROSS_REPO_ISSUE_TOKEN: ${token ? 'configured' : 'ABSENT — cross-repo closes will be reported, not performed'}`,
73+
);
74+
75+
const targets = new Map();
76+
for (const [, owner, repo, number] of body.matchAll(pattern)) {
77+
const key = `${owner}/${repo}#${number}`;
78+
// Skip same-repo references: GitHub already closed those, and
79+
// closing them again would be a no-op comment on every merge.
80+
if (`${owner}/${repo}`.toLowerCase() === thisRepo.toLowerCase()) continue;
81+
targets.set(key, { owner, repo, number: Number(number) });
82+
}
83+
84+
if (targets.size === 0) {
85+
core.info('No cross-repository closing keywords in this PR body.');
86+
return;
87+
}
88+
core.info(`Cross-repo targets: ${[...targets.keys()].join(', ')}`);
89+
90+
if (!token) {
91+
// Degrade VISIBLY. Someone has to close these by hand, and this
92+
// comment is the only thing that will tell them so.
93+
const list = [...targets.keys()].map((k) => `- \`${k}\``).join('\n');
94+
await github.rest.issues.createComment({
95+
owner: context.repo.owner,
96+
repo: context.repo.repo,
97+
issue_number: context.payload.pull_request.number,
98+
body:
99+
`### ⚠️ 跨仓库 issue 未被自动关闭\n\n` +
100+
`本 PR 的正文声明了跨仓库关闭关键字,但 GitHub 的关闭关键字**只在同仓库内生效**,` +
101+
`因此以下 issue 仍处于 open 状态,需要**手工关闭**:\n\n${list}\n\n` +
102+
`自动关闭需要仓库 secret \`CROSS_REPO_ISSUE_TOKEN\`(对目标仓库具备 \`issues: write\` 的` +
103+
` fine-grained PAT 或 GitHub App token)。\`GITHUB_TOKEN\` 只对当前仓库有写权限,无法胜任。\n\n` +
104+
`配置该 secret 后本条提示会自动消失,改为直接关闭目标 issue。\n\n` +
105+
`---\n_Generated by [Claude Code](https://claude.ai/code)_`,
106+
});
107+
core.warning(
108+
`CROSS_REPO_ISSUE_TOKEN is not configured — ${targets.size} issue(s) left open. ` +
109+
`Reported on the pull request instead.`,
110+
);
111+
return;
112+
}
113+
114+
// A second client: `github` is bound to GITHUB_TOKEN, which has no
115+
// write access outside this repository.
116+
const crossRepo = require('@actions/github').getOctokit(token);
117+
118+
for (const [key, t] of targets) {
119+
try {
120+
const { data: issue } = await crossRepo.rest.issues.get({
121+
owner: t.owner, repo: t.repo, issue_number: t.number,
122+
});
123+
if (issue.state === 'closed') {
124+
core.info(`${key} is already closed — skipping.`);
125+
continue;
126+
}
127+
await crossRepo.rest.issues.createComment({
128+
owner: t.owner, repo: t.repo, issue_number: t.number,
129+
body:
130+
`已由 ${thisRepo} 的 ${prUrl} 修复并合并。\n\n` +
131+
`(跨仓库的关闭关键字不会自动生效,本条由 \`cross-repo-issue-closer\` 工作流代为收口。)\n\n` +
132+
`---\n_Generated by [Claude Code](https://claude.ai/code)_`,
133+
});
134+
await crossRepo.rest.issues.update({
135+
owner: t.owner, repo: t.repo, issue_number: t.number,
136+
state: 'closed', state_reason: 'completed',
137+
});
138+
core.info(`Closed ${key}.`);
139+
} catch (error) {
140+
// One unreachable target must not swallow the rest, and a
141+
// failure here must not read as success.
142+
core.warning(`Could not close ${key}: ${error.message}`);
143+
}
144+
}

0 commit comments

Comments
 (0)