GitHub Actions Type Checking for Python
Wiring mypy and pyright into GitHub Actions turns type annotations from a local courtesy into an enforced contract: every pull request runs the same checker, against the same pinned versions, across the same Python matrix, and the build fails when a type error slips through. This guide builds a complete typecheck.yml workflow — matrix over interpreter versions, dependency and .mypy_cache caching, inline PR annotations, and a gate job — then explains the choices that keep it fast and reproducible. It complements the broader Static Analysis Tools & CI Integration standards and the local mirror provided by pre-commit hooks.
A complete typecheck.yml
The workflow below is self-contained: it triggers on pushes to main and on every pull request, builds a matrix over three interpreter versions, restores caches, and runs both checkers. Pinning matters — actions/setup-python and actions/cache are pinned to major versions, and the checkers themselves are pinned in the dependency lockfile so a silent upstream release never turns a green build red.
Read the file as a nested structure rather than a flat script. At the top level on: declares the two triggers; permissions: drops the token to read-only; and concurrency: cancels an in-flight run when you push again to the same ref, so a rapid series of commits does not queue three redundant type checks. Inside jobs: the single mypy job carries a strategy.matrix that fans out into one runner per interpreter, and each runner walks the same ordered list of steps. Every level below inherits the settings above it, which is why the matrix value ${{ matrix.python-version }} is available to setup-python, to the cache key, and to the --python-version flag alike.
# .github/workflows/typecheck.yml — GitHub Actions, mypy 1.x + pyright 1.1.x
name: typecheck
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
concurrency:
group: typecheck-${{ github.ref }}
cancel-in-progress: true
jobs:
mypy:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip # caches the pip download wheelhouse
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]" # mypy + pyright pinned in pyproject
- name: Restore mypy incremental cache
uses: actions/cache@v4
with:
path: .mypy_cache
key: mypy-${{ matrix.python-version }}-${{ hashFiles('**/uv.lock', '**/requirements*.txt') }}
restore-keys: |
mypy-${{ matrix.python-version }}-
- name: Run mypy
run: >
mypy --strict
--python-version ${{ matrix.python-version }}
--show-error-codes
--output=json
.
mypy --strict is the policy switch; see mypy configuration & strictness for what it bundles. Among other flags it turns on disallow_untyped_defs, disallow_any_generics, warn_return_any, and no_implicit_optional, so an unannotated function surfaces as [no-untyped-def], a return of an Any-typed value becomes [no-any-return], and a mismatched return type becomes [return-value]. --python-version pins the target interpreter independently of the runner’s interpreter, so the matrix exercises version-specific narrowing (for example, X | Y unions resolving differently on 3.10 vs 3.9) — a technique covered in full in matrix-testing mypy across Python versions.
The --output=json flag emits one JSON object per diagnostic — the machine-readable form you hand to a downstream annotator — whereas a bare mypy . prints the familiar path:line: error: message [code] text. One caveat about caching: cache: pip on setup-python only persists the wheel download directory, not mypy’s own incremental .mypy_cache. That needs a separate actions/cache step keyed on the lockfile, detailed in caching mypy and pyright in GitHub Actions. Point the matrix at the versions you actually ship; three legs is illustrative, not a limit.
Failing the build on type errors
Both checkers communicate through their exit code, which is exactly what GitHub Actions reads to decide pass/fail. mypy exits 1 when it finds errors and 0 when clean; pyright exits 1 on any reported error and 0 when none remain. Because each runs as the final command in its step, no extra scripting is needed — a non-zero exit marks the step failed, and a failed step fails the job unless you have explicitly told Actions to tolerate it. Think of it as a small state machine: the checker runs, produces an exit code, the shell propagates that code, and the runner maps 0→success and non-zero→failure.
Two subtleties are worth knowing. mypy reserves exit code 2 for a usage or internal error — a bad flag, an unreadable config, a crash — as distinct from 1 for type errors. Both fail the build, but 2 means “the run never happened,” not “your code has type errors,” and CI logs should be read with that difference in mind. And a multi-command shell step only reports the exit status of its last command unless set -o pipefail is active, which is the exact trap behind the masked failures described next.
Do not mask the exit code. Patterns like mypy . || true, a trailing ; true, or a step-level continue-on-error: true silently convert a real [arg-type] or [return-value] regression into a passing build. The two differ in visibility: || true makes the step itself green, while continue-on-error: true records the step as failed but lets the job succeed anyway — at least it leaves a trace in the logs. Either way the merge gate is gone. pyright has the same failure mode through severity handling: by default its warnings do not change the exit code, so a project that downgrades reportOptionalMemberAccess to a warning will pass CI while still printing the diagnostic. If you want type errors to be advisory during an early migration, prefer scoping which code is checked — via [[tool.mypy.overrides]] or pyright exclude globs — over discarding the exit status, so the code you do gate stays genuinely gated.
# .github/workflows/typecheck.yml (pyright job) — GitHub Actions, pyright 1.1.x
pyright:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -e ".[dev]"
- name: Run pyright
run: pyright --outputjson > pyright-report.json || true
# capture JSON for annotations; real gate is the next step
- name: Fail on pyright errors
run: pyright # exits 1 on reportGeneralTypeIssues, reportArgumentType, etc.
The two-step split lets you keep a machine-readable report (--outputjson) for annotations while the bare pyright invocation provides the authoritative gate. The || true on the first step is the one legitimate use of exit-code masking: it exists only so the JSON file is still written when errors are present, and the real pass/fail decision is delegated to the second, unmasked command. Pyright’s error categories — reportArgumentType, reportGeneralTypeIssues, reportOptionalMemberAccess, reportMissingImports — are the names readers grep for; surface them verbatim so CI logs stay searchable. You can raise or lower the bar with pyright --level error (ignore warnings when deciding the gate) or by promoting specific rules to "error" in pyrightconfig.json, which is pyright’s analogue of tightening a mypy error code.
Inline annotations and problem matchers
A red check is useful; an inline comment on the offending line is better. GitHub renders annotations — the gutter marks on the “Files changed” tab — from special ::error and ::warning workflow commands a step prints to stdout, or from a registered problem matcher that scrapes ordinary tool output with a regex. mypy can emit the workflow-command format directly; pyright ships a community action plus a problem matcher that maps its text output onto the diff. The mechanism is a small translation pipeline: the checker prints path:line: error: message [code], something converts that into a ::error file=path,line=N::message workflow command, and GitHub pins it to the matching line of the diff.
# .github/workflows/typecheck.yml — GitHub annotation output, mypy 1.x
- name: Run mypy with annotations
run: |
mypy --strict --output=github . 2>&1 | tee mypy.log
# --output=github prints ::error file=...,line=...::message [code]
--output=github was added in mypy 1.11; it prints ::error and ::warning workflow commands so each [no-untyped-def] or [return-value] lands as an annotation on the changed line without any regex. GitHub only displays annotations for lines that appear in the pull request’s diff, so an error in an untouched file still fails the build but shows in the log rather than the “Files changed” view — a frequent source of “the check is red but I see no annotation” confusion. For mypy older than 1.11, register a problem matcher: write a JSON file whose regex captures file, line, severity, code, and message, then activate it with echo "::add-matcher::.github/mypy-matcher.json" before the mypy step. A pattern like ^(.+):(\d+): (error|note): (.+?)(?:\s+\[(.+)\])?$ covers mypy’s default line format. Pyright users can skip all of this with jakebailey/pyright-action, which renders reportArgumentType, reportGeneralTypeIssues, and the other reportX diagnostics as annotations with no custom parsing.
# .github/workflows/typecheck.yml — pyright annotations via action, pyright 1.1.x
- uses: jakebailey/pyright-action@v2
with:
version: 1.1.389 # pin the pyright version explicitly
python-version: "3.12"
Pinning version: on the action is the same discipline as pinning in the lockfile: pyright’s bundled type stubs (a snapshot of typeshed) change between releases, and an unpinned bump can introduce reportMissingTypeStubs or reportUnknownMemberType diagnostics that did not exist yesterday. The action also accepts working-directory and extra-args inputs, so you can point it at a subpackage or pass --project pyrightconfig.ci.json for a CI-specific config without maintaining a second workflow. Whichever route you choose, keep exactly one authoritative gate: annotations are a presentation layer, and a run that emits beautiful annotations but never fails the job is decoration, not enforcement.
Caching for speed
mypy’s incremental mode (on by default) writes a per-module fingerprint cache to .mypy_cache — a pair of *.meta.json and *.data.json files per module recording source hashes and inferred types. On a fresh runner that directory is empty, so the first run performs a full cold analysis of every module; restoring the directory across runs lets mypy compare hashes and re-check only the modules that actually changed, which is the single largest CI speedup available. The cache key must include the Python version and a hash of the dependency lockfile, because inferred types differ across interpreters and a dependency upgrade can change a third-party signature that mypy has cached. Pyright has no equivalent on-disk type cache — it re-analyzes the whole project on every invocation — so the win there is caching the dependency install instead. Both strategies are covered in depth in caching mypy and pyright in GitHub Actions.
For large repositories, you can additionally scope a PR-only job to changed files — see running mypy only on changed files — while keeping a full mypy . on main. Be careful pairing that with the incremental cache: mypy’s follow_imports still needs to see the modules a changed file depends on, so a naive “only pass the changed paths” job can miss a [attr-defined] error introduced downstream. Restoring a warm .mypy_cache mitigates this because the imported modules’ fingerprints are already present. A common layout is a fast, changed-files job for quick PR feedback plus a full, cache-backed job that is the actual required check, so speed never comes at the cost of soundness.
Version pinning
Three things must be pinned for reproducibility, and they fail differently when you forget. First, the action versions — actions/checkout@v4, actions/setup-python@v5, actions/cache@v4; a floating @main can change caching or checkout behavior overnight, and for third-party actions it is also a supply-chain exposure, which is why some teams pin to a full commit SHA rather than a tag. Second, the interpreter versions — the matrix list; "3.12" resolves to the latest available 3.12.x, which is usually fine, but pinning the patch ("3.12.4") removes even that variance. Third, the checker versions — pin them in pyproject.toml or a lockfile, never on a floating pip install mypy pyright. A minor mypy release routinely tightens inference and surfaces new [unreachable], [truthy-bool], or [redundant-expr] diagnostics, and a pyright bump ships a newer typeshed that can add or remove reportUnknownMemberType findings. Unpinned, any of these can redden a PR that changed nothing.
# pyproject.toml — pin checkers so CI is reproducible
[project.optional-dependencies]
dev = [
"mypy==1.13.0",
"pyright==1.1.389",
"types-requests==2.32.0.20240914",
]
[tool.mypy]
python_version = "3.10"
strict = true
show_error_codes = true
Pin pyright with the same rigor. The PyPI pyright package is a thin wrapper that downloads a matching pyright Node build, so pyright==1.1.389 fixes both the analyzer and its bundled stubs; if you gate through jakebailey/pyright-action, set its version: input to the identical string so the action and any locally pip-installed pyright never disagree. The goal across all three axes is the same: a red build should mean your code regressed, never that a dependency of your CI moved underneath you.
Common pitfalls
Most broken type-checking pipelines fail for one of four reasons, and each has a one-line fix. Walk the questions below whenever a gate feels untrustworthy — either it is not actually failing on errors, or it is passing (or failing) for the wrong reason.
- Masking the exit code.
mypy . || trueandcontinue-on-error: trueturn a hard gate into a no-op; a[arg-type]regression merges green.continue-on-errorat least records the step as failed in the log, whereas|| truehides it entirely. Scope the checked code instead of discarding the status. - Unpinned checkers. A floating
pip install mypy pyrightmakes the build a moving target. A new mypy release emitting[unreachable], or a pyright release shipping newer stubs, will redden an unrelated PR. Pin exact versions in the lockfile. - Caching without the lockfile in the key. A stale
.mypy_cachekeyed only on Python version can hide errors a dependency bump introduced, because mypy trusts its cached fingerprints for unchanged third-party modules. Always hash the lockfile into the key. - Forgetting
--python-versionin a matrix. Without it, every matrix leg checks against mypy’s default target and the matrix tests nothing useful — only the runner’s interpreter varies, not the analysis target. See matrix-testing mypy across Python versions.
FAQ
Should I run mypy and pyright in the same job or separate jobs? Separate jobs. They have different caching needs and run in parallel, so total wall-clock time is bounded by the slower one rather than their sum. Separate jobs also produce two distinct, individually re-runnable checks.
Do I need both checkers in CI? Many teams run one as the gate and the other advisory. They disagree on edge cases — see pyright vs mypy — so running both catches more, at the cost of reconciling divergent diagnostics.
How do I keep CI consistent with local checks?
Pin identical checker versions in pyproject.toml and run them through pre-commit hooks locally. Identical versions and flags eliminate “works on my machine” type drift.
Why does the matrix use fail-fast: false?
So one failing interpreter version does not cancel the others. You want to see whether a [no-untyped-def] error is version-specific or universal, which requires all legs to finish.