Skip to content

CI paths: dirsql-checks ci-paths — assert workflow filters and Mergify anchors agree #862

Closed as not planned

Description

@thekevinbot

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 if must never apply more broadly than its workflow's paths: 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:

  1. Take the corpus of candidate paths: every file from git ls-files, plus a synthetic entry per distinct glob in every paths: list (so a glob covering files that do not exist yet is still exercised).
  2. For each (W, P) pair and each path f, evaluate both sides and assert the implication above.
  3. 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

Implementation

  • internals/checks/src/checks/ci_paths/ — new subcommand, registered on the click group in cli.py, following the shape of the existing changelog_gate / declared_deps siblings.
  • Colocated unit tests (*_test.py, per AGENTS.md — suffix, never test_ prefix), mocking the filesystem and git ls-files. The internals-checks lane 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

MUST NOT run

  • Python Test, TS Test, Rust Test, Plugins Test, Python Lint, Python Typecheck, TS Lint, TS Typecheck
  • the seven non-internals-checks Conventions 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-paths passes against the post-epic .mergify.yml and 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 Protections green
  • Merge, close this issue and Epic — CI: run only the checks relevant to the changed paths #834

Notes

  • No changelog fragment (internals/ is not packages/<pkg>/), no e2e attestation, no PARITY.md change.

Activity

  1. thekevinbot commented on Aug 14, 2026

    @thekevinbot
    CollaboratorAuthor

    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.yml to a single condition:

    success_conditions:
      - "#check-failure = 0"

    There are no per-workflow anchors left. The central invariant here — P.if matches f ⟹ W.paths matches f — has no P side any more, and with it go the "every merge_protections entry other than Nothing failed has an if: containing base = main" and "every success_conditions check 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 = 0 and no anchors, a PR that triggers zero checks is vacuously green. CI-config-only PRs are exactly that class: .github/workflows/** and .mergify.yml appear in no workflow's paths:, 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, running dirsql-checks ci-paths plus actionlint. .github/actionlint.yaml already 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.yml anchor (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 in dirsql-python-ci.yml, dirsql-typescript-ci.yml and release-ci.yml, where a bare packages/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's push: and pull_request: paths lists agree unless a reason comment sits directly above.

    Do not assert expected check counts. They drift with the upstream testing-conventions@v0 moving 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-paths passes against the post-epic .mergify.yml" no longer means anything and should be dropped. "Mergify Merge Protections green" still does. The "MUST NOT run" list also needs refreshing against the post-#834 workflow names (Python Test / TS Test / Rust Test and the "seven non-internals-checks Conventions lanes" are all retired).


    Generated by Claude Code

  2. thekevinbot commented on Aug 20, 2026

    @thekevinbot
    CollaboratorAuthor

    Closing in the #934 sweep. The invariant this check enforces compares workflow paths: against .mergify.yml anchors; 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_request paths: parity) — refile narrowly if that drift ever actually bites. Not worth machinery today.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions