Add fast static analysis to pull requests without blocking developers
This guide shows how to add static analysis to pull requests in a way developers will actually tolerate: fast local runs, changed-files scoping, caching, and a non-blocking rollout path. You’ll end with a CI job that comments or fails on real issues, while keeping feedback under a couple of minutes for typical PRs.
TL;DR — Add static analysis to pull requests in two layers: a fast PR job that runs only on changed files with cache enabled, and a full scan on the default branch or nightly. The most common fix for "CI got too slow" is to stop scanning the whole repo on every PR and wire the analyzer to
git diff --name-only origin/main...HEADinstead. Reading time: ~5 min
Goal
When you finish, every pull request will run static analysis automatically in CI, return a clear pass/fail exit code, and stay fast enough that developers do not bypass it. You will also have a local command that matches CI so engineers can reproduce failures before pushing.
Prerequisites
- Git repository with pull requests enabled and a default branch such as
main - CI system that can run shell commands on PRs and on pushes to the default branch
git >= 2.39— check with:
git --version
bashor compatible shell in CI runners- One language toolchain already used by your repo, for example:
- Node.js
>= 20— check withnode --version - Python
>= 3.11— check withpython --version
- Node.js
- A static analyzer already standard for your stack, for example one of:
- JavaScript/TypeScript:
eslint >= 9 - Python:
ruff >= 0.6
- JavaScript/TypeScript:
- Permission to edit CI config and branch protection rules
- The exact default branch name, confirmed with:
git remote show origin | sed -n '/HEAD branch/s/.*: //p'
Steps
Step 1: Add a repo-local wrapper script that only scans changed files
Create scripts/static-analysis-pr.sh:
mkdir -p scripts
cat > scripts/static-analysis-pr.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
BASE_REF="${BASE_REF:-origin/main}"
git fetch origin "${BASE_REF#origin/}" --depth=50 >/dev/null 2>&1 || true
mapfile -t files < <(git diff --name-only --diff-filter=ACMR "$BASE_REF...HEAD")
if [ ${#files[@]} -eq 0 ]; then
echo "No changed files to analyze."
exit 0
fi
js_ts_files=()
py_files=()
for f in "${files[@]}"; do
[[ -f "$f" ]] || continue
case "$f" in
*.js|*.cjs|*.mjs|*.jsx|*.ts|*.tsx) js_ts_files+=("$f") ;;
*.py) py_files+=("$f") ;;
esac
done
status=0
if [ ${#js_ts_files[@]} -gt 0 ] && [ -f package.json ]; then
echo "Running ESLint on ${#js_ts_files[@]} file(s)"
npx eslint --max-warnings=0 --cache --cache-location .cache/eslint/ "${js_ts_files[@]}" || status=$?
fi
if [ ${#py_files[@]} -gt 0 ] && command -v ruff >/dev/null 2>&1; then
echo "Running Ruff on ${#py_files[@]} file(s)"
ruff check "${py_files[@]}" || status=$?
fi
exit "$status"
EOF
chmod +x scripts/static-analysis-pr.sh
Success looks like this when run locally on a branch with code changes:
./scripts/static-analysis-pr.sh
Running ESLint on 3 file(s)
Running Ruff on 1 file(s)
Step 2: Add deterministic local commands to your project config
For Node-based repos, add these scripts to package.json:
{
"scripts": {
"lint": "eslint . --cache --cache-location .cache/eslint/",
"lint:pr": "./scripts/static-analysis-pr.sh"
}
}
For Python repos using pyproject.toml, add Ruff config if you do not already have it:
[tool.ruff]
line-length = 100
exclude = ["dist", "build", ".venv"]
[tool.ruff.lint]
select = ["E", "F", "I", "B"]
Success looks like this locally:
npm run lint:pr
or
./scripts/static-analysis-pr.sh
No changed files to analyze.
for a clean branch, or analyzer output for changed files.
Step 3: Add a fast PR job in CI with cache enabled
Use your CI provider’s pipeline file and add a PR job that runs the wrapper script. Example for GitHub Actions in .github/workflows/static-analysis.yml:
name: static-analysis
on:
pull_request:
push:
branches: [main]
schedule:
- cron: "17 3 * * *"
jobs:
pr-fast:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
if: hashFiles('package.json') != ''
with:
node-version: '20'
cache: 'npm'
- uses: actions/setup-python@v5
if: hashFiles('pyproject.toml') != ''
with:
python-version: '3.11'
cache: 'pip'
- name: Install Node deps
if: hashFiles('package-lock.json') != ''
run: npm ci
- name: Install Python deps
if: hashFiles('requirements.txt') != ''
run: pip install -r requirements.txt ruff
- name: Run PR analysis
env:
BASE_REF: origin/${{ github.base_ref }}
run: ./scripts/static-analysis-pr.sh
full-main:
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
if: hashFiles('package.json') != ''
with:
node-version: '20'
cache: 'npm'
- uses: actions/setup-python@v5
if: hashFiles('pyproject.toml') != ''
with:
python-version: '3.11'
cache: 'pip'
- name: Install Node deps
if: hashFiles('package-lock.json') != ''
run: npm ci
- name: Install Python deps
if: hashFiles('requirements.txt') != ''
run: pip install -r requirements.txt ruff
- name: Full ESLint
if: hashFiles('package.json') != ''
run: npx eslint . --max-warnings=0 --cache --cache-location .cache/eslint/
- name: Full Ruff
if: hashFiles('pyproject.toml') != ''
run: ruff check .
Success looks like a PR check named pr-fast completing in under a few minutes and showing a green check.
Step 4: Roll out as non-blocking first, then enforce
In your Git hosting provider’s branch protection settings, add the CI check as required only after one clean week of runs. During rollout, leave it visible but not required. Use your provider’s branch rules UI, for example: repository settings → branches/rulesets → add rule for main → require status checks → select static-analysis / pr-fast.
Success looks like developers seeing the check on every PR, and merges still allowed during the initial tuning period.
Step 5: Fail only on actionable findings
Set analyzers to return non-zero on errors, not style noise. For ESLint, keep --max-warnings=0 only if warnings are already under control; otherwise use:
npx eslint --quiet --cache --cache-location .cache/eslint/ "${js_ts_files[@]}"
For Ruff, keep errors only with:
ruff check "${py_files[@]}"
Success looks like CI failing with a direct file:line message, for example:
src/api/user.ts
14:7 error 'tmp' is assigned a value but never used no-unused-vars
✖ 1 problem (1 error, 0 warnings)
View as text
[Developer] ---- 1. push branch ----> [PR] [PR] ---- 2. trigger fast job ----> [CI] [CI] ---- 3. diff vs main ----> [Changed files] [CI] ---- 4. run analyzers ----> [Status check] [Status check] ---- 5. pass/fail ----> [Merge rule]
Verify it works
Run the local command on a feature branch with a known issue:
printf 'const x = 1;\n' > tmp-unused.js
git add tmp-unused.js
./scripts/static-analysis-pr.sh; echo $?
Expected output shape:
Running ESLint on 1 file(s)
/tmp/repo/tmp-unused.js
1:7 error 'x' is assigned a value but never used no-unused-vars
✖ 1 problem (1 error, 0 warnings)
1
Then open a PR and confirm the CI check appears and reports the same failure. After fixing the issue and pushing again, the check should turn green on the PR page within your normal CI time budget.
Common pitfalls
Wrong diff base branch
Mistake: hard-coding origin/master when the repo default branch is main or something else.
Symptom: PR job scans the wrong files or prints fatal: ambiguous argument 'origin/master...HEAD'.
Fix: set BASE_REF from CI metadata, for example origin/${{ github.base_ref }}.
Shallow checkout breaks diff detection
Mistake: using the CI default shallow clone.
Symptom: git diff returns no files or errors on merge-base resolution.
Fix: set full history or enough depth; in GitHub Actions use:
with:
fetch-depth: 0
Scanning the whole repo on every PR
Mistake: running eslint . or ruff check . in the PR job.
Symptom: 8-20 minute checks, developers rebasing just to wait again, pressure to disable the gate.
Fix: keep full scans on main/nightly and run changed-files-only on PRs.
Cache path not persisted or not writable
Mistake: enabling --cache but writing to a path that is discarded or permission-denied.
Symptom: no speed-up between runs, or messages like EACCES: permission denied, mkdir '.cache/eslint'.
Fix: use a repo-local cache path such as .cache/eslint/ and let the CI cache the package manager dependencies.
Turning on warnings-as-errors too early
Mistake: using --max-warnings=0 on a codebase with hundreds of existing warnings.
Symptom: every PR fails on unrelated legacy files or low-value style issues.
Fix: start with eslint --quiet or baseline the existing findings, then tighten later.
Analyzer versions drift between local and CI
Mistake: developers run a globally installed analyzer while CI runs a newer package version.
Symptom: "works on my machine" with different rule results.
Fix: run project-local tools only, for example npx eslint and ruff from pinned dependencies or locked requirements.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI