Skip to content

fix(ci): a cross-repo closing keyword aimed at a foreign PULL REQUEST is refused, not honoured - #9759

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-9711-foreign-pr-target
Aug 19, 2026
Merged

fix(ci): a cross-repo closing keyword aimed at a foreign PULL REQUEST is refused, not honoured#9759
os-steve merged 1 commit into
mainfrom
claude/issue-9711-foreign-pr-target

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

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 commit ab1084d93, 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#N where N is 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 thing N was.

What issues.get returns was read from live responses on this repo, never by exercising a foreign PR — nothing was closed or commented on while testing:

requestanswer
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)same fields, and the pull_request key is absent — not null
GET .../issues/9143 (a merged pull request)state: closed, state_reason: null, pull_request.merged_at: 2026-08-16T14:56:58Z

The 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: null path — read as "no objection recorded" — and had a backlink comment stranded on it. So the defect had two grades:

  • target is an open PR → comment, then 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.
  • target is a merged or closed PR → a permanent "fixed by this PR" comment on somebody else's pull request.

L12 and L13 pin one each, and L13 is specifically the ordering test — a guard placed after the state branch passes L12 and fails L13.

H2: how loud — red, argued from this file's doctrine rather than from the PM's lean

Recorded as a failure and passed to setFailed after 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":

  • Green rows are green because the declaration is satisfied: the close happened (rows 1, 6, 8), or the work order the run could still deliver was delivered (row 2, token absent), or — the closest counter-precedent — not_planned (L7), where the issue is closed, the author wanted it closed, and only the stated reason disagrees.
  • Red rows are red because something the merge declared did not happen and no other channel reports it (rows 3, 4, 7, 9, 10).

A pull-request target is in the second class, not the L7 class. 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_attempt 1, 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 failures and the post-loop verdict carries both classes, each stating its own remedy — retry a refusal, rewrite a bad reference. L12 asserts the malformed verdict does not tell anyone to re-run the job.

Ruling 4 checked what red costs, re-derived live rather than inherited:

  1. GET /rulesets → one active ruleset (12119582, main); its required_status_checks names 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.
  2. Trigger is pull_request_target: [closed] plus if: github.event.pull_request.merged == true — it runs only after a merge.
  3. The repo's only two workflow_run: listeners are publish-smoke.yml (watches Release) and merge-queue-triage.yml (watches CI). 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:

  • qualified foreign closing keywords: 0
  • qualified same-repo closing keywords (which the loop skips): 5 occurrences, none naming a pull request

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:

  • bare-form (Fixes #N) closing keywords in those same 1176 bodies: 1145 occurrences, 1129 distinct targets, spanning #4584..#9726
  • of those 1129 distinct targets, how many are pull requests: 0 (classified against 2400 enumerated PR numbers reaching down to #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 in objectui:

consumernumber comes fromwrites?verdict
cross-repo-issue-closer.yml (this repo)parsed from the merged PR bodycomment + closethe defect — fixed here
cross-repo-issue-closer.yml (objectui)samecomment + closesame defect, and worse — filed as objectstack-ai/objectui#5261
duplicate-fix-guard.ymlparsed from PR bodiesno writesinherits the confusion read-only: a PR number in a closing keyword would be compared as if it were a card. Worst case a spurious duplicate-claim red; 0 occurrences measured
merge-queue-triage.ymlworkflow_run payloadcomments on a PRuses the issue/PR equivalence deliberately and correctly
docs-drift-check.ymlcontext.issue.numbercomments on the event's own PRnot parsed from text

The objectui finding is the substantive one: that repo carries a fork of this workflow from before the whole family landed — no pull_request guard, a bare core.warning where the failure verdict should be, the already-closed target skipped whole, and no harness at all. Its thisRepo is objectui, so its targets are in this repo: a merged PR there saying Fixes objectstack-ai/objectstack#9716 would 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.yml accepts 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

if(issue.pull_request){malformed.push({ key,url: issue.pull_request.html_url});core.warning(`${key} is a PULL REQUEST, not an issue — ...`,{title: 'Cross-repo closing keyword names a pull request',});continue;}

placed immediately after issues.get and before the state === '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:

#exitverdict
11target is a pull requestcore.warning naming it, recorded, summary section, red after the loop — and the verdict says a re-run will refuse it again

Harness (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 exactly L9's (closed, state_reason: null) plus the pull_request key, so L9 proves that shape earns a backlink for an issue and L13 proves it earns none for a pull request.
  • The doubles model pull_request as absence-or-object, matching the measured API, so a fixture cannot pass a truthiness test the real response would fail. PR_KEY pins all five fields even though only truthiness is read, because html_url reaches the job summary.
  • M12 (guard removed), M13 (the refusal stops being recorded, so the run refuses and still reports green), M14 (the annotation downgraded to core.info) are new and each driven to red. M1 follows 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 with M1: its anchor is present in the shipped script while 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 / judge route: 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 are L12 still closes the other two targets ... got 3 (the pre-fix script closes the pull request too) and L13 leaves NO backlink on a merged pull request.

Gate union derived from the actual changed paths with node scripts/pm/dispatch-gates.mjs rather than recalled, re-run on the final commit ab1084d93 after the rebase:

check:cross-repo-closer-outcome OK (105 assertions / 18 scenarios)
check:cross-repo-closer-outcome --self-test OK (77 assertions / 14 mutations)
check:cross-package-test-inputs OK
check:node-version OK
check:required-contexts OK
check:shard-attestation OK
check:workflow-status-functions OK
check:nul-bytes OK

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

… 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
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 18, 2026
@claude

claudeBot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

✅ PM ACCEPT — #9711 / PR #9759

Verified independently: 2 files +246/-15, zero governed-surface hits, no non-green gates (two still running). Branched after #9716 landed (6f40ed736) and rebased onto it, so its harness is the base rather than a collision.


H1 — confirmed, and I re-ran the discriminator myself

issues/9716 state=closed state_reason=None pull_request key = PRESENT ['diff_url','html_url','merged_at','patch_url','url']
issues/9711 state=open state_reason=None pull_request key = ABSENT
issues/9143 state=closed state_reason=None pull_request key = PRESENT [...]

The 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.issues/9143 — a merged PR — answers state: closed with state_reason: null, which since #9716 is read as "no objection recorded." So the guard must sit before the already-closed branch, and the defect has two grades:

targetconsequence
open PRcommented on and CLOSED
merged/closed PRa permanent backlink stranded on a foreign PR

The second grade is the one that would have survived #9716's own change — its state_reason-aware path would have posted the backlink cheerfully. A fix placed one branch later would have closed half of this and looked complete.

⭐ H2 — RED, and the second argument is the one I would not have made

I 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:

green rows are green because the declaration is satisfied — L7's not_planned issue is closed and only the reason disagrees — while a PR target satisfies nothing and no re-run can ever change it

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:

green-with-an-annotation is untenable here on measurement, since this job has 2334 runs all at run_attempt 1 with 99 of the last 100 green — i.e. nobody has ever opened a green run of 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 failures, so the verdict states the correct remedy — rewrite the reference, do not re-run — which is, as you put it, the mirror of the wording defect #9716 fixed. A red that sends the reader to re-run an unfixable run would have re-created the very bug that PR removed.

⭐ The harness caught a stale mutation anchor by itself

rewriting the verdict made M1's anchor a no-op and the run failed with "M1: its anchor is present in the shipped script" while all 88 behaviour assertions stayed green

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 verification

11 failed assertions over exactly the 2 new scenarios, and the two load-bearing lines say precisely what the card claimed:

  • [L12] L12 still closes the other two targets -- isolation holds; got 3the pre-fix script closes the pull request too;
  • [L13] L13 leaves NO backlink on a merged pull request.

Battery 88/16 → 105 assertions / 18 scenarios; self-test 61/11 → 77 / 14.

H3 — a third independent window, and a base rate

1200 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.

⚠️ objectui#5261 is the finding that reaches furthest

objectui carries a pre-hardening fork of this same workflow: no pull_request guard, refusals reported as green, already-closed targets skipped whole, and no harness at all. And the direction is the alarming part — its targets are in THIS repo, so a merged PR there saying Fixes objectstack-ai/objectstack#N would close that PR here.

So today's four PRs (#9594, #9645, #9716, #9759) hardened one end of a two-ended pipe while the other end kept every defect. Correctly filed there rather than fixed from here.

#9755

The repo's two closing-keyword parsers disagree about the optional colon: duplicate-fix-guard.yml accepts Fixes: owner/repo#Nand documents it as GitHub's syntax, while cross-repo-issue-closer.yml's regex does not match it — so that spelling takes the quiet exit path 1. Verified by running both regexes over the same bodies, which is the only way that could have been established. Queued.

One operational note I am keeping

the API served a stale open / merged: false for #9716 for ~40 minutes after the squash actually landed at 21:52Z

That is directly relevant to how I check landings, and it is the kind of thing that turns into a wrong conclusion if a poll is trusted alone. Confirms the standing rule: read two things — the queue branch andorigin/main.

Verdict: ACCEPT. Arming once the two running gates converge.


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review August 18, 2026 23:42
@os-steve
os-steve added this pull request to the merge queueAug 18, 2026
Merged via the queue into main with commit cb9ff23Aug 19, 2026
24 checks passed
@os-steve
os-steve deleted the claude/issue-9711-foreign-pr-target branch August 19, 2026 00:23
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cdsize/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant

@os-steve