Repository navigation
Description
Part of #834. Phase 5 — the durable anti-drift fix. Blocked on #850 through #861 (there is nothing to compare until both sides exist).
What
After this epic, the same path knowledge lives in two places: each workflow's paths: and each .mergify.yml protection's if:. They drift silently, and one direction of drift deadlocks the merge gate — exactly the failure mode #832 cost an admin bypass to escape.
Per AGENTS.md ("CI logic lives in scripts, not workflow YAML"), the fix is a dirsql-checks subcommand.
The invariant to enforce
From #850:
An anchor's
ifmust never apply more broadly than its workflow'spaths:trigger.
Formally, for every anchored workflow W with protection P, and every file path f:
P.if matches f ⟹ W.paths matches f
The forward direction only. The converse (workflow runs unanchored) is a deliberate, safe slack — release-precheck and changelog-check both rely on it — so the check must not flag it.
How to make that mechanically checkable
Do not attempt general regex-vs-glob equivalence. Sample instead:
- Take the corpus of candidate paths: every file from
git ls-files, plus a synthetic entry per distinct glob in everypaths:list (so a glob covering files that do not exist yet is still exercised). - For each
(W, P)pair and each path f, evaluate both sides and assert the implication above. - Report every violating
(protection, path)pair, not just the first.
Every tracked file in the repo becomes a test case, and the direction that deadlocks the gate is the direction that fails.
Also assert
- Every
merge_protectionsentry other thanNothing failedhas anif:containingbase = mainand anor:block. An ungated anchor is the CI:#check-pending = 0deadlocks the Mergify gate (it counts its own check) #832 bug. - Every
success_conditionscheck pattern maps to a job id that exists in exactly one workflow file — catches a job rename that silently unblocks the gate. - Every workflow with a
paths:filter names its own file in that filter (spec row 16). This is the gate that would have caught the four missing self-paths (CI paths: Python Lint — add self-trigger path #843, CI paths: Python Typecheck — add self-trigger path #844, CI paths: TS Lint — add self-trigger path #845, CI paths: TS Typecheck — add self-trigger path and tsconfig.build.json #846). - Both
paths:lists of a workflow that haspush:andpull_request:are identical, unless a reason comment sits directly above.
Implementation
internals/checks/src/checks/ci_paths/— new subcommand, registered on the click group incli.py, following the shape of the existingchangelog_gate/declared_depssiblings.- Colocated unit tests (
*_test.py, per AGENTS.md — suffix, nevertest_prefix), mocking the filesystem andgit ls-files. Theinternals-checkslane enforces a 100% unit-coverage floor and runs the mutation gate, so every branch needs an assertion that kills its mutant. - Failure output must be actionable per AGENTS.md: name the protection, the workflow, the offending path, and which side to edit — not just "mismatch".
Red/green applies normally here. Unlike the rest of this epic, this is ordinary code: write the failing unit tests first, push them, confirm CI red for the right reason, then implement. The "the PR's own check list is the test" shortcut covers workflow triggering, which is not what this slice changes.
Wiring it into CI
Add .github/workflows/ci-paths-check.yml:
name: CI Paths
on:
pull_request:
branches: [main]
paths:
- '.github/workflows/**'
- '.mergify.yml'
- 'internals/checks/**'
- '.github/workflows/ci-paths-check.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
ci-paths:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: astral-sh/setup-uv@v5
- run: uv run --project internals/checks dirsql-checks ci-paths(.github/workflows/** already covers the self-path; it is listed separately only for legibility — drop the duplicate if you prefer.)
Then add the matching gated anchor to .mergify.yml, following the phase-2 pattern:
- name: CI Paths reported
if:
- base = main
- or:
- files ~= ^\.github/workflows/
- files ~= ^\.mergify\.yml$
- files ~= ^internals/checks/
success_conditions:
- "check-success = ci-paths"The new check must pass against its own configuration — that anchor's if is an exact mirror of the workflow's paths:, so it satisfies the invariant by construction.
Expected checks on this PR
Files touched: internals/checks/**, .github/workflows/ci-paths-check.yml, .mergify.yml.
MUST run
ci-paths— the new check, on itselfinternals-checks / …—internals/checks/**is its triggerRelease Precheck— it runsdirsql-checks(release-precheck.yml:59,:88,:119), sointernals/checks/**is in its filter (CI paths: Release Precheck — stop running on every PR #851)Changelog Check— same reason (changelog-check.yml:29, CI paths: Changelog Check — stop running on every PR #853)
MUST NOT run
Python Test,TS Test,Rust Test,Plugins Test,Python Lint,Python Typecheck,TS Lint,TS Typecheck- the seven non-
internals-checksConventions lanes Release Config Check
This is also the end-to-end confirmation of spec row 8 — an internals/checks/** change reaching exactly its three declared consumers and nothing else.
Acceptance
- Unit tests pushed red first, CI observed failing for the right reason, then implementation
-
ci-pathspasses against the post-epic.mergify.ymland workflow set - Deliberately break one glob locally and confirm the check fails with an actionable message naming the protection, the workflow and the path
- The PR's check list matches the groups above exactly
-
Mergify Merge Protectionsgreen - Merge, close this issue and Epic — CI: run only the checks relevant to the changed paths #834
Notes
- No changelog fragment (
internals/is notpackages/<pkg>/), no e2e attestation, noPARITY.mdchange.
Activity
thekevinbot commentedon Aug 14, 2026
Re-scope after #881 and the #834 stack
Two things changed since this issue was written, and together they invert what the check should assert.
1. The anchors this issue compares against no longer exist. #881 reduced
.mergify.ymlto a single condition:There are no per-workflow anchors left. The central invariant here —
P.if matches f ⟹ W.paths matches f— has noPside any more, and with it go the "everymerge_protectionsentry other thanNothing failedhas anif:containingbase = main" and "everysuccess_conditionscheck pattern maps to a job id" assertions. Nothing to compare.2. A different hole opened in its place, and it is the one worth closing. With a bare
#check-failure = 0and no anchors, a PR that triggers zero checks is vacuously green. CI-config-only PRs are exactly that class:.github/workflows/**and.mergify.ymlappear in no workflow'spaths:, so a change to the CI system itself merges unverified. Post-#881 that is the highest-value thing this check can address.Proposed scope
(a) A workflow that actually runs on CI config. A new workflow triggered on
.github/workflows/**,.mergify.yml, and the check's own sources, runningdirsql-checks ci-pathsplus actionlint..github/actionlint.yamlalready sits in the repo and nothing invokes it today — wiring it in here gives the lane real content beyond the bespoke check and closes the zero-check-merge hole for the paths where it bites hardest. The workflow sketch in the issue body above still applies; it just gains an actionlint step and drops the accompanying.mergify.ymlanchor (there is nowhere to add one).(b) Assert filter consistency, never check-count integers. The surviving cross-file invariant is workflow-to-workflow rather than workflow-to-Mergify. Concretely: every consumer of a glob carries the same exclusions alongside it — every workflow listing
packages/rust/**should also list!packages/rust/changelog.d/**and!packages/rust/migrations.d/**. That is precisely the drift this epic just repaired by hand indirsql-python-ci.yml,dirsql-typescript-ci.ymlandrelease-ci.yml, where a barepackages/rust/**fanned a rust fragment edit into 47 irrelevant checks.Two assertions from the original scope survive the anchor removal unchanged and should stay: every workflow with a
paths:filter names its own file, and a workflow'spush:andpull_request:paths lists agree unless a reason comment sits directly above.Do not assert expected check counts. They drift with the upstream
testing-conventions@v0moving tag — a gate added or renamed upstream changes the integer with no change in this repo, so a count-based assertion goes red on someone else's release. Counts made an excellent manual probe for this epic; they would make a brittle gate.Consequences for the acceptance criteria
"
ci-pathspasses against the post-epic.mergify.yml" no longer means anything and should be dropped. "Mergify Merge Protectionsgreen" still does. The "MUST NOT run" list also needs refreshing against the post-#834 workflow names (Python Test/TS Test/Rust Testand the "seven non-internals-checksConventions lanes" are all retired).Generated by Claude Code
thekevinbot commentedon Aug 20, 2026
Closing in the #934 sweep. The invariant this check enforces compares workflow
paths:against.mergify.ymlanchors; Mergify (and the file) were removed in #950, so the deadlock failure mode it guards against no longer exists. Two of its side-asserts would still catch real drift (every path-filtered workflow names its own file; push/pull_requestpaths:parity) — refile narrowly if that drift ever actually bites. Not worth machinery today.