Integrating ruff check with mypy in CI: Zero-Conflict Pipeline Configuration
Run ruff check and mypy in parallel CI jobs with separate cache directories (.ruff_cache and .mypy_cache). Disable ruff’s ANN* rules when mypy is active to eliminate duplicate diagnostics. Use a wrapper script that captures both exit codes before returning a combined status so neither tool’s output is lost.
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 fast, reliable 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 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.
Use Ruff >=0.1.6 for consistent --exit-zero support. Use mypy >=1.0 for reliable --show-error-codes. To gate CI reliably, run both tools and evaluate their exit codes together. A unified wrapper script ensures full log aggregation before returning a single status code.
Avoid set -e in these wrappers to prevent premature pipeline termination.
#!/usr/bin/env bash
set +e
# Run ruff, capturing output
ruff_output=$(ruff check . 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. These must be kept separate.
Explicitly configure environment variables to isolate these directories. Use exact hash keys for cache restoration. Validate integrity by forcing clean rebuilds on cache misses.
- name: Restore static analysis caches
uses: actions/cache@v4
with:
path: |
.ruff_cache
.mypy_cache
key: ${{ runner.os }}-ruff-mypy-${{ hashFiles('**/pyproject.toml', '**/requirements*.txt') }}
- name: Run Ruff
run: ruff check . --output-format=github
env:
RUFF_CACHE_DIR: ${{ github.workspace }}/.ruff_cache
- name: Run mypy
run: mypy src/ --config-file pyproject.toml
env:
MYPY_CACHE_DIR: ${{ github.workspace }}/.mypy_cache
Explicit environment variable injection guarantees absolute path separation and 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. Disable ruff rules that mypy already validates strictly.
ANN* rule family checks annotation completeness — the same thing mypy's disallow_untyped_defs and warn_return_any enforce. Running both produces duplicate diagnostics that inflate CI output and confuse triage. Add all ANN* codes plus F821 to ruff's ignore list whenever mypy strict mode is enabled.
Ignore ANN* annotation rules when mypy --strict is active — mypy’s disallow_untyped_defs and related flags already enforce annotation completeness. Exclude F821 if mypy handles import resolution.
[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
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.
Use mypy --follow-imports=skip to bypass unchanged modules for quick PR checks. Implement a fallback to full-scan when configuration files change to prevent 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)
For changed-file targeting in CI:
- name: Run scoped static analysis
run: |
changed=$(git diff --name-only --diff-filter=AMR origin/main HEAD | grep '\.py$' || true)
if [ -n "$changed" ]; then
echo "$changed" | xargs ruff check
echo "$changed" | xargs mypy --follow-imports=skip
else
ruff check .
mypy src/
fi
Common Mistakes
- Enabling ruff’s type-checking rules alongside mypy --strict: Causes duplicate diagnostics, inflates CI runtime, and creates conflicting error messages during triage. Disable
ANN*rules when mypy is active. - Sharing a single cache directory between ruff and mypy: Leads to corrupted cache states and unpredictable false positives when runners reuse artifacts. Always use separate directories.
- 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 changed-file targeting to restrict analysis to modified files. Cache .mypy_cache across CI runs using content-addressable keys.
What is the safest way to gate CI on both ruff and mypy results?
Run both tools and capture their exit codes. Use a wrapper script that returns exit 1 only if either tool reports actual violations. This ensures complete output from both tools even when one fails.