Skip to content

Latest commit

 

History

History
172 lines (147 loc) · 7.74 KB

File metadata and controls

172 lines (147 loc) · 7.74 KB

CI Path-Split: Fast-Path Docs/Skills PRs Past Heavyweight Required Checks

A branch-protection required check is satisfied by any run that reports the right status name. A cheap path-aware "gate" can therefore let documentation- and skills-only PRs report every required check as green in seconds, while code PRs still run the full Rust pipeline. This repo implements the pattern in .github/workflows/ci.yml (issue #609); this doc explains it so it can be reused elsewhere.

The problem

Branch protection on main requires a set of checks — in this repo Test (stable), Rustfmt, Clippy, and Docs. Every one of them compiles the crate, so a PR that only edits a .md file or a .claude/skills/ manifest waits several minutes for results that can't possibly change. That friction is exactly what motivated distributing skills out-of-repo via symlinks (ai claude skills sync, issue #558). Splitting CI removes the friction at its source: skills and docs can live in the repo, travel with clones and worktrees for free, and still merge in seconds.

The principle

GitHub matches required status checks by name. Branch protection asks "did a check named Clippy report success?" — it does not care which workflow or job produced it. So there are two levers:

  1. Path filters decide whether the expensive work runs for a given PR.
  2. The check name stays constant either way, so the required context is always reported.

Combine them and a docs-only PR can publish a passing Clippy context without ever invoking cargo clippy.

The as-built pattern (single gate)

This repo uses a single gate job whose one boolean output every other job keys off. There is no second workflow to keep in sync.

on:
  pull_request:
    branches: [ main ]

jobs:
  # One source of truth for the split.
  gate:
    name: Gate
    runs-on: ubuntu-latest
    outputs:
      code: ${{ steps.decide.outputs.code }}
    steps:
    - uses: actions/checkout@v7
    - uses: dorny/paths-filter@v3
      id: filter
      if: github.event_name == 'pull_request'
      with:
        filters: |
          code:
            - '**'          # start from "everything is code"...
            - '!*.md'       # ...then subtract the doc-only paths
            - '!docs/**'
            - '!.claude/skills/**'
    - id: decide
      run: |
        # Non-PR events (main pushes, release tags) always run full CI.
        if [ "${{ github.event_name }}" != "pull_request" ]; then
          echo "code=true" >> "$GITHUB_OUTPUT"
        else
          echo "code=${{ steps.filter.outputs.code }}" >> "$GITHUB_OUTPUT"
        fi

  # A REQUIRED context: the job always runs (so the context is always
  # reported), but every step is guarded on the gate. On the fast path the
  # job is a genuine *passing* job with zero steps executed.
  clippy:
    name: Clippy
    needs: gate
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v7
      if: needs.gate.outputs.code == 'true'
    - uses: dtolnay/rust-toolchain@stable
      if: needs.gate.outputs.code == 'true'
      with: { components: clippy }
    - name: Run clippy
      if: needs.gate.outputs.code == 'true'
      run: cargo clippy --all-targets -- -D warnings

  # A NON-required job: skip it wholesale with a job-level `if`.
  coverage:
    name: Coverage
    needs: gate
    if: needs.gate.outputs.code == 'true'
    runs-on: ubuntu-latest
    steps:
    - run: echo "full-CI-only work"

The mechanics that make it safe:

  • Required contexts guard steps, not the job. Test, Clippy, Rustfmt, and Docs always run as jobs; on the fast path each simply executes no steps. That is a success conclusion — deliberately not a skipped one, because branch protection treats a skipped job's context inconsistently. Non-required jobs can safely use a job-level if and vanish entirely.
  • The filter is negative, so it fails safe. It starts from '**' (everything counts as code) and subtracts known doc paths. Anything a contributor adds that isn't explicitly a doc — a new source dir, a config file, a build script — runs full CI by default. There is no "unmatched path produces no status" deadlock (see Caveats).
  • *.md is scoped to the repo root, not **/*.md. Markdown under src/templates/ is compiled into the binary with include_str! and covered by unit tests, so editing it is a code change. Fast-pathing **/*.md would let a template edit skip the tests that guard it. ci.yml is the source of truth for the exact glob set — mirror it there, don't re-derive it here.
  • Non-PR events always take the full path. Pushes to main and release tags bypass the filter and run everything.

Caveats

  • Path filters must be exhaustive. If a PR touches paths matched by neither the code set nor the doc set, no run is produced, the required context never reports, and the PR is stuck "waiting" forever. The single-gate negative filter above is inherently exhaustive ('**' matches everything, minus a short subtract-list). A twin-workflow split (below) must add an explicit catch-all.
  • Matrix jobs expand into per-context names. Test with a rust: [stable, beta, nightly] matrix publishes Test (stable), Test (beta), … — only the contexts actually listed in branch protection need to stay green, but each must still be produced, which the always-run-the-job approach guarantees.
  • Keep the doc glob and the branch-protection set in one head. Adding a new required check, or a new top-level doc directory, means revisiting the gate.

Alternatives (and why this repo didn't use them)

  • Twin workflows — the pattern originally sketched in issue #598: a heavyweight ci.yml with paths/paths-ignore, plus a no-op ci-skip.yml that runs on the inverse paths and publishes stub jobs with the same names (Clippy, Test (stable), …) that immediately succeed. It has a simple mental model, but the stub names must be hand-mirrored against the required-check set and silently rot the moment the matrix, a job name, or the protection set changes — a stale stub reports green for a check that no longer means anything, or a renamed required check is left with no producer on the fast path. The single gate keeps one source of truth, which is why this repo chose it.
  • Native paths-ignore + "skipped counts as success." GitHub's branch- protection UI can treat a skipped required check as passing. Where that setting is available and enabled, a plain paths-ignore: on the workflow is the least-code option — try it first. The workflow-level split here is the fallback for when that setting isn't available or its skipped-vs-success semantics can't be relied on.

When to prefer this over skills sync

This CI split and ai claude skills sync (issue #558) both target the same goal — iterating on skills without slow CI. Prefer the CI split when:

  • You control the CI workflow.
  • Required checks are the main iteration blocker (not team policy or cross-repo skill sharing).
  • Skills naturally belong with the project — its history, collaborators, and clones.

Prefer skills sync when CI config is outside your control, or skills genuinely belong in a separate source-of-truth repo distributed to many consumers.

See also