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.

Type-checking pipeline stages A job flows from checkout, to setup-python and install, to cache restore, to running mypy and pyright in parallel, and finally to a gate that passes or fails the build. checkout actions/checkout setup-python + install deps cache restore .mypy_cache mypy --strict pyright gate pass / fail
Each PR runs checkout → install → cache restore → mypy/pyright → gate; a non-zero checker exit fails the gate.

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.

Structure of the typecheck.yml workflow The workflow nests on, permissions and concurrency at the top level, a jobs block containing the mypy job, and inside it a strategy.matrix and an ordered list of steps. typecheck.yml on: push [main] · pull_request permissions: contents: read concurrency: cancel-in-progress jobs: mypy: runs-on ubuntu-latest strategy.matrix: 3.10 · 3.11 · 3.12 steps: checkout → setup-python → cache → mypy
The workflow is a tree: on/permissions/concurrency at the top, and a jobs block whose mypy job holds the matrix and steps.
# .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.

Exit-code state machine for a type-check step A checker run produces an exit code; exit zero passes the step and keeps the job green while exit one fails the step and turns the job red, but a masking clause reroutes exit one to green and destroys the gate. checker run emits exit code exit 0 exit 1 step passes → job green clean type check step fails → job red errors block the merge || true masks exit 1 as green ✗
Exit 0 keeps the job green and exit 1 turns it red; || true reroutes a failure to green and silently removes the gate.

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.

From checker text to a pinned diff annotation A mypy or pyright text diagnostic is converted by output github into a double-colon error workflow command, which GitHub renders as an annotation pinned on the corresponding pull request diff line. checker output app.py:12: error: ... [arg-type] --output=github workflow command ::error file=app.py, line=12::message GitHub maps annotation pinned on diff line 12
The text diagnostic becomes a ::error workflow command, which GitHub renders as an annotation on the changed line.
# .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.

Cold analysis versus a restored incremental cache On a cold runner mypy re-analyzes all four modules, but with a restored mypy cache three modules are replayed from fingerprints and only the changed module is re-checked. cold runner — empty .mypy_cache module A — re-analyzed module B — re-analyzed module C — re-analyzed module D — re-analyzed every module re-analyzed (slow) restored cache — fingerprints reused module A — replayed from cache module B — replayed from cache module C — replayed from cache module D — changed → re-checked only changed module re-checked (fast)
A cold runner re-analyzes everything; a restored .mypy_cache replays unchanged fingerprints and re-checks only what moved.

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 versionsactions/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.

Three things to pin for a reproducible type-check Actions, interpreters and checkers each have a pinned form that keeps the build reproducible and a floating form that lets it drift. Actions checkout · setup-python Interpreters matrix python-version Checkers mypy · pyright ✓ pinned @v4 (or SHA) ✓ pinned "3.12.4" ✓ pinned mypy==1.13.0 ✗ floating @main drifts ✗ floating latest patch varies ✗ floating pip install mypy
Actions, interpreters, and checkers each need a pinned value; any floating one can turn a green build red without a code change.
# 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.

Decision tree for the four common type-check pitfalls Starting from whether the gate is trustworthy, four questions about masking, pinning, cache keys and the python-version flag each lead to a corrective fix. Gate trustworthy? check these four exit code masked? || true, continue-on-error checkers unpinned? floating pip install key lacks lockfile? stale .mypy_cache no --python- version? ✓ drop the mask scope code instead ✓ pin in lockfile exact versions ✓ hash lockfile into the cache key ✓ pin per leg --python-version=X
Four questions and their fixes: unmask the exit code, pin the checkers, hash the lockfile into the key, and pass --python-version on every leg.
  • Masking the exit code. mypy . || true and continue-on-error: true turn a hard gate into a no-op; a [arg-type] regression merges green. continue-on-error at least records the step as failed in the log, whereas || true hides it entirely. Scope the checked code instead of discarding the status.
  • Unpinned checkers. A floating pip install mypy pyright makes 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_cache keyed 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-version in 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.

Back to Static Analysis Tools & CI Integration