Contents
A quality gate assembled from scattered README snippets can leave local hooks and CI disagreeing. This post consolidates the official documentation for Ruff, MyPy, pre-commit, and GitHub Actions into one setup.
This guide is assembled from official documentation and community reports — we did not run this build ourselves.
What This Guide Covers (and What It Does Not)
This guide documents a three-layer quality gate: Ruff for linting and formatting, MyPy for static type checking, wired through pre-commit locally and GitHub Actions in CI, with shared settings in a single pyproject.toml. Every mechanism below comes from the official Ruff configuration, MyPy configuration, ruff-pre-commit, and GitHub Actions documentation.
It is a documentation-based guide, not a log from a live project. It contains no benchmarks, timings, or false-positive counts, because the sources do not publish any. Version numbers (Ruff v0.17.1 in the examples, MyPy 2.4.0 in its docs) reflect the documentation fetched 2026-10-17 and will drift, so pin current versions when you adopt this.
The three layers:
- Local and fast: pre-commit hooks (
ruff-check,ruff-format) that run on every commit. - CI and authoritative: a GitHub Actions workflow running Ruff and MyPy on every push and pull request.
- Shared settings: one
pyproject.tomlthat both tools read, so local hooks and CI use the same Ruff and MyPy rules. The Ruff version pins must still match by hand.
Tool Roles: Ruff vs. MyPy
Ruff and MyPy answer different questions. Ruff enforces style and common-bug patterns across 900+ lint rules plus a formatter, while MyPy checks type correctness against annotations and stubs. The table maps each tool’s role, config location, CI entry point, and documented failure classes. Neither tool subsumes the other.
| Tool | Purpose | Config file(s) | CI invocation | Documented failure classes | Notable docs caveat |
|---|---|---|---|---|---|
| Ruff | Linting + formatting | pyproject.toml [tool.ruff], ruff.toml, or .ruff.toml | ruff check --output-format=github . | Rule violations, format drift | Configs do not merge across files; the closest file wins |
| MyPy | Static type checking | mypy.ini, .mypy.ini, pyproject.toml [tool.mypy], or setup.cfg [mypy] | Plain CI job step | Missing library stubs, functions returning Any | Configs do not merge; documented precedence rules apply |
Ruff’s default rule set is listed on the default rules page, and the rules page marks auto-fixable rules with a 🛠️ marker. --fix applies those fixes. MyPy’s configuration docs define which files and modules each setting applies to, a different control surface for a different job.
Single Source of Truth: The pyproject.toml
Both tools read pyproject.toml, which makes it the natural single config home. Ruff reads [tool.ruff], with lint settings under [tool.ruff.lint] and formatter settings under [tool.ruff.format], as described in the Ruff settings reference. MyPy reads [tool.mypy] with per-module overrides via [[tool.mypy.overrides]]. Per the Ruff configuration docs and MyPy config docs, neither tool merges configs across files, so pick one file and stay there.
# pyproject.toml: one config home for both tools
[tool.ruff]
line-length = 88 # documented default in the Ruff example config
target-version = "py311" # as shown in the documented example
[tool.ruff.lint]
extend-select = ["B"] # flake8-bugbear rules, in addition to the defaults
ignore = ["E501"] # documented example drops E501; see failure modes
unfixable = ["B"] # report B violations but never auto-fix them
fixable = ["ALL"] # the documented default
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["E402"] # documented example: imports not at top of __init__ files
[tool.mypy]
files = ["src"] # global-only option: default files to check
exclude = '^tests/' # a regex, not a glob
warn_unused_configs = true # reports per-module sections matching no processed files
warn_return_any = true # reports functions returning Any
[[tool.mypy.overrides]]
module = "somelibrary"
ignore_missing_imports = true # last resort: only when no type hints or stubs can be found
[[tool.mypy.overrides]]
module = "mycode.foo.*"
disallow_untyped_defs = true # tighten one package before the whole codebase
Ruff also respects its exclude and extend-exclude settings when choosing which Python files to check, per the configuration docs.
Documented behavior, easy to get wrong: Ruff uses the closest config file and does not merge settings across files. You can inherit explicitly with the documented
extendkey. MyPy likewise picks one file in discovery order (mypy.ini,.mypy.ini,pyproject.toml,setup.cfg), and the--config-fileflag overrides discovery. In both tools, a second config file in the tree does not add to your settings; it can replace them.
Local Gate: pre-commit with Ruff
The documented ruff-pre-commit hooks are ruff-check and ruff-format. The ordering rule is explicit: when the lint hook runs with --fix, it must come before the formatter hook; without --fix, order is free. Pin rev to an immutable tag or SHA, because branch names are unsupported, and use pre-commit autoupdate to move pins forward deliberately, as described in the pre-commit documentation.
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# rev must be an immutable ref (tag or SHA). The value below is
# illustrative: pin the current tag, then keep it fresh with autoupdate.
rev: v0.17.1
hooks:
# ORDER RULE: with --fix, the lint hook MUST run before the formatter.
- id: ruff-check
args: [--fix]
types_or: [python, pyi] # skips Jupyter notebooks
- id: ruff-format
types_or: [python, pyi]
Setup is three commands:
pip install pre-commit
pre-commit install # writes the git hook
pre-commit run --all-files # runs against the whole tree
Without --fix on ruff-check, the two hooks can run in either order. types_or: [python, pyi] keeps the hooks away from Jupyter notebooks. The pre-commit documentation also states that a branch name as rev is not supported and will not auto-update, which is the problem pre-commit autoupdate exists to address.
The CI Gate: GitHub Actions Workflow
The documented GitHub Actions integration is ruff check --output-format=github . for inline PR annotations. Ruff also ships as astral-sh/ruff-action, which accepts version, args (default check), and src (default [".", "src"]). The Ruff integrations page documents both forms.
# .github/workflows/quality.yml (illustrative sketch)
name: quality
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/ruff-action@v3
with:
version: "0.17.1" # illustrative pin: keep equal to the pre-commit rev
args: check --output-format=github
- uses: astral-sh/ruff-action@v3
with:
version: "0.17.1"
args: format --check # formatter drift fails CI too
type-check:
needs: lint # SKIPPED if lint fails unless you add always()
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11"] # illustrative: list the versions you support
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install -r requirements.txt mypy # requirements.txt must list stub packages such as types-PyYAML
- run: mypy # reads [tool.mypy] from pyproject.toml
Two design notes. First, needs sequences the jobs, but per the GitHub Actions jobs documentation, a failed or skipped prerequisite skips its dependents. If you want MyPy results even when lint fails, add if: ${{ always() }}, as covered by the status-check functions reference. Second, strategy.matrix runs job variations such as Python versions, so the type-check job can cover every interpreter you support.
The Ruff version appears in two places here, the pre-commit rev and the ruff-action version. Keep them equal by hand; the config file does not enforce it.
If GitHub Actions is not your CI, the Ruff integrations page documents equivalents: ruff check --output-format=gitlab for GitLab, ruff format --diff as a formatting check, and Docker images at ghcr.io/astral-sh/ruff:<version>.
Tuning MyPy Strictness Without Breaking the Build
MyPy’s documented precedence lets you tighten incrementally. The config documentation lists the order, highest priority first:
- Inline configuration in the source file (see inline config)
- Sections with concrete module names (
[mypy-mycode.foo], or[[tool.mypy.overrides]]with an exactmodule) - Sections with unstructured wildcard patterns (such as
foo.*.baz), where later sections in the file win - Sections with structured wildcard patterns (such as
mycode.foo.*), where more specific patterns win - Command-line options
- Top-level config (
[mypy]/[tool.mypy])
The docs note that the difference between structured and unstructured pattern ordering “is subject to change in future versions,” so do not depend on it. Within that chain, a wildcard override can enforce disallow_untyped_defs = True on mycode.foo.* while the top-level config stays lenient. warn_unused_configs = True reports per-module sections that match no files processed in a run, which helps catch a misspelled module pattern that silently does nothing. The config docs point readers to their existing-codebase guide for concrete advice on gradual rollout.
Missing type information surfaces as two documented error messages, both from the Running MyPy docs:
Library stubs not installed for "requests"module is installed, but missing library stubs or py.typed marker
For the first, the docs’ order of fixes is: upgrade the library, install the stub package MyPy names (for example types-PyYAML, by pip or with mypy --install-types), or write your own stubs. In CI, list those stubs in requirements.txt like any other test dependency. Suppression is the last resort: when no type hints exist and you cannot write stubs, use a per-module [mypy-somelibrary] section with ignore_missing_imports = True, or its [[tool.mypy.overrides]] equivalent.
Import-following is a separate control. The follow_imports setting governs how MyPy treats imported project modules, and it does not fix missing-stub errors. Resolve library import errors first. For new codebases the docs recommend normal (the default) or error, and they reserve silent for large existing codebases.
Failure Modes and What the Docs Say
The documentation points to five failure classes. None of them come with measured rates, and the sources publish no false-positive percentages.
- Untyped third-party imports. MyPy reports the missing-stubs errors above when a dependency lacks type information. Install the stub package first (
types-PyYAML-style), and reserve the per-moduleignore_missing_imports = Trueoverride for dependencies where no type hints can be found, kept as narrow as the module pattern allows. - Rule overlap with the formatter. The formatter docs cover lint rules that conflict with formatter output. The documented example config sets
ignore = ["E501"]under[tool.ruff.lint], which drops the line-length lint rule. Line length itself still comes from theline-length = 88default, whichruff formatapplies. - Config drift. Neither tool merges config files, per the Ruff and MyPy docs. A second config file replaces the first instead of extending it. MyPy’s
warn_unused_configs = truereports sections that match no files processed in a run. - Stale pre-commit or action pins.
revmust be an immutable tag or SHA, and branch names are not supported. Runpre-commit autoupdate(pre-commit documentation) to advance pins deliberately. Keep theruff-actionversion in step with the pre-commitrev. - Silently skipped CI jobs. Per the GitHub Actions jobs docs, a failed or skipped
needsprerequisite skips its dependents. Useif: ${{ always() }}on the dependent job when you want its result regardless.
FAQ
Should the lint hook run before or after the formatter in pre-commit?
Before, but only with --fix. The documented rule: when the lint hook runs with --fix, it must precede the formatter hook (and Black or isort, if present). Without --fix, the order is free. The rule is stated in the ruff-pre-commit README, and it is the one ordering constraint this setup imposes.
Why does MyPy flag imports that work fine at runtime?
MyPy checks types, not runtime behavior. The documented errors, such as Library stubs not installed for "requests", mean MyPy has no type information for that dependency. They do not mean the import is broken. The documented fix is to install the stub package MyPy names. The MyPy running docs reserve ignore_missing_imports = True for cases where no type hints can be found.
Can I split Ruff or MyPy config across multiple files?
Not implicitly, for either tool. Ruff uses the closest file and never merges, but you can inherit explicitly with its documented extend key. MyPy picks one file in discovery order and never merges, though --config-file overrides discovery. Consolidating into pyproject.toml keeps one source of truth for local hooks and CI.
The Bottom Line
The documented shape of a solid quality gate is one pyproject.toml for both tools, pre-commit with lint-before-format ordering for fast local feedback, and a GitHub Actions gate that uses --output-format=github and a pinned Ruff version. The documentation cannot tell you your team’s tolerance for findings. Tune strictness per module and verify behavior on your own codebase.
As a checklist:
[tool.ruff]and[tool.mypy]in onepyproject.toml. Delete stray config files deliberately.ruff-checkwith--fixbeforeruff-formatin.pre-commit-config.yaml, withrevpinned to a tag and refreshed bypre-commit autoupdate.- A CI lint job with
ruff checkandruff format --check, plus a MyPy job wired withneedsand stub packages listed inrequirements.txt. Addif: ${{ always() }}if you want both reports regardless of failure. - MyPy strictness rolled out per module, with
warn_unused_configsandwarn_return_anyon from day one.
How This Guide Was Built
No hands-on build backs this guide. It is assembled from official documentation and community reports, and no build was run for it. The sources were fetched 2026-10-17: Ruff’s configuration, rules, integrations, settings, and default rules pages; the ruff-pre-commit README and ruff-action repository; pre-commit; MyPy’s config-file and running-mypy docs (v2.4.0); and the GitHub Actions jobs docs. A few pages are linked for specific points but were not read in full in this pass: the MyPy command-line, inline config, and existing-codebase pages, the Ruff formatter conflicting-rules section, and the GitHub status-check functions reference. Version numbers drift, so pin current tags and check every snippet yourself.




