Linting in CI/CD
CI lint gates block merges when code violates style, type, or security rules. Every pull request gets the same checks regardless of local editor setup.
Recipe
# .github/workflows/lint.yml
name: lint
on: [pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- run: uv sync --frozen --group dev
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run mypy src/When to reach for this:
- Developers skip pre-commit hooks
- Code review catches formatting instead of logic
- You need audit evidence of quality gates
Working Example
name: quality
on:
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
python-version: "3.14"
- run: uv sync --frozen --group dev
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run mypy src/
- run: uv run pytest --cov=src --cov-fail-under=80
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- run: uv sync --frozen --group dev
- run: uv run pre-commit run --all-filesWhat this demonstrates:
uv sync --frozenensures lockfile integrity- Separate check and format steps for clear error messages
- mypy runs on
src/only (not tests) - Optional pre-commit job catches hook-skipping devs
Deep Dive
CI Lint Pipeline
| Step | Command | Fails on |
|---|---|---|
| Lint | ruff check . | Style, bugs, security |
| Format | ruff format --check . | Unformatted code |
| Types | mypy src/ | Type errors |
| Hooks | pre-commit run --all-files | Any hook failure |
Performance Tips
- Cache
.venvkeyed on lockfile hash - Cache
.ruff_cacheand.mypy_cache - Run lint and test jobs in parallel
Gotchas
- No
--checkon formatter - CI auto-commits formatting. Fix:ruff format --checkfails instead of fixing. - CI installs latest ruff - different from local. Fix: lock dev deps in
uv.lock; use--frozen. - mypy without project deps - import errors. Fix:
uv sync --group devbefore mypy. - Linting
migrations/- noisy failures on generated code. Fix: exclude in ruff config. - Required checks not enforced - PRs merge despite failures. Fix: branch protection rules requiring lint job.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Pre-commit.ci | Managed hook runner | Self-hosted CI required |
| Reviewdog | Inline PR annotations | Simple pass/fail is enough |
| SonarQube | Enterprise compliance | Small projects |
FAQs
Should lint and test be one job?
Separate jobs run in parallel and give clearer failure messages.
How do I cache uv in GitHub Actions?
setup-uv handles caching. Key on uv.lock hash.
Should CI auto-fix formatting?
No. Fail and let the developer fix locally. Auto-fix in CI hides the problem.
How do I lint only changed files?
Use ruff check $(git diff --name-only origin/main) for speed on large repos.
What about GitLab CI?
Same commands in a script: block after installing uv.
Should main branch run lint too?
Yes, on push to main as a safety net. PR checks are the primary gate.
How do I add a coverage gate?
pytest --cov-fail-under=80 in the test job alongside lint.
Can I skip lint for docs-only PRs?
Use path filters: paths-ignore: ['**.md'] on the workflow trigger.
How do I report lint results in PR comments?
Tools like reviewdog or GitHub Actions problem matchers annotate the diff.
What if lint takes too long?
Scope mypy to src/, cache aggressively, lint only changed files on large repos.
Related
- Ruff Setup - ruff config
- pre-commit Hooks - local hooks
- mypy & pyright in CI - type checking
- Linting & Formatting Best Practices - policy
Stack versions: This page was written for Python 3.14.0, FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.