Repository navigation
feat: flag a required check with a second pull request producer - #21
Merged
Merged
Conversation
The DUPLICATE rule found a second producer only on push. Another active workflow that runs a job of the same context on a pull request into the default branch, on pull_request or pull_request_target, gives that head the context from two suites as well, and the audit passed it. So did one workflow that runs on both events. pull_request_target now counts as a pull request event. Its check runs carry the pull request's head sha, and GitHub lists it among the events whose checks satisfy a required status check, so a required check it alone produces is no longer ORPHANED, and one it produces unrequired is UNGATED. A path filter or a types: restriction is a finding only when every event that reaches the pull request has one. A pair of pull request producers is reported once, from the first workflow by path. A job elsewhere whose context cannot be resolved, or whose branch filter cannot be read, is refused with exit 2. Closes #20 Signed-off-by: Cameron Brooks <cambrooks3393@gmail.com>
A pull request pair was reported only from the first workflow by path, and the main loop skips allowlisted jobs, so an ALLOW entry on that workflow hid the DUPLICATE and the audit exited 0. The pairs are now computed per required context from every pull request producer, whatever ALLOW says, once per pair of workflows. A job in ALLOW still counts as producing its contexts. PATH-FILTER and TRIGGER were each decided over all events separately, so a path filter on one event and types: [labeled] on the other gave neither finding, and types: [opened] with types: [synchronize] gave a false TRIGGER. Each event is now blocked by a path filter or by types: that omit opened or synchronize. The blocks are findings only when the unfiltered events together miss one of those types, and each finding names its event. A required check whose only producers are pull_request_target jobs is now TARGET-ONLY. Such a job runs the default branch's workflow on the default branch's code unless it checks out the pull request, so it may be unable to fail because of the pull request. ALLOW on the job, with the reason, accepts one. The summary line counts "pull request jobs", since it now includes pull_request_target. Signed-off-by: Cameron Brooks <cambrooks3393@gmail.com>
An allowlisted job skipped every check, so accepting a TARGET-ONLY check through ALLOW also silenced that required job's CONDITIONAL, TRIGGER, PATH-FILTER and DUPLICATE findings. ALLOW now silences only UNGATED, for a job that gates nothing; a required job in it is checked as usual. A new TARGET_OK, keyed by (repo, context), accepts TARGET-ONLY and nothing else. TARGET-ONLY no longer fires when a push run on a pull request's branch also produces the check: that run tests the pull request's code, and the shape is already a DUPLICATE. The message now says the check is reported on a pull request only by pull_request_target. Self-test cases now pin types: [] as a TRIGGER (GitHub does not document it, so the audit reads it fail-closed), a lone types: [opened] as a TRIGGER, and pairs whose producers are all allowlisted. Signed-off-by: Cameron Brooks <cambrooks3393@gmail.com>
This was referenced Oct 8, 2026
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changes
The DUPLICATE rule (#17, #19) finds a second producer only on
push. A required context can also get one from the pull request itself:checkonpull_request;checkonpull_request, or onpull_request_target.The head then carries
checkfrom two suites, and the gate takes whichever run it finds (kindspec/rowspec#43). The audit passed this. The PR also corrects howpull_request_targetis treated, and adds TARGET-ONLY for a check that only it produces.DUPLICATE: a second producer on the pull request
push_producers()is nowproducers(data, branch, admits).push_duplicatesit does what it did before.pr_admitsit collects every active workflow whosepull_requestorpull_request_targetruns for a PR into the default branch, and the context of each job. That context isname:or else the id, or<caller> / <called>for a call. The sameexpand()and refusals apply.Three shapes are reported:
Two workflows. For each required context, every pair of workflows that produce it on a pull request is a DUPLICATE, reported once per pair. The pairs come straight from the producer list, so
ALLOWon either job does not hide them. The message names both workflows and their events:One workflow on both
pull_requestandpull_request_targetis also a DUPLICATE.Several producers. These findings do not overlap with feat: flag a required check that push also produces on a pr branch #17/feat: flag a required check that another workflow produces on push #19's push rules; each one names a different pair of suites. Three workflows give three findings.
pushandpull_requestin another workflow are two distinct suites from that workflow, so they give two findings, plus that workflow's own feat: flag a required check that push also produces on a pr branch #17 finding. Cases pin each count.Refused (exit 2), as #18 does:
pull_request_targetreports on the PR headUntil now, a required check produced only by
pull_request_targetwas ORPHANED, on the grounds that "its check runs attach to the base commit". That is wrong. I checked it against the issue's cpython run and two more independent repositories:head_shacommits/<sha>/check-runsstatusCheckRollup77e73c2warsaw/cpython)a965781a965781(same-repo branch,compare main...shaahead 10, behind 0)event: pull_request_target7af445b7af445b(forkabdulmajidcse/react, ahead 1, behind 0)event: pull_request_targetThe commands were:
gh api repos/<o>/<r>/actions/runs/<id>/jobsgh api repos/<o>/<r>/pulls/<n>gh api repos/<o>/<r>/commits/<sha>/check-runsstatusCheckRollup.contextswithcheckSuite.workflowRun.eventreact's head also carries
check_accessfrom two differentpull_request_targetworkflows, which is this issue's hazard in a real repository.GitHub's documentation says such checks count. The source is github/docs
content/pull-requests/how-tos/merge-and-close-pull-requests/troubleshooting-required-status-checks.md, lines 55–64 at60f9f34:That a ruleset accepts a
pull_request_targetcheck is the documentation's statement; I have not watched a kindspec ruleset do it. The same page answers the issue's "unverified routes":workflow_dispatchon a PR branch does not count, andcreateis not in the list.So
pull_request_targetis now a pull request event:pull_request_targetalone produces is no longer ORPHANED; it gets TARGET-ONLY instead (below).pull_request_targetjob that is not required is UNGATED, like apull_requestjob. A job meant to gate nothing, such as a labeller, goes inALLOWwith that reason.TARGET-ONLY: a check that may be unable to fail
A
pull_request_targetjob runs the workflow file from the default branch of the base repository, withGITHUB_SHAthat branch's last commit. Unless it checks out the pull request, it runs on the default branch's code, so it may be unable to fail because of the pull request. That is the §2.2 pattern. The audit cannot tell whether a job checks out the pull request, so:pull_request_targetjobs is TARGET-ONLY, exit 1.TARGET_OK, a new set of(repo, context): reason, accepts one, and only that finding. The job's other findings (CONDITIONAL, TRIGGER, PATH-FILTER, DUPLICATE) still fire.ALLOWsilences only UNGATED, for a job that gates nothing. A required job inALLOWis checked like any other. An allowlisted job also still counts as producing its contexts; before, a required context from one was left ORPHANED. An allowlisted job whose context cannot be resolved, such as a matrix labeller, is still not refused, as on main. If it produced a required check, that check would be ORPHANED, so this is not a silent pass.pull_requestgets no TARGET-ONLY; the DUPLICATE rule covers it instead.pushproducer clears TARGET-ONLY. If apushrun on a pull request's branch also produces the check, in the same workflow or another, TARGET-ONLY does not fire. That run tests the pull request's code, so the check can fail because of the pull request, and the shape is already a DUPLICATE. I chose this over rewording the message, because a message saying the check "may be unable to fail" would be false there. The message now says the check is "reported on a pull request only by pull_request_target".PATH-FILTER and TRIGGER, per event
Each event that reaches the PR is blocked by a path filter, or by
types:that omit opened or synchronize.its pull_request_target trigger is path-filtered, orpull_request_target types ['labeled'] omit ….pathson one event withtypes: [labeled]on the other gives both findings, andtypes: [opened]withtypes: [synchronize]gives neither.types: []now gives TRIGGER (types[]). Before, it gave nothing, because the empty list read as false. GitHub does not documenttypes: [], so the audit reads it fail-closed, and the docstring says so.Measured
Live, with this branch:
No TARGET-ONLY in the org. The summary now says "pull request jobs", because the count includes
pull_request_targetjobs.The issue's reproduction. I added
.github/workflows/second.ymlto kindkit in that snapshot, with jobcheck(runs-on: ubuntu-latest, onerun: 'true'step) andworkflow_stateactive:Main's figures match the issue's.
pull_request_target only. On the same snapshot I changed kindkit's
check.ymlfrompull_requesttopull_request_target:ruff checkandruff format --check(kindkit's ruff 0.16.6,--line-length 100) are clean onscripts/.Shown to fail
Red first. I grafted this branch's self-test onto main's logic, as #15, #17 and #19 did: main's module up to
# --- self-test, then this branch's self-test section. Main givesselftest: 185 of 225 as expected, exit 1. The threeTARGET_OKcases crash there, because main'sevaluatetakes no such argument:Some new cases pass on main, and the sweep pins them:
pull_requestfiltered todevelop;pull_request_targetwithbranches-ignore: [main];pull_requestjob with a different name;merge_groupjob of the same name;disabled_manuallyworkflow;disabled_inactivityworkflows, onepull_request_targetand onepush;pull_request_targetinto another branch, which is still ORPHANED;disabled_inactivityworkflow that produces nothing, which is ORPHANED;At this head:
selftest: 225 of 225 as expected.The
disabled_inactivitygap the issue names. I mutated main'sif state != "active"toif state == "disabled_manually". Main's self-test then givesselftest: 173 of 173 as expected, exit 0, so the mutant survives there. On this branch, that mutant and the same one inproducers()arecaught.Mutation sweep. Against main, 128 mutants become 158: 33 added and 3 removed, all three replaced by mutants on the rewritten code. 8 keep their label but have new patterns, because the code they target was rewritten or renamed: the four push-doubt mutants, "push producers not collected", "path filter ignored", "restrictive types ignored" and "types check inverted". The lines for the new and rewritten mutants on the pull request rules:
The one
hungis #19'sunclosed [ accepted, which loops forever by construction and is counted red. Every other line iscaught. The hash is that of the committed file.No CI runs the self-test or the sweep; the daily audit workflow runs the audit only.
Docs
pull_requestorpull_request_target, and one workflow on both, whether or notALLOWnames either job.TARGET_OKaccepts one andALLOWdoes not.TARGET_OK.pull_request_targetamong events that never report on the PR, and says why;pull_request_review,deploymentordeployment_statusas producers, although the same page lists them as eligible. Nor does it flag two jobs of one workflow with the same context.evaluatereturns no finding for either. These are left to a separate issue.Review
An independent review of the first head found one HIGH and two MEDIUMs, plus some LOWs. This head addresses each one.
ALLOWon the first-by-path producer hid the pull request DUPLICATE, and the audit exited 0. Pairs are now computed from the producer list, independently ofALLOW. The reviewer's A4, A4c and A5b are now cases, and all three are red on main.The reviewer's harness against this head:
attack.pygives the following:mymut.py:Second round, at
a85060d. H1 and M1 were confirmed fixed, and the reviewer re-derived every figure. One new HIGH, one MEDIUM and one LOW came up; this head addresses each one.ALLOWalso silenced every other finding on that required job, because allowlisted jobs skipped the whole loop.ALLOWnow silences UNGATED only, and the newTARGET_OKaccepts TARGET-ONLY only. The reviewer's B3b and B4 are now cases, as is "TARGET_OK keeps CONDITIONAL".attack2.pyat this head: every one of B1 to B9 exits 1.ma?npush filter is settled, not undecided.?makes theaoptional, so the filter admitsmn, a witness branch, and the result is a DUPLICATE. Any finding gives exit 1 in any case.types: []and a lonetypes: [opened]are now cases that give TRIGGER. "Every producer allowlisted still pairs" is a case too.len(pr_events_of.get(context, ())) == 0) is now an equivalent mutant: allowlisted jobs record their events, so the condition never holds for a context that has a producer.pushproducer clears TARGET-ONLY, as above, and the message is reworded.mymut2.pyat this head:Not reviewed again since the second round.
Closes #20