Integrating ruff check with mypy in CI: Zero-Conflict Pipeline Configuration
Combining ruff check and mypy in continuous integration requires strict orchestration. Without proper configuration, teams face duplicate diagnostics, conflicting exit codes, and cache collisions. This blueprint delivers a production-ready pipeline for sub-10-second feedback loops.
The core strategy decouples execution contexts. You must harmonize exit codes, isolate cache directories, and suppress overlapping rules. For foundational architecture patterns, review the Static Analysis Tools & CI Integration guidelines before deploying this configuration.
Exit Code Harmonization & CI Gating
Ruff and mypy use fundamentally different exit code schemas. Ruff returns 1 for lint violations and 0 for clean runs. Mypy returns 0 for success, 1 for type errors, and 2 for fatal crashes. Pyright diverges further by returning 1 for both warnings and errors.
Version constraints matter for stable behavior. Use Ruff >=0.1.6 for consistent --exit-zero support. Use mypy >=1.0 for reliable --show-error-codes. To gate CI reliably, force both tools to return 0 during execution. Capture their standard output for logging.
Avoid set -e in these wrappers to prevent premature pipeline termination. A unified wrapper script then evaluates the captured state. This ensures full log aggregation before returning a single status code.
#!/usr/bin/env bash
set +e
# Run ruff with exit-zero to capture violations without failing
ruff_output=$(ruff check . --exit-zero 2>&1)
ruff_exit=$?
# Run mypy with strict mode
mypy_output=$(mypy --strict --show-error-codes . 2>&1)
mypy_exit=$?
echo "$ruff_output"
echo "$mypy_output"
# Unified gate: fail if either tool reports actual errors
if [[ $ruff_exit -ne 0 || $mypy_exit -ne 0 ]]; then
exit 1
fi
exit 0
Cache Isolation & Parallel Execution
Concurrent runners frequently corrupt shared cache directories. Ruff stores AST and lint state in .ruff_cache. Mypy stores incremental type graphs in .mypy_cache. Overlapping paths trigger race conditions.
Explicitly configure environment variables to isolate these directories. Disable filesystem polling in CI environments to eliminate overhead. Use exact hash keys for cache restoration. Validate integrity by forcing clean rebuilds on misses.
- name: Restore static analysis caches
uses: actions/cache@v3
with:
path: |
.ruff_cache
.mypy_cache
key: ${{ runner.os }}-ruff-mypy-${{ hashFiles('**/pyproject.toml', '**/poetry.lock') }}
- name: Run scoped static analysis
env:
MYPY_CACHE_DIR: ${{ github.workspace }}/.mypy_cache
RUFF_CACHE_DIR: ${{ github.workspace }}/.ruff_cache
run: |
changed=$(git diff --name-only --diff-filter=AMR origin/main HEAD)
if [ -n "$changed" ]; then
echo "$changed" | xargs ruff check --stdin-filename
echo "$changed" | xargs mypy --follow-imports=skip
else
ruff check .
mypy .
fi
Environment variable injection guarantees absolute path separation. This prevents cross-job contamination in matrix builds.
Rule Suppression for Type-Overlap Elimination
Ruff performs syntactic analysis while mypy executes semantic type inference. Enabling both creates diagnostic duplication. You must disable ruff rules that mypy already validates strictly.
Ignore ANN* annotation rules when mypy --strict is active. Exclude F821 if mypy handles import resolution. Configure pyproject.toml to enforce a lint-only profile. Validate suppression by running ruff check --diff.
[tool.ruff]
line-length = 88
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B"]
ignore = [
"ANN001", "ANN002", "ANN003", "ANN101", "ANN102", "ANN201", "ANN202", "ANN204", "ANN205", "ANN206", "ANN401",
"F821"
]
[tool.mypy]
python_version = "3.10"
strict = true
warn_return_any = true
warn_unused_configs = true
cache_dir = ".mypy_cache"
This configuration aligns with best practices detailed in Ruff Linter Integration. It eliminates redundant type validation while preserving style enforcement.
Incremental PR Targeting with git diff Piping
Full-scan execution blocks pull request merges on large codebases. Restrict both tools to modified files and direct dependencies. Pipe git diff output directly into the linter and type checker.
Use mypy --follow-imports=skip to bypass unchanged modules. Maintain a minimal baseline config for required stub files. Implement a fallback to full-scan when configuration files change. This prevents stale incremental state.
# example.py (Python 3.10+)
from __future__ import annotations
from typing import Protocol
class DataProcessor(Protocol):
def process(self, payload: bytes) -> dict[str, int]: ...
def run_pipeline(processor: DataProcessor, raw: bytes) -> dict[str, int]:
return processor.process(raw) # type: ignore[return-value]
The protocol definition above triggers strict mypy checks. Ruff ignores it due to the ANN* suppression. CI only evaluates this file when git diff detects changes.
Common Mistakes
- Enabling ruff’s type-checking rules alongside mypy --strict: Causes duplicate diagnostics, inflates CI runtime, and creates conflicting error messages during triage.
- Sharing a single cache directory between ruff and mypy: Leads to corrupted cache states, invalid incremental analysis, and unpredictable false positives when runners reuse artifacts.
- Using
set -ein wrapper scripts without explicit exit code handling: Terminates the CI job immediately on the first tool failure, preventing log aggregation and masking secondary violations.
FAQ
Should ruff check type annotations if mypy is already running in CI?
No. Disable ruff’s ANN* rules and F821 when mypy --strict is active to eliminate duplicate diagnostics and reduce execution overhead.
How do I prevent mypy’s slow first-run from blocking PR merges?
Use mypy --follow-imports=skip combined with git diff targeting to restrict analysis to changed files. Cache .mypy_cache across CI runs.
What is the safest way to gate CI on both ruff and mypy results?
Run both tools with --exit-zero or equivalent. Capture their stdout/stderr. Implement a wrapper script that returns exit 1 only if either tool reports actual violations.