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.
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.
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:
- Path filters decide whether the expensive work runs for a given PR.
- 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.
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, andDocsalways run as jobs; on the fast path each simply executes no steps. That is asuccessconclusion — deliberately not askippedone, because branch protection treats a skipped job's context inconsistently. Non-required jobs can safely use a job-levelifand 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). *.mdis scoped to the repo root, not**/*.md. Markdown undersrc/templates/is compiled into the binary withinclude_str!and covered by unit tests, so editing it is a code change. Fast-pathing**/*.mdwould let a template edit skip the tests that guard it.ci.ymlis 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
mainand release tags bypass the filter and run everything.
- 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.
Testwith arust: [stable, beta, nightly]matrix publishesTest (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.
- Twin workflows — the pattern originally sketched in issue #598: a heavyweight
ci.ymlwithpaths/paths-ignore, plus a no-opci-skip.ymlthat 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 plainpaths-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.
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.
.github/workflows/ci.yml— the live implementation and the source of truth for the exact path globs.ai claude skills— Distribute Skills Across Repositories — the symlink-based alternative to keeping skills in-repo.