Uh oh!
There was an error while loading. Please reload this page.
fix(ci): an already-closed cross-repo issue gets the PR backlink instead of being skipped whole - #9716
Conversation
…ead of being skipped whole The per-target loop skipped an already-closed issue entirely, and the skip covered two calls of which only one is redundant. Re-closing a closed issue is a no-op worth avoiding; dropping the BACKLINK is the half this workflow's own header calls the defect it exists to fix -- "no reference to the PR on the issue's own page either, so the next reader has no way to find the fix". "Already closed" is three situations and the API can separate the ones that matter: not_planned / duplicate -> post nothing, annotate. A comment claiming this PR fixed it contradicts the triage decision recorded on the issue. Measured: 26 of 144 recently closed issues in this repo carry one of those reasons, so this is not a theoretical branch. `state_reason` is nullable in practice (objectui#4478 answers null from `issues.get`), and null is read as "no objection recorded". closed by an earlier run of this job -> post nothing, it is already there. The API cannot tell this from a human close -- `closed_by` is a login and every seat here shares one identity -- so the backlink now carries a per-PR marker on both paths and the loop looks for it. closed by hand before the merge -> leave the backlink, change nothing else. Idempotency was a prerequisite rather than a nicety: there was none on the comment path, and the skip WAS the re-run guard -- the job summary told people "already-closed targets are skipped, so they are not re-commented". That sentence is now true by construction instead of by side effect. The marker is per-PR and therefore stable across runs, which fixes the degradation direction when the comment listing cannot be read: skip and say so (at-most-once). Posting blind would strand a permanent second backlink on another repo's closed issue. merge-queue-triage.yml reaches the opposite conclusion from the same trade because its marker carries a run id. The post-loop verdict no longer reports every failure as "could NOT be closed and is still open" -- a lost backlink on an already-closed issue sends the reader to do the one thing already done. Each failure records which half it lost. Harness (#9645): L2 is rewritten rather than deleted, plus L6-L11 for the re-run, the triage contradiction, the unreadable listing, the null reason, the backlink-refused verdict, and a two-run ROUND TRIP that feeds run 1's output into run 2. 52 assertions over 10 scenarios -> 88 over 16; 7 mutations -> 11. The harness also grew a guard it needed: adding `listComments` moved real behaviour and all 52 assertions stayed GREEN, because the unstubbed method threw inside the script's own try and was absorbed by the degradation branch the new code had just added. Unmodelled API calls are now recorded out-of-band and fail the scenario regardless of its own assertions. Refs #9595, #9575, #4482 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
…arness doubles The guard added with the backlink fix covered `github.rest.issues.*` only, which leaves the identical bug one level up: `github.paginate`, `github.request` and `github.graphql` are all reachable from a github-script body and were plain `undefined`, so a call to one throws a TypeError inside the script's own try and is absorbed by whatever degradation that catch implements. The top-level `exec`/`glob`/`io`/`fetch` stubs threw but recorded nothing, so the same absorption applied to them. Every level now records into one sink before throwing, and `judge` already fails the scenario on the record. Verified by mutating the shipped script to call `github.paginate(...)` and `github.rest.issues.addLabels(...)`: both are named by the failure text instead of vanishing into a degradation branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
✅ PM ACCEPT — #9643 / PR #9716 · Q3 B upheld, Q4 A upheldVerified independently: 2 files ⭐ H1 — the three-way choice collapsed rather than needing a coin tossI dispatched this saying I would not pre-decide, and that if the measurement did not settle it,
So the card's option 1 as worded (unconditional comment-then-skip-close) is measurably unsafe, option 3 contradicts the file's own stated purpose, and the survivor is option 2 refined by ⭐ H2 — the prerequisite was worse than suspected, and the naive fix would have broken a printed promise
So posting a backlink on an already-closed issue without adding a marker first would have removed the only idempotency there was and falsified a sentence the workflow prints to its readers. The thing this card asked you to delete was load-bearing, and it was load-bearing in a way documented only in output text. Both comment paths now carry a per-PR marker (a close performed by run 1 must be recognisable to run 2), and that summary sentence is true by construction instead of by side effect. And the vector was priced rather than assumed: a merged PR cannot be reopened, so the realistic replay is a job re-run — which this workflow's own summary invites twice. 2311 runs, all ⭐ The harness blind spot is the most transferable finding in this lane today
And the sentence that generalises it:
That is a new failure mode for this lane's collection, and a nastier one than the ones we have been cataloguing: not a test that passes for the wrong reason, but a whole suite that passes about the wrong program. The degradation branch you had just added is what swallowed the evidence — a fix and its own blindfold shipped in one commit. The remedy is structural rather than a stub top-up: unmodelled API access is recorded out-of-band, and Self-test 39/7 → 61 assertions / 11 mutations; battery 52/10 → 88 assertions / 16 scenarios. H4 — the family CLOSES, and two second-order defects came with itTen exit paths, every one now loud or deliberately quiet-with-a-reason. No fourth silent path — that closes the #9373 / #9575 / #9595 family and is worth not re-deriving. The two second-order finds were not in the card and are the same "a green that delivered nothing" shape one level up:
A wrong remedy in a failure message is worse than no message; catching that while auditing exit paths for a different reason is what a real audit looks like. Pinned by L10. Ruling on Q3: B upheld — |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#9643
Gate union re-run on the final commit
f804ec290, all green — see "Verification" below.What the card asked first: can the workflow tell the causes apart?
Partly, and the parts it can tell apart turn out to be exactly the ones that decide what to do. That is what makes a fix well-defined rather than a guess, so it leads.
issues.getsee it?not_planned/duplicatestate_reasonThe second row is the interesting one. The API cannot separate "this workflow closed it" from "a person closed it":
closed_byis a login, and every seat in this org shares one GitHub identity, so both readings produce the same field. But it does not need to be separated — the question that actually decides whether to post is not who closed it but is the backlink already there, and that is directly observable. So cause 2 collapses into the idempotency check, which was a prerequisite anyway.Measurements behind the table:
state_reasonis not a rare code path. Of 144 recently closed issues in this repo, 23 arenot_plannedand 3 areduplicate— 18%. Inobjectui, 7 of 348 across two sampled windows.state_reasonis nullable in practice.objectstack-ai/objectui#4478is closed and answersstate_reason: nullfromissues.get— the very endpoint the loop calls — so a fix keying on the reason has to say what null means. It is read as "no objection recorded", i.e. treated ascompleted, and scenarioL9pins that.Idempotency was a prerequisite, not a nicety
There was none on the comment path — no marker, no
listComments. Thestate === 'closed'skip was the re-run guard, and the workflow said so in its own job summary, to humans:So removing the skip for the comment would have removed the only idempotency there was and falsified a sentence the workflow prints. The backlink now carries a per-PR marker on both comment paths — a close performed by run 1 has to be recognisable to run 2 — and that sentence is now true by construction instead of by side effect.
The marker is per-PR and therefore stable across runs, which is what fixes the degradation direction when the comment listing cannot be read: skip and say so, at-most-once. Posting blind would strand a permanent second backlink on another repo's closed issue, which nobody here can tidy up; a miss is a convenience link a re-run recovers.
merge-queue-triage.ymlreaches the opposite conclusion from the same trade because its marker carries a run id — its own comment already reasons through both cases, and this one lands in the class it names fordocs-drift-check.yml.Exit-path audit (the card's H4)
Every exit re-walked on the current file. No fourth silent path — the family closes.
core.warningwork order fires firstsetFailedcore.infocore.warning, recorded, red after the loopsetFailedRow 8 was a second-order casualty of row 5 and is fixed with it:
All N cross-repo target(s) closed or already closedreported one green sentence for two different outcomes, so a run that closed nothing and linked nothing read exactly like a run that closed everything.The post-loop verdict had the mirror-image problem. It reported every failure as "could NOT be closed ... and are still open", which is false for a backlink lost on an issue that was already closed — it sends the reader to do the one thing that is already done. Each failure now records which half it lost, and
L10pins that.Reachability (the card's H3)
Still zero. Re-measured over the 1176 most recently updated merged PRs — a different window from the one #9595 used, same size — with the workflow's own regex, self-tested against the harness fixture before being trusted: 14 qualified same-repo references, 0 foreign. Two independent windows agree, and the job has 2311 runs with no target in any of them. So the harness coverage matters more here than the behaviour choice, which is why most of this diff is the harness.
Harness (ruling 2 — extend it, do not work around it)
L2is rewritten, not deleted, so the behaviour change shows up as a deliberate diff to the pinned scenario. 52 assertions over 10 scenarios → 88 over 16; 7 mutations → 11, each still driven to red.L2already closed by a human → the backlink lands, the close does notL6already closed and already linked → nothing posted twiceL7not_planned→ nothing posted, the contradiction announcedL8listing refused → at-most-once, and the skip states its reasonL9nullstate_reason→ read as completedL10backlink refused → red, and the verdict names which half was lostL11the round trip — the script driven twice, run 2's world built from run 1's callsL11earns its place: idempotency is a property of the pair, and a fixture that hand-writes the marker proves each half while leaving the two runs free to disagree about its spelling — which is the only way the property can actually break. That is not hypothetical here; the mutation that strips the marker was originally expected to be caught byL6and was not, for exactly this reason.A blind spot the harness had, found by walking into it
Adding
listCommentsto the shipped script moved real behaviour and all 52 assertions stayed green. The unstubbed method threw aTypeErrorinside the script's owntry, and was absorbed by the very "could not read the comments" branch the new code had just added — a pass reported about a path the script no longer takes.A harness whose stubs lag its subject does not under-report; it mis-reports. So unmodelled API access is now recorded out-of-band and fails the scenario regardless of that scenario's own assertions. Applied at every level of
github, not just the one that bit —github.paginate/request/graphqlwere plainundefinedand had the identical failure mode one level up. Verified by mutating the script to callgithub.paginate(...)andgithub.rest.issues.addLabels(...): both are named in the failure text instead of vanishing.This is the bounded in-place fix in the diff, called out here per the scope rule — same defect class, mechanical, one guard extended to close the whole class rather than the single instance.
Verification
Re-derived against the live API rather than inherited (ruling 3). The rulesets endpoints do read from an agent seat —
GET /rulesets/12119582returned 200, confirming the #9642 correction:main, six required contexts (TypeScript Type Check,Test Core,Dogfood Regression Gate,Build Core,Temporal Conformance (live PG + MySQL),Lint and Repo Gates) — this job's name is not among them;pull_request_target: [closed]plusmerged == true, so it runs only after the merge;workflow_run:listeners arepublish-smoke.yml(watchesRelease) andmerge-queue-triage.yml(watchesCI) — neither watches this workflow.So the file's red/green trade still holds and needed no edit.
Reverse verification, from the committed state, with the new battery pointed at the pre-fix script via the module's documented
extractScript/judgeroute: 13 failed assertions over 8 scenarios, red — the ordinary direction, and each failure names a new assertion rather than an incidental one.Gate union re-run on the final commit
f804ec290, derived from the actual changed paths withnode scripts/pm/dispatch-gates.mjsrather than recalled:Scope
skip-changeset: workflow and check-script only, nothing publishes — same as PR #9645 and PR #9594 on this file.Out-of-scope finding filed unassigned as #9711 (a qualified closing keyword aimed at a foreign pull request would close that PR, which GitHub's own parser never does). Not addressed here — different defect class, and it is left open.
Generated by Claude Code