docs(skills): record ComponentInput's five ADR-0049 tombstones in the plugin-development guide #2058
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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. | |
| # | |
| # ## This file is a PORT, and the four things it carries across (#5261) | |
| # | |
| # It landed here on 2026-08-09 (#3969) as a copy of the framework's file taken | |
| # before four rounds of hardening reached that copy. Each round removed one | |
| # SILENT exit, and this port brings all four across at once rather than | |
| # re-deriving them; the framework cards carry the full arguments. | |
| # | |
| # 1. The token-absent notice's DELIVERY is retried, never assumed | |
| # (objectstack#9575). Until then 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. 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. | |
| # 2. The per-target close loop reached the same rule one card later | |
| # (objectstack#9595). Its `catch` is unchanged and must stay so: it buys | |
| # ISOLATION, and one unreachable target taking the rest down with it would | |
| # be a worse failure than the one being fixed. What it did NOT buy is a | |
| # verdict. `core.warning` leaves the job green, so a refused close left the | |
| # foreign issue open, put nothing on the PR, and produced a conclusion | |
| # identical to the runs where there was nothing to do at all — while the | |
| # catch's own comment said "a failure here must not read as success". | |
| # Isolation and outcome are now separate: the loop records the keys it | |
| # could not close, runs to the end, and the verdict is passed after it. | |
| # 3. The already-closed target was skipped WHOLE (objectstack#9643), and the | |
| # skip covered two calls of which only one is redundant: re-closing a | |
| # closed issue is a no-op worth avoiding, but the BACKLINK is the half this | |
| # file's own first paragraph calls the defect — "no way to find the fix" — | |
| # and a wholesale skip dropped it and reported green. | |
| # 4. A closing keyword aimed at a foreign PULL REQUEST used to be honoured | |
| # (objectstack#9711). Every pull request is also an issue to | |
| # `GET /repos/{owner}/{repo}/issues/{N}`, so `owner/repo#N` naming a PR | |
| # reached the loop like anything else and would have been commented on and | |
| # CLOSED. It is now refused, out loud, BEFORE the already-closed branch. | |
| # | |
| # Item 3 is the one this repository was actually living with, and that is | |
| # measured rather than assumed. Over ALL 3425 merged pull requests in this | |
| # repository (#1..#5288, the closed-PR listing exhausted at 37 pages), 60 carry | |
| # a qualified foreign closing keyword — 61 occurrences over 59 distinct targets, | |
| # where the framework's own equivalent measurement found ZERO in its window. Of | |
| # those 60, fifteen merged after this file landed on 2026-08-09, and in every | |
| # one of them the framework issue was closed BY A SEAT one to two seconds after | |
| # the merge, before this workflow's run had even been created. So the loop hit | |
| # `state === 'closed'` fifteen times out of fifteen, skipped each target whole, | |
| # left no backlink on any of them, and reported success — which is what all 941 | |
| # runs of this workflow have reported. Not one of those fifteen framework issues | |
| # carries this workflow's backlink comment today. | |
| # | |
| # Item 4, by the same measurement, has never yet had an occasion here: of the 59 | |
| # distinct targets, 57 could be resolved (52 directly, plus five through the | |
| # repository renames `objectstack-ai/framework` -> `objectstack-ai/objectstack` | |
| # and `objectql/objectui` -> `objectstack-ai/objectui`, each corroborated by the | |
| # naming PR's own title) and ALL 57 are issues, not pull requests. Two — | |
| # `objectstack-ai/cloud#667` and `#654`, from #2045 and #2040 — could not be | |
| # read. The guard is therefore insurance rather than a repair, but insurance on | |
| # a LIVE path: this repository writes ~1.5 qualified foreign closing keywords a | |
| # day, so a mistyped one is an ordinary authoring slip here, not a hypothetical. | |
| # | |
| # ## Why a refusal FAILS the job here | |
| # | |
| # Failing is deliberate on both exits, and the affordability of it is a property | |
| # of this job in THIS repository, measured rather than inherited: | |
| # | |
| # - its conclusion is in no required set — the `main` ruleset (11776024) | |
| # carries `deletion`, `non_fast_forward`, `merge_queue` and `pull_request` | |
| # rules and NO `required_status_checks` rule at all, so no context is | |
| # required on this repository today; | |
| # - 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 exists in this repository at all, 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 several hundred green runs where there was simply | |
| # nothing to report. The fifteen measured above are precisely that failure | |
| # already happening in the quiet direction. | |
| 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`, seen on this | |
| # workflow's first successful-parse run). 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 | |
| # (objectstack#9575/#9576): 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}`; | |
| // ONE vocabulary for a refusal, used by both exits below: `HTTP 503` | |
| // or `ECONNRESET` is what lets a reader separate platform weather | |
| // from a 403/404 that means the credential is wrong. Hoisted rather | |
| // than copied per call site — the framework copy reached the same | |
| // shape (objectstack#9576), and a second hand-written copy is the | |
| // thing that note is about. | |
| const describe = (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. | |
| return `${kind}: ${String(error?.message || '').replace(/\s*\.\s*$/, '')}`; | |
| }; | |
| // 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 | |
| // (objectstack#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 reason = describe(error); | |
| 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; | |
| } | |
| // ISOLATION and OUTCOME are separable, and the loop owns only the | |
| // first (objectstack#9595). The `catch` below must keep swallowing | |
| // — one | |
| // unreachable target must not take the rest down — so the verdict | |
| // is passed AFTER the loop, over the keys it collected. Until this | |
| // split existed the two were fused into `core.warning` alone: a | |
| // refused close left the foreign issue open, put no notice on the | |
| // PR, and handed the run the same green conclusion as the several | |
| // hundred runs that had nothing to do at all. The catch's own comment | |
| // already stated the requirement; only the code was missing. | |
| const failures = []; | |
| // Targets that are not issues AT ALL. Kept apart from `failures` | |
| // above because the two need opposite remedies: a failure is an API | |
| // refusal a re-run can clear, this one is a defect in the merged | |
| // PR's own body that no re-run will ever change. Fusing them would | |
| // send the reader off to re-run a job that is going to refuse again. | |
| const malformed = []; | |
| // The backlink this PR leaves, identified so a SECOND run can see | |
| // it. The marker is scoped to one pull request and is therefore | |
| // STABLE across runs — one PR leaves one backlink, however many | |
| // times the job runs. That is deliberate, and it decides the | |
| // degradation direction below: a marker carrying a RUN id makes a | |
| // duplicate self-evidently one comment per run, so posting blind is | |
| // safe for it; a STABLE marker strands a permanent second copy | |
| // instead. The framework copy weighs both classes side by side | |
| // (objectstack#9423/#9424). This one is in the second. | |
| const backlinkMarker = | |
| `<!-- cross-repo-issue-closer:${thisRepo}#${context.payload.pull_request.number} -->`; | |
| const backlink = (lead) => | |
| `${lead}\n\n` + | |
| `(跨仓库的关闭关键字不会自动生效,本条由 \`cross-repo-issue-closer\` 工作流代为收口。)\n\n` + | |
| `---\n_Generated by [Claude Code](https://claude.ai/code)_\n\n${backlinkMarker}`; | |
| for (const [key, t] of targets) { | |
| // What a refusal from here costs, for the verdict after the loop. | |
| // Until the issue has been read there is no way to know it is | |
| // already closed, so a refusal costs both halves. | |
| let lost = 'close and backlink'; | |
| try { | |
| const { data: issue } = await github.rest.issues.get({ | |
| owner: t.owner, repo: t.repo, issue_number: t.number, | |
| }); | |
| // A pull request is ALSO an issue to this endpoint. Every PR | |
| // answers `GET /repos/{owner}/{repo}/issues/{N}` with `state`, | |
| // `state_reason` and the rest, plus a `pull_request` key that | |
| // nothing here used to read (objectstack#9711) — the key is | |
| // ABSENT on a | |
| // real issue, present as an object on a PR, so its truthiness | |
| // is the whole test. The target regex takes any `owner/repo#N` | |
| // and PR and issue numbers share one sequence, so a body saying | |
| // `Fixes owner/repo#4500` where 4500 is a pull request would | |
| // have made this loop comment on that PR and CLOSE it. | |
| // | |
| // GitHub's own closing-keyword parser — the behaviour this | |
| // workflow exists to carry across repository boundaries — never | |
| // closes a pull request; `Fixes #N` aimed at a PR leaves a | |
| // reference and nothing else. So the keyword is a MALFORMED | |
| // instruction, and it is refused OUT LOUD rather than skipped: | |
| // this file's ten exit paths were arrived at by fixing one | |
| // quiet one after another, and a quiet `continue` would have | |
| // been the eleventh. The refusal is also the conservative half | |
| // of an asymmetry — closing a pull request drops its | |
| // merge-queue membership and any armed auto-merge in the same | |
| // step, and neither comes back by itself. | |
| // | |
| // Placed BEFORE the `state === 'closed'` branch on purpose: a | |
| // merged pull request reads `state: 'closed'` with | |
| // `state_reason: null` from this very endpoint (measured on | |
| // objectstack-ai/objectstack#9143), which that branch reads as | |
| // "no objection recorded" and answers with a backlink comment. | |
| // A guard sitting after it would still write on somebody else's | |
| // pull request. | |
| if (issue.pull_request) { | |
| malformed.push({ key, url: issue.pull_request.html_url }); | |
| core.warning( | |
| `${key} is a PULL REQUEST, not an issue — ${prUrl} declares it FIXED, but GitHub's own ` | |
| + `closing-keyword parser never closes a pull request, so this workflow does not either. ` | |
| + `Nothing was commented on and nothing was closed. Re-running cannot fix a keyword that ` | |
| + `can never fire: point the reference at the issue it meant.`, | |
| { title: 'Cross-repo closing keyword names a pull request' }, | |
| ); | |
| continue; | |
| } | |
| // "Already closed" is NOT one situation, and the three causes | |
| // that reach here do not deserve the same treatment (objectstack#9643). | |
| // Until this split existed all three were skipped whole, which | |
| // dropped the backlink — the half of the defect the header | |
| // names — on every one of them, and reported green. | |
| if (issue.state === 'closed') { | |
| // (a) Closed as not-planned or as a duplicate. A comment | |
| // saying this PR fixed it would CONTRADICT the triage | |
| // decision on the issue, so nothing is posted. Two people | |
| // have said opposite things and only a human can settle | |
| // it, hence an annotation rather than a silent skip. | |
| // `state_reason` is the only cause the API states | |
| // outright; it is nullable (a real closed issue answers | |
| // `null` here), and null is deliberately read as "no | |
| // objection recorded", i.e. as `completed`. | |
| if (issue.state_reason === 'not_planned' || issue.state_reason === 'duplicate') { | |
| core.warning( | |
| `${key} is closed as \`${issue.state_reason}\`, but this PR's body declares it FIXED. ` | |
| + `No backlink was left: a comment saying ${prUrl} closed it would contradict that ` | |
| + `triage decision. Someone has to decide which of the two is right.`, | |
| { title: 'Cross-repo close contradicts the issue triage' }, | |
| ); | |
| continue; | |
| } | |
| // (b) Closed by an EARLIER run of this job — a re-run, which | |
| // this workflow's own job summary invites. The marker is | |
| // the only evidence that separates it from (c): the API | |
| // cannot, because `closed_by` is a login and every seat | |
| // here shares one identity, so "the workflow did it" and | |
| // "a human did it" read the same. | |
| let alreadyLinked = false; | |
| try { | |
| // One page is the bound. Overflowing it costs a DUPLICATE | |
| // backlink, never a lost one, and the stable marker makes | |
| // the pair self-evident. | |
| const existing = await github.rest.issues.listComments({ | |
| owner: t.owner, repo: t.repo, issue_number: t.number, per_page: 100, | |
| }); | |
| alreadyLinked = existing.data.some((c) => (c.body ?? '').includes(backlinkMarker)); | |
| } catch (error) { | |
| // AT-MOST-ONCE, stated: the marker is stable, so posting | |
| // without knowing strands a second permanent backlink on | |
| // somebody ELSE's repo, which nobody here can tidy up. A | |
| // miss costs a convenience link on an already-closed issue | |
| // and a re-run recovers it. Loud either way — this is a | |
| // skip with a reason, not a silent one. | |
| core.warning( | |
| `${key} is already closed, but its comments could not be read ` | |
| + `(${describe(error)}), so this run could not tell whether ${prUrl} is already ` | |
| + `linked there. No backlink was posted — re-run this job to leave it.`, | |
| { title: 'Cross-repo backlink skipped, not confirmed' }, | |
| ); | |
| continue; | |
| } | |
| if (alreadyLinked) { | |
| core.info(`${key} is already closed and already carries this PR's backlink — nothing to do.`); | |
| continue; | |
| } | |
| // (c) Closed by a human before this PR merged — the ordinary | |
| // sequence the header is about (v17 verification: #3150 | |
| // fixed objectstack#4475, #3163 fixed objectstack#4478). | |
| // The close is genuinely redundant; the backlink is the | |
| // whole remaining value, so leave it and touch nothing | |
| // else. | |
| lost = 'backlink'; | |
| await github.rest.issues.createComment({ | |
| owner: t.owner, repo: t.repo, issue_number: t.number, | |
| body: backlink( | |
| `本 issue 在本次合并之前就已经是 closed 状态,这里只补上修复它的 PR:` + | |
| `${thisRepo} 的 ${prUrl}。本工作流没有改动它的状态。`, | |
| ), | |
| }); | |
| core.info(`${key} was already closed — left the backlink to ${prUrl}, did not re-close it.`); | |
| continue; | |
| } | |
| await github.rest.issues.createComment({ | |
| owner: t.owner, repo: t.repo, issue_number: t.number, | |
| body: backlink(`已由 ${thisRepo} 的 ${prUrl} 修复并合并。`), | |
| }); | |
| 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. This half buys the | |
| // first: the loop continues. The failure is RECORDED instead of | |
| // dropped, and the second half is passed below. | |
| const reason = describe(error); | |
| failures.push({ key, reason, lost }); | |
| core.warning(`Could not finish ${key} — ${lost} not delivered: ${reason}`, { | |
| title: 'Cross-repo issue left unfinished', | |
| }); | |
| } | |
| } | |
| if (failures.length === 0 && malformed.length === 0) { | |
| core.info(`All ${targets.size} cross-repo target(s) handled: closed, or already closed and linked.`); | |
| return; | |
| } | |
| // A spent failure IS the report. Announce it in both channels the | |
| // notice branch uses, for the same reasons: the summary carries the | |
| // full list a human needs, `setFailed` carries the conclusion that | |
| // makes anyone open the run at all. | |
| // | |
| // Both classes are red, and each says what to do about ITSELF: a | |
| // refusal is retried, a malformed reference is rewritten. Green | |
| // with an annotation was the alternative for the malformed class, | |
| // and this repository's own history rejects it — 941 runs of this | |
| // job, the last 100 of them green without exception, including the | |
| // fifteen measured in the header that delivered nothing at all. A | |
| // green run of this job has never been opened by anybody here. An | |
| // annotation on a | |
| // green post-merge run is the silent path with extra steps, and a | |
| // declared close that can never happen is exactly what objectstack#9595 made | |
| // red for. | |
| const failedKeys = failures.map((f) => f.key).join(', '); | |
| const failedList = failures.map((f) => `- \`${f.key}\` —— ${f.lost} 未完成:${f.reason}`).join('\n'); | |
| const malformedKeys = malformed.map((m) => m.key).join(', '); | |
| const malformedList = malformed.map((m) => `- \`${m.key}\` —— 这个编号是一个 pull request:${m.url}`).join('\n'); | |
| const summaryLines = []; | |
| if (failures.length > 0) { | |
| summaryLines.push( | |
| '## ⚠️ 跨仓库 issue 没能收口', | |
| '', | |
| `本次合并声明了 ${targets.size} 个跨仓库关闭目标,其中 ${failures.length} 个没有完成:`, | |
| '', | |
| failedList, | |
| '', | |
| `- 修复它们的是 ${prUrl} —— 需要手工收口:还是 open 的要关掉,已经关掉的要把这条链接留在上面。`, | |
| '- 原因排除后可以直接 re-run 本 job:已经关闭的目标不会被重复关闭,已经留下过本 PR 反链的目标也不会被重复评论。', | |
| '- 401/404 通常意味着 `CROSS_REPO_ISSUE_TOKEN` 对目标仓库没有 `issues: write`,而不是目标不存在 —— GitHub 对无权访问的仓库回 404。', | |
| '', | |
| ); | |
| } | |
| if (malformed.length > 0) { | |
| summaryLines.push( | |
| '## ⛔ 关闭关键字指向的是 pull request,不是 issue', | |
| '', | |
| `本次合并声明的 ${targets.size} 个跨仓库关闭目标里,有 ${malformed.length} 个的编号是 pull request:`, | |
| '', | |
| malformedList, | |
| '', | |
| '- 它们没有被评论,也没有被关闭。GitHub 自己的关闭关键字解析器从不关闭 pull request,本工作流也不会 —— 关掉一个 PR 会在同一步里丢掉它的 merge queue 成员资格和已经武装的 auto-merge,两者都不会自己回来。', | |
| `- 这不是 re-run 能解决的故障:写错的是 ${prUrl} 的正文。把引用改成它真正想关的那个 issue 编号,或者手工关掉那个 issue。`, | |
| '', | |
| ); | |
| } | |
| try { | |
| await core.summary.addRaw(summaryLines.join('\n')).write(); | |
| } catch (summaryError) { | |
| // Same asymmetry as the notice branch: the summary is the richer | |
| // channel, the conclusion the reliable one. Losing the richer one | |
| // must not restore the silence. | |
| core.info(`Could not write the job summary: ${summaryError.message}`); | |
| } | |
| const verdict = []; | |
| if (failures.length > 0) { | |
| verdict.push( | |
| `${failures.length} of ${targets.size} cross-repo target(s) were NOT finished by this merge: ` | |
| + `${failedKeys}. Finish them by hand (fixed by ${prUrl}) — an open one needs closing, an ` | |
| + `already-closed one needs this PR's link on it — or re-run this job once the cause is ` | |
| + `cleared. The full list, with what each one is missing, is in this run's job summary.`, | |
| ); | |
| } | |
| if (malformed.length > 0) { | |
| verdict.push( | |
| `${malformed.length} of ${targets.size} cross-repo closing keyword(s) in this merged pull ` | |
| + `request name a PULL REQUEST rather than an issue: ${malformedKeys}. GitHub's own keyword ` | |
| + `parser never closes a pull request and this workflow does not either, so nothing was ` | |
| + `commented on and nothing was closed. Re-running this job will refuse them again — what ` | |
| + `has to change is the reference in ${prUrl}'s body.`, | |
| ); | |
| } | |
| core.setFailed(verdict.join(' ')); |