27. Pull Request Jobs Never Skip; Each Job Filters Its Own Paths¶
Date: 2026-10-14
Status¶
Accepted. Supersedes ADR-0026. Point 5 is amended by ADR-0035.
Context¶
ADR-0026 let required-checks accept a skip from the jobs a change detector gates. Each of ci.yml and security-scans.yml ran a changes job; ten jobs needed it and skipped through a job if: when no path they test changed.
The engine audit (HISS-18, praetor docs/guides/workflow-triggers.md) reported 17 jobs that ran on a draft pull request. Its rule for a job a pull request starts:
- The first step is the draft step
Stop on a draft pull request, which fails the job on a draft. The workflow restarts onready_for_review, and the new run reports the same check on the same commit. - No
needson such a job. A job that needs a draft-stopped job is skipped, and the forge reports that skip as success. - No job condition that a draft or a path decides. A skipped job reports success, which a required check accepts.
The detector shape breaks the second and third rule by design. The engine also derives the required contexts of .github/rulesets/main.json from the workflows: a job that reports on every pull request is required by name, and a job with a condition is not (praetor internal/forge/workflow_checks.go, requiredJob).
Decision¶
- Every job a pull request starts begins with the draft step, carries no job condition and no
needs, and its workflow listsready_for_reviewamong thepull_requesttypes. - A path-filtered job filters inside itself. After the draft step and the checkout it runs
dorny/paths-filter(pinned by commit) on.github/path-filters.ymlwith step idfilter, then a notice step, and gates every later step ongithub.event_name != 'pull_request' || steps.filter.outputs.<job id> == 'true'. A step that already had a condition keeps it as the left conjunct. A push or a scheduled run reads no filter and runs every step. - One filter file.
.github/path-filters.ymlholds one filter per filtered job, named after the job id, built from anchored blocks. Every filter includes the filter file and the job's own workflow. - The
changesjobs are removed, and with them the contextsDetect Changed PathsandDetect Security Scan Paths. required-checksaccepts no skip.MAY_SKIPand the second wait step are removed; one wait step allowssuccessonly.- The rule set requires the jobs by name.
.github/rulesets/main.jsonlists the 19 contexts the engine derives from the workflows; the online audit compares the forge with it. No required context names a toolchain version: the Go job isGo Build & Unit Tests, so a toolchain bump from the update bot, which cannot change the live rule set, never renames a required check. - Tests hold the shape.
tests/test_required_aggregator.pychecks every pull request job for the draft step, no condition, noneedsand theready_for_reviewtrigger, and refuses planted jobs.tests/test_path_filters.pychecks every filtered job's steps and gates, the filter keys against the filtered jobs, and every pattern against a tracked file, and refuses planted jobs.
Consequences¶
- Positive: no required check can pass by being skipped; the aggregator needs no list of names.
- Positive: the engine audit reports no HISS-18 finding, and a draft pull request shows every gate red with the draft notice instead of running the work.
- Negative: a filtered job with nothing to check still starts a runner for the checkout and the filter, where the detector used to start one runner per workflow. The cost per pull request is recorded in the pull request that introduced this decision.
- Negative: renaming a job changes a required context; the rename and
.github/rulesets/main.jsonchange in the same pull request, and the engine audit fails until they agree. - Neutral: the
semgrepandtrivyfilters keep the path list of the old detector, which omitscmd/**andpkg/**; widening it is a separate change.