From 96b65f2390640788feaaebab9c39e0bc5c9d72ef Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 02:41:27 +0000 Subject: [PATCH 1/4] ci: close issues that a merged PR fixes in another repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/cross-repo-issue-closer.yml | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 .github/workflows/cross-repo-issue-closer.yml diff --git a/.github/workflows/cross-repo-issue-closer.yml b/.github/workflows/cross-repo-issue-closer.yml new file mode 100644 index 000000000..7637b8954 --- /dev/null +++ b/.github/workflows/cross-repo-issue-closer.yml @@ -0,0 +1,136 @@ +# GitHub's closing keywords (`Fixes #123`) only work WITHIN a repository. A PR +# here that says `Fixes objectstack-ai/objectstack#4475` 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. +# +# This is not hypothetical for this repository: defects are routinely FOUND in +# objectstack (where verification runs) and FIXED here. During v17 verification +# that happened twice in one day — #3150 fixed objectstack#4475 and #3163 fixed +# objectstack#4478 — and both framework issues 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 exactly the "declared but never enforced" +# shape both repositories keep having to fix. 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', + ); + + 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(', ')}`); + + const token = process.env.CROSS_REPO_TOKEN; + + 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 octokit = require('@actions/github').getOctokit(token); + + for (const [key, t] of targets) { + try { + const { data: issue } = await octokit.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 octokit.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 octokit.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}`); + } + } From 76515a5dab5f56e6d1d53155e3af77137315749e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 02:43:21 +0000 Subject: [PATCH 2/4] chore: add release-nothing changeset for the cross-repo issue closer --- .changeset/cross-repo-issue-closer.md | 31 +++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .changeset/cross-repo-issue-closer.md diff --git a/.changeset/cross-repo-issue-closer.md b/.changeset/cross-repo-issue-closer.md new file mode 100644 index 000000000..a0c959727 --- /dev/null +++ b/.changeset/cross-repo-issue-closer.md @@ -0,0 +1,31 @@ +--- +--- + +ci: close issues that a merged PR fixes in another repository + +Release-nothing: adds `.github/workflows/cross-repo-issue-closer.yml` and no +package code. + +Defects are routinely found in objectstack, where verification runs, and fixed +here. But GitHub's closing keywords only act within a repository, so a PR here +saying `Fixes objectstack-ai/objectstack#4475` merges and leaves that framework +issue open — with no reference to the PR on the issue's own page either. v17 +verification hit this twice in one day: #3150 fixed objectstack#4475 and #3163 +fixed objectstack#4478, and 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 by hand — this repository's secrets are +`GITHUB_TOKEN` (scoped to the repository running the workflow, which is the +whole problem), `NPM_TOKEN` and `CODECOV_TOKEN`, so until an admin provisions +`CROSS_REPO_ISSUE_TOKEN` the job cannot perform the close at all. + +That second mode is deliberate, not a fallback. A workflow that quietly does +nothing because a secret was never provisioned is the "declared but never +enforced" shape both repositories keep having to fix. A missing credential has +to announce itself. + +Matched references are restricted to the qualified `owner/repo#N` form; the +bare `#N` form already works natively and is left alone. Same-repo qualified +references are filtered out, already-closed targets are skipped, and one +unreachable target cannot swallow the rest or read as success. From 6cc49d9e54138e1c8f55c4fd46480b93dbb2a0f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 07:58:38 +0000 Subject: [PATCH 3/4] =?UTF-8?q?fix(ci):=20rename=20the=20cross-repo=20clie?= =?UTF-8?q?nt=20=E2=80=94=20github-script=20already=20declares=20`octokit`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .github/workflows/cross-repo-issue-closer.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cross-repo-issue-closer.yml b/.github/workflows/cross-repo-issue-closer.yml index 7637b8954..51e2ead43 100644 --- a/.github/workflows/cross-repo-issue-closer.yml +++ b/.github/workflows/cross-repo-issue-closer.yml @@ -105,25 +105,25 @@ jobs: // A second client: `github` is bound to GITHUB_TOKEN, which has no // write access outside this repository. - const octokit = require('@actions/github').getOctokit(token); + const crossRepo = require('@actions/github').getOctokit(token); for (const [key, t] of targets) { try { - const { data: issue } = await octokit.rest.issues.get({ + 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 octokit.rest.issues.createComment({ + 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 octokit.rest.issues.update({ + await crossRepo.rest.issues.update({ owner: t.owner, repo: t.repo, issue_number: t.number, state: 'closed', state_reason: 'completed', }); From 864b358854b29e66c9a5ff8a6a6c21ad6cad5af6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 08:23:42 +0000 Subject: [PATCH 4/4] 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. --- .github/workflows/cross-repo-issue-closer.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cross-repo-issue-closer.yml b/.github/workflows/cross-repo-issue-closer.yml index 51e2ead43..e5acd1d8d 100644 --- a/.github/workflows/cross-repo-issue-closer.yml +++ b/.github/workflows/cross-repo-issue-closer.yml @@ -62,6 +62,16 @@ jobs: '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}`; @@ -77,8 +87,6 @@ jobs: } core.info(`Cross-repo targets: ${[...targets.keys()].join(', ')}`); - const token = process.env.CROSS_REPO_TOKEN; - if (!token) { // Degrade VISIBLY. Someone has to close these by hand, and this // comment is the only thing that will tell them so.