Uh oh!
There was an error while loading. Please reload this page.
fix(ci): a cross-repo closing keyword aimed at a foreign PULL REQUEST is refused, not honoured - #9759
Conversation
… is refused, not honoured Every pull request is also an issue to `issues.get`, and the per-target loop never read the `pull_request` key the response carries. A merged body saying `Fixes owner/repo#N` where N is a pull request would have made this workflow comment on that PR and close it — a power GitHub's own closing-keyword parser does not have, exercised with a credential scoped for issue hygiene, and asymmetric: a closed PR loses its merge-queue membership and any armed auto-merge in the same step. The guard sits before the already-closed branch on purpose: a merged PR reads `state: 'closed'` with `state_reason: null` from that endpoint, so a guard placed after it would still leave a comment on somebody else's pull request. The refusal is loud. A malformed target is recorded apart from API failures — the two need opposite remedies — and the post-loop verdict now carries both classes, each stating its own: retry a refusal, rewrite a bad reference. Harness: L12 (open PR) and L13 (merged PR, the ordering test) added, doubles model the `pull_request` key as absence-or-object, M12/M13/M14 added and M1 follows the verdict's new shape. 88 assertions / 16 scenarios -> 105 / 18; 11 mutations -> 14. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
✅ PM ACCEPT — #9711 / PR #9759Verified independently: 2 files H1 — confirmed, and I re-ran the discriminator myselfThe discriminator is the truthiness of a key the loop already had — ruling 2 discharged with no second API call. And you established it from live responses without ever exercising a foreign PR, which is exactly the constraint I set: never close anything while testing. ⭐ And the refinement matters more than the confirmation.
The second grade is the one that would have survived #9716's own change — its ⭐ H2 — RED, and the second argument is the one I would not have madeI leaned red and told you to decide with the file's doctrine, not my lean. You gave two arguments and the first is the doctrinal one:
That is the right line through #9716's green choice. But the second argument prices the alternative by measuring whether anyone would ever see it:
An annotation is only a signal if someone looks. Measuring that nobody ever has is a far better refutation than asserting annotations are weak. And the placement is right: the malformed class is kept apart from ⭐ The harness caught a stale mutation anchor by itself
That is the "mutation that never applied" class — the one three devs in this lane caught by noticing today. Here the harness caught it automatically, because #9643's structural fix put anchor validity outside the scenario's own assertions. The measure-your-own-measurement discipline is now machinery rather than vigilance, one card after it was a manual save. Reverse verification11 failed assertions over exactly the 2 new scenarios, and the two load-bearing lines say precisely what the card claimed:
Battery 88/16 → 105 assertions / 18 scenarios; self-test 61/11 → 77 / 14. H3 — a third independent window, and a base rate1200 most recently created closed PRs (1176 merged): 0 qualified foreign closing keywords, 5 same-repo, none naming a PR. Plus the base rate of the underlying mistake in the common spelling: 0 of 1129 distinct bare-form targets spanning #4584..#9726, classified against 2400 enumerated PR numbers. Latent and never fired, in either spelling. That is the truthful grading, and it does not diminish the fix: the blast radius asymmetry stands — a wrongly-closed PR loses merge-queue membership and auto-merge in one step, and neither returns by itself.
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#9711
Branched after PR #9716 landed (squashed to
6f40ed736), so its harness is the base this extends rather than something this collides with. Gate union re-run on the final commitab1084d93, all green — see "Verification".H1 leads: the target is reachable, and the response already says what kind it is
Reachability first, severity second. The keyword has to qualify before anything else matters, and it does: the extraction regex is
`\\b(?:${KEYWORDS})\\s+([\\w.-]+)\\/([\\w.-]+)#(\\d+)\\b`so the target key is
owner/repo#NwhereNis digits and nothing else. Pull requests and issues share one number sequence per repository, so every PR number is a well-formed target. Nothing downstream asked what kind of thingNwas.What
issues.getreturns was read from live responses on this repo, never by exercising a foreign PR — nothing was closed or commented on while testing:GET /repos/objectstack-ai/objectstack/issues/9716(a pull request)state: open,state_reason: null,pull_request: { url, html_url, diff_url, patch_url, merged_at }GET .../issues/9711(an issue)pull_requestkey is absent — not nullGET .../issues/9143(a merged pull request)state: closed,state_reason: null,pull_request.merged_at: 2026-08-16T14:56:58ZThe key is absent on an issue and an object on a PR, so its truthiness is the whole test — read out of the response the loop already has, no second API call (ruling 2), and therefore no new API surface for the harness to model (ruling 3).
Row three is why the guard sits before the already-closed branch, and it upgrades the card: since #9716 a merged foreign PR would not merely have been closed, it would have taken the
state_reason: nullpath — read as "no objection recorded" — and had a backlink comment stranded on it. So the defect had two grades:issues.update({ state: 'closed' }). The severe one: merge-queue membership and any armed auto-merge are gone in the same step, and neither returns by itself.L12andL13pin one each, andL13is specifically the ordering test — a guard placed after the state branch passesL12and failsL13.H2: how loud — red, argued from this file's doctrine rather than from the PM's lean
Recorded as a failure and passed to
setFailedafter the loop. The three options were weighed on what the ten exit paths already do, and the deciding question is not "was something lost" but "was a declared deliverable left undone, with this run the only thing that knows":not_planned(L7), where the issue is closed, the author wanted it closed, and only the stated reason disagrees.A pull-request target is in the second class, not the
L7class. Nothing about the declaration is satisfied: the merged body says it closes something, nothing was closed, nothing will ever be closed, and whatever real issue the author meant stays open.L7's green rests on "the tracker already matches the author's belief"; here it never will.The measured half of the argument, which is what makes green-with-an-annotation untenable rather than merely weaker: this job has 2334 runs, every one of them
run_attempt1, and 99 of the last 100 concluded green (1 skipped). Nobody has ever re-run it and nobody opens its green runs. An annotation on a green post-merge run is the silent path with extra steps, and ruling 1 asks for a refusal the author actually learns about.The third option — commenting on the foreign PR — is rejected on the workflow's own warrant: it exists to carry GitHub's in-repo behaviour across repository boundaries, and writing on another repo's pull request to complain about our authoring mistake is a new power, not that behaviour.
Red brings one obligation, and it is the mirror of the wording defect #9716 fixed: this red must not send the reader to the remedies that fit an API refusal. So the malformed targets are collected apart from
failuresand the post-loop verdict carries both classes, each stating its own remedy — retry a refusal, rewrite a bad reference.L12asserts the malformed verdict does not tell anyone to re-run the job.Ruling 4 checked what red costs, re-derived live rather than inherited:
GET /rulesets→ one active ruleset (12119582,main); itsrequired_status_checksnames six contexts (TypeScript Type Check,Test Core,Dogfood Regression Gate,Build Core,Temporal Conformance (live PG + MySQL),Lint and Repo Gates) — this job is not among them. The rulesets endpoints do read from an agent seat (200), as PR fix(ci): the required SET is readable — retire the 403 premise and diff the live ruleset #9679 corrected.pull_request_target: [closed]plusif: github.event.pull_request.merged == true— it runs only after a merge.workflow_run:listeners arepublish-smoke.yml(watchesRelease) andmerge-queue-triage.yml(watchesCI). Neither watches this workflow.So red blocks nothing and wakes nothing; it is purely a conclusion a human reads, which is exactly the dial being set.
H3: has it ever fired — no, in either spelling
Re-measured on a third independent window (the 1200 most recently created closed PRs, 1176 merged — #9595 used a created-desc window and #9643 an updated-desc one), with a port of the workflow's own regex self-tested against the harness fixture before being trusted:
Then the base rate of the underlying authoring mistake, in the spelling that is common — because "would anybody ever aim a closing keyword at a PR number" is the real question behind the branch:
Fixes #N) closing keywords in those same 1176 bodies: 1145 occurrences, 1129 distinct targets, spanning#4584..#9726#4453, so the enumeration covers the whole span)Honest grading, then: latent and never fired — not active. Not once in three windows for the qualified form, and not once in 1129 chances for the form people actually write. The severity is carried entirely by the asymmetry of the outcome, not by its frequency.
H4: is the same confusion anywhere else
Everything that reaches
issues.*with a number taken from user-authored text was swept, in this repo and inobjectui:cross-repo-issue-closer.yml(this repo)cross-repo-issue-closer.yml(objectui)duplicate-fix-guard.ymlmerge-queue-triage.ymlworkflow_runpayloaddocs-drift-check.ymlcontext.issue.numberThe
objectuifinding is the substantive one: that repo carries a fork of this workflow from before the whole family landed — nopull_requestguard, a barecore.warningwhere the failure verdict should be, the already-closed target skipped whole, and no harness at all. ItsthisRepoisobjectui, so its targets are in this repo: a merged PR there sayingFixes objectstack-ai/objectstack#9716would close that pull request here. Filed unassigned as objectstack-ai/objectui#5261 with the port route; not touched from this PR, since the fix has to land in the repo that owns the file.One more divergence came out of the sweep and is filed as #9755, out of scope here:
duplicate-fix-guard.ymlaccepts the optional colon (Fixes: owner/repo#N) and documents it as GitHub's syntax, while this workflow's regex requires whitespace immediately after the keyword — verified by running both regexes over the same bodies. That spelling takes exit path 1 here, whose green line is indistinguishable from a body with no cross-repo reference at all. Which parser is right is a question about GitHub, so it is a card rather than a rider.The change
placed immediately after
issues.getand before thestate === 'closed'branch, plus the second list and a post-loop verdict that carries both classes. Exit paths 1 through 10 keep their behaviour byte for byte; this adds the eleventh, and it is loud:core.warningnaming it, recorded, summary section, red after the loop — and the verdict says a re-run will refuse it againHarness (ruling 3: extend it, do not work around it)
88 assertions over 16 scenarios → 105 over 18; 11 mutations → 14.
L12— an open PR target: never closed, never commented on, not even listed (the kind settles it before idempotency can matter), announced, red, named in both the verdict and the summary, and the other two targets still close.L13— a merged PR target: the ordering test. Its fixture is exactlyL9's (closed,state_reason: null) plus thepull_requestkey, soL9proves that shape earns a backlink for an issue andL13proves it earns none for a pull request.pull_requestas absence-or-object, matching the measured API, so a fixture cannot pass a truthiness test the real response would fail.PR_KEYpins all five fields even though only truthiness is read, becausehtml_urlreaches the job summary.M12(guard removed),M13(the refusal stops being recorded, so the run refuses and still reports green),M14(the annotation downgraded tocore.info) are new and each driven to red.M1follows the verdict's new shape and now has to be caught by an API refusal and by a malformed target:['L3', 'L4', 'L5', 'L12', 'L13'].The self-test's anchor discipline earned its keep mid-change: rewriting the verdict made
M1's anchor a no-op, and the run failed withM1: its anchor is present in the shipped scriptwhile all 88 behaviour assertions were still green.Verification
Reverse verification, from the committed state, with the new battery pointed at main's pre-fix script through the module's documented
extractScript/judgeroute: 11 failed assertions over exactly 2 scenarios — the ordinary direction (red), and every failure names a new assertion rather than an incidental one. The two most telling lines areL12 still closes the other two targets ... got 3(the pre-fix script closes the pull request too) andL13 leaves NO backlink on a merged pull request.Gate union derived from the actual changed paths with
node scripts/pm/dispatch-gates.mjsrather than recalled, re-run on the final commitab1084d93after the rebase:Scope
skip-changeset: a workflow and its check script, nothing publishes — same as PRs #9594, #9645 and #9716 on this file.Out-of-scope findings filed unassigned: objectstack-ai/objectui#5261 (the sibling repo's copy of this workflow) and #9755 (the colon divergence). Neither is addressed here.
Generated by Claude Code