There's a meme for this: a soldier staring into the middle distance, captioned "POV: watching the CI/CD pipeline run 1,800 tests for a one-line change that fixes a typo." Punchline: "That typo was also in a comment." Everyone who's watched a full E2E suite spin up for a README change recognizes that specific exhaustion.
Skipping tests on docs changes is the right instinct. The implementation is where it goes sideways.
The Landmine
The first thing most people reach for is trigger-level path filtering:
on:
pull_request:
paths-ignore:
- '**/*.md'
- 'docs/**'
This looks right. It's a landmine.
If those test jobs are required status checks in your branch protection rules, here's what happens on a docs-only PR: the workflow never triggers, the checks never run, they never report a status. GitHub doesn't interpret "didn't run" as "passed." It interprets it as "still waiting." Your PR sits there with the merge button greyed out, indefinitely, until someone with admin access manually overrides it.
Job-level if: conditions have the same problem. Skip a required job and GitHub waits on a verdict that's never coming.
So the real question isn't how do I skip jobs. It's how do I skip jobs while still giving the merge gate a definite answer.
How It Actually Works
Three parts:
A detection job runs first and figures out what actually changed. Work jobs each gate on that output and only run when relevant. A gate job always runs, depends on everything, and is the only required check. It passes when every job either succeeded or was intentionally skipped, and fails when anything actually broke.
That third piece is the whole trick. You stop requiring individual test jobs and start requiring one aggregator that knows the difference between "skipped on purpose" and "failed."
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
changes:
runs-on: ubuntu-latest
outputs:
code: ${{ steps.filter.outputs.code }}
frontend: ${{ steps.filter.outputs.frontend }}
docs: ${{ steps.filter.outputs.docs }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
code:
- 'src/**'
- 'lib/**'
- 'package.json'
- 'package-lock.json'
frontend:
- 'src/components/**'
- 'src/routes/**'
docs:
- 'docs/**'
- '**/*.md'
lint:
needs: changes
if: ${{ needs.changes.outputs.code == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run lint
unit:
needs: changes
if: ${{ needs.changes.outputs.code == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
e2e:
needs: changes
if: ${{ needs.changes.outputs.frontend == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run test:e2e
gate:
if: always()
needs: [changes, lint, unit, e2e]
runs-on: ubuntu-latest
steps:
- name: Fail if any dependency failed or was cancelled
if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
run: |
echo "A required job did not pass."
exit 1
- name: All clear
run: echo "Everything passed or was skipped on purpose."
Why a Separate Detection Action
You could hand-roll the diff with git diff against the base ref. I've done this. The base ref behaves differently between push and pull_request events, force-pushes complicate the history, and merge commits are their own afternoon. dorny/paths-filter handles all of it and returns clean boolean outputs you can branch on directly. Outsource the boring part.
Worth noting: the filters are independent. Touch src/components/Button.tsx and both code and frontend flip to true, so lint runs and E2E runs. That's the right behavior, and it falls out naturally.
Why the Gate Job Is the Point
gate:
if: always()
needs: [changes, lint, unit, e2e]
if: always() is load-bearing. Without it, the gate would itself be skipped the moment any upstream job is skipped. You'd be right back to a required check that never reports. always() forces it to run regardless of what happened upstream.
Then it checks:
if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
needs.*.result gives you an array of every dependency's outcome: success, failure, cancelled, or skipped. The gate fails only on failure or cancelled. A skipped job is fine — that was the plan. A genuinely broken job trips the gate, and the gate is what merge is waiting on.
Docs-only PR: lint, unit, and e2e all skip. Gate sees nothing but skipped and success, reports green, merge in seconds. Real frontend change: E2E runs, something breaks, gate goes red, merge is blocked. Both cases give the merge gate a definite, honest answer. That's the whole job.
The Step People Skip
In your branch protection settings, remove the individual jobs from the required checks list and require only gate.
If you leave e2e as a required check, you've reintroduced the original deadlock. A skipped e2e hangs the PR even when the gate is green. One required check. The gate is your single source of truth for whether a PR is mergeable.
This is the step that makes everything else work. It's also the step that doesn't happen automatically when you push the workflow file.
The Honest Boundary
Path filtering can prove a change touched a different file. It cannot prove a change to a source file is semantically inert.
Remember that punchline about the typo being in a comment? That's the honest limit of this pattern. Path filtering sees src/auth/login.ts changed and flips the code flag. It can't tell the only edit was a comment that does nothing at runtime. The tests run anyway.
That's not a bug worth fixing. AST-level diffing to detect comment-only changes is language-specific, fragile, and almost never worth the maintenance. Run the tests, eat the cost on that rare case. Path-level skipping wins you the common case: the docs PR, the config tweak, the CI YAML edit. That's the 80% that was wasting afternoons.
When to Graduate
Path globs are a hand-maintained map of your repo. Maps drift. Add a src/shared/ directory that half your packages import and your filters silently stop reflecting reality. You start skipping tests you needed.
That's the signal to move to dependency-aware skipping: Nx, Turborepo, and Bazel build an actual graph of what depends on what, compute the affected set from your diff, and run only the downstream tasks. Touch a shared utility and everything that imports it reruns. Touch an isolated package and only that suite runs. Same idea, done with a real dependency graph instead of globs you maintain by hand.
This pattern is the right starting point. Single YAML file, no extra tooling, works today. Reach for the graph tools when your filters start lying to you.
The Short Version
Skipping CI jobs isn't hard. Skipping them without breaking merge is the part that trips people. The answer is one always-running gate job that knows the difference between "skipped on purpose" and "actually failed." Require that one check, let everything else run conditionally, and the soldier in that meme can finally finish his cigarette.
No comments yet, add yours here.