Stop Running Every Check on Every Commit
by Eric Hanson, Backend Developer at Clean Systems Consulting
The Full Pipeline on a README Change
A developer fixes a typo in the README. The pipeline starts: unit tests, integration tests, Docker build, security scan, staging deployment — all of it. 38 minutes later, the PR is green. The code that changed was documentation.
This is the default behavior of almost every CI system configured without path filtering. It's also wasteful in a way that compounds across dozens of commits per day. Worse, it conditions developers to see CI as a bureaucratic hurdle rather than a useful feedback loop — because a feedback loop that takes 38 minutes for a typo fix is not actually giving feedback, it's just burning time.
Path-Based Filtering: The Foundational Technique
Most CI platforms support conditional job execution based on which files changed in the commit. This single capability can eliminate a large fraction of unnecessary pipeline runs.
# GitHub Actions: only run backend tests when backend code changes
on:
push:
paths:
- 'backend/**'
- 'shared/**'
- '.github/workflows/backend-ci.yml'
jobs:
backend-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./gradlew test
# Different workflow for frontend changes
on:
push:
paths:
- 'frontend/**'
- '.github/workflows/frontend-ci.yml'
jobs:
frontend-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test
The paths filter means the backend pipeline doesn't run when only frontend/ changes. In a monorepo with multiple services, this is the difference between a 40-minute pipeline and a 6-minute pipeline for changes that touch one service.
Important caveat: the paths filter doesn't work well when there's real shared code that affects multiple services. If shared/ changes, you need to know which services depend on it and run those tests. This is where dependency graphs matter.
Dependency-Aware CI in Monorepos
For monorepos with multiple services or packages, the naive approach (run everything when anything in shared/ changes) is correct but expensive. The better approach is a dependency graph: each service declares what it depends on, and the CI system calculates the minimal set of services to rebuild and retest based on what actually changed.
Nx (for Node.js/TypeScript monorepos) and Gradle (for JVM monorepos) both handle this natively:
# Nx: only run tests for affected projects since last commit to main
npx nx affected --target=test --base=origin/main
# Gradle: only compile/test subprojects that depend on changed modules
./gradlew :service-a:test --parallel # if service-a was determined to be affected
For Nx, affected uses your nx.json project graph to determine what changed. For Gradle, you can build a similar system using the gradle-enterprise plugin or a custom input fingerprinting strategy.
Stage-Level Gating Inside a Pipeline
Even within a single service pipeline, not every check needs to run on every commit. A useful pattern is tiered execution:
- On every commit: compile, unit tests (fast, in-process)
- On PR to main: + integration tests, lint, SAST scan
- On merge to main: + Docker build, container scan, staging deploy
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- run: ./gradlew test
integration-tests:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- run: ./gradlew integrationTest
build-and-deploy:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
needs: [unit-tests]
steps:
- run: ./gradlew bootJar
- run: docker build -t myapp .
- run: ./deploy.sh staging
The integration tests and Docker build don't run on every push to a feature branch. They run on PRs and on merge. This cuts the per-commit pipeline time significantly while keeping the pre-merge gate thorough.
The Risk: Missing a Signal
The obvious concern with selective execution is missing a failure. If you don't run the full suite on every commit, a broken commit might not be caught until it hits the PR gate.
This is acceptable if your branch lifetime is short (under a day) and your PR gate is thorough. If developers push once, wait for feedback, and push a fix — the delay in detection is a few hours, not a sprint. That's a reasonable trade for halving your pipeline runtime.
What's not acceptable: selective execution on the merge-to-main gate. Whatever you decide not to run on feature branch commits, run all of it before anything merges. The tiering described above maintains this invariant.
The Documentation Pipeline
Back to the README typo. A separate, minimal pipeline for documentation changes:
on:
push:
paths:
- '**/*.md'
- 'docs/**'
jobs:
lint-docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npx markdownlint-cli '**/*.md'
Check markdown syntax. That's all. Two minutes. Done.
The goal is matching the check to the risk. Documentation changes carry documentation-level risk. Code changes carry code-level risk. Running the same pipeline for both is not cautious — it's indiscriminate.