Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 97 additions & 11 deletions .github/workflows/cross-repo-issue-closer.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,9 +29,21 @@
# 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:
# The OTHER exit — the per-target close loop, the branch that runs when the
# token IS present — reached the same rule one card later (#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 ~2270 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.
#
# Failing is deliberate on BOTH exits, 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 — and re-measured for
# the loop on 2026-08-18 rather than carried over from the notice (#9595):
#
# - its conclusion is in no required set — the context name `Close issues
# referenced in other repositories` is absent from the registry in
Expand All@@ -44,10 +56,21 @@
# - no `workflow_run:` listener in this repo watches it (the only two listen
# for `CI` and `Release`), so a red starts no fan-out.
#
# The first of those three was re-derived on 2026-08-18 against the LIVE ruleset
# rather than against the repo-side pin alone: `GET /repos/.../rulesets/12119582`
# lists six required contexts (`Lint & Repo Gates`, `TypeScript Type Check`,
# `Test Core`, `Dogfood Regression Gate`, `Build Core`, `Temporal Conformance
# (live PG + MySQL)`) and this job's name is not among them.
#
# 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;
# report. That a red here is READ, meanwhile, is not an assumption either: this
# job's only two script-level failures (2026-08-02, a `SyntaxError` that stopped
# it running at all) were diagnosed and fixed 39 minutes after the first one,
# and the second of them landed on an unrelated author's merge.
#
# 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.
Expand DownExpand Up@@ -121,6 +144,20 @@ jobs:
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 — merge-queue-triage.yml declares the
// identical helper once, and a second hand-written copy here is the
// shape #9576 records as the thing to stop doing.
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.
Expand DownExpand Up@@ -197,11 +234,7 @@ jobs:
`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*$/, '')}`;
const reason = describe(error);
try {
await core.summary.addRaw([
'## ⚠️ 跨仓库关闭提示没能发到 PR 上',
Expand DownExpand Up@@ -234,6 +267,17 @@ jobs:
return;
}

// ISOLATION and OUTCOME are separable, and the loop owns only the
// first (#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 ~2270
// runs that had nothing to do at all. The catch's own comment
// already stated the requirement; only the code was missing.
const failures = [];

for (const [key, t] of targets) {
try {
const { data: issue } = await github.rest.issues.get({
Expand All@@ -257,7 +301,49 @@ jobs:
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}`);
// 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 });
core.warning(`Could not close ${key}: ${reason}`, {
title: 'Cross-repo issue left open',
});
}
}

if (failures.length === 0) {
core.info(`All ${targets.size} cross-repo target(s) closed or already closed.`);
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.
const failedKeys = failures.map((f) => f.key).join(', ');
const failedList = failures.map((f) => `- \`${f.key}\` —— ${f.reason}`).join('\n');
try {
await core.summary.addRaw([
'## ⚠️ 跨仓库 issue 没能自动关闭',
'',
`本次合并声明了 ${targets.size} 个跨仓库关闭目标,其中 ${failures.length} 个被拒绝,仍是 open:`,
'',
failedList,
'',
`- 修复它们的是 ${prUrl} —— 需要手工关闭,并把这条链接留在目标 issue 上。`,
'- 原因排除后可以直接 re-run 本 job:已经关闭的目标会被跳过,不会重复评论。',
'- 401/404 通常意味着 `CROSS_REPO_ISSUE_TOKEN` 对目标仓库没有 `issues: write`,而不是目标不存在 —— GitHub 对无权访问的仓库回 404。',
'',
].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}`);
}
core.setFailed(
`${failures.length} of ${targets.size} cross-repo issue(s) could NOT be closed by this merge and `
+ `are still open: ${failedKeys}. Close them by hand (fixed by ${prUrl}), or re-run this job once `
+ `the cause is cleared. The full list with reasons is in this run's job summary.`,
);
38 changes: 38 additions & 0 deletions .github/workflows/lint.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -785,6 +785,44 @@ jobs:
- name: Workflow status-function guard
run: pnpm check:workflow-status-functions

# Cross-repo closer outcome contract (#9595, and #9575 before it).
# `cross-repo-issue-closer.yml` carries ~150 lines of inline
# github-script, and it is code nobody has ever seen run: over the 1176
# most recently merged PRs, ZERO bodies carry a qualified cross-repo
# closing keyword, so the branch that closes foreign issues has had no
# target in that whole window. Its defects are therefore found by reading
# — one card per silent exit — and each fix lands as more unexercised
# code. This step is the exercise: the shipped script is extracted from
# the YAML with a real parser (never retyped) and run under doubles the
# way actions/github-script runs it, as one AsyncFunction body. Ten
# scenarios pin the target parse and the outcome of EVERY exit — which of
# setFailed / warning / job summary fires, and which API calls were made.
#
# Assertion 0 is the compile, and it is not theoretical: this job failed
# twice on 2026-08-02 with `SyntaxError: Identifier 'octokit' has already
# been declared`, i.e. a script that never ran at all, on a post-merge
# workflow whose red nothing else in CI can see.
#
# Its --self-test runs first and is the half that stops the battery
# rotting into decoration: it mutates the shipped script seven ways —
# downgrade the verdict to a warning, stop collecting failed keys, break
# out of the loop instead of isolating, drop the same-repo skip, narrow
# the keyword set, drop the already-closed skip, downgrade the notice
# path's verdict — and requires the battery to go RED for each, naming the
# scenario that catches it. A mutation whose anchor no longer exists is a
# failure too, so a rewrite of the workflow cannot leave the mutations
# silently matching nothing.
#
# Invoked as `node` rather than through a `pnpm check:*` alias: that alias
# belongs in root package.json, declared territory of the @changesets/cli
# v3 migration lane (#9465) while it runs. Same shape as the
# release-rehearsal step above; dispatch-gates.mjs derives gate families
# from either spelling. No network, no build; ~0.2 s.
- name: Cross-repo closer outcome contract
run: |
node scripts/check-cross-repo-closer-outcome.mjs --self-test
node scripts/check-cross-repo-closer-outcome.mjs

# Shard positive-attestation gate (#6082). ci.yml's two aggregate gates
# used to decide from one `needs.<matrix>.result` word, which cannot carry
# three shards' verdicts: run 31120902911 read the undocumented
Expand Down
Loading
Loading