Incremental pyright in CI: Strict Types Without Hour-Long Monorepo Gates

Incremental pyright in CI: Strict Types Without Hour-Long Monorepo Gates

Strict pyright on a giant monorepo is correct—and useless if every PR waits an hour. The unfair advantage is affected-package analysis: typecheck only dirty projects plus dependents, keep a warm cache, and reserve full-fleet runs for main/nightly.

⚡ TL;DR: Partition with pyrightconfig.json / polyrepo packages; compute changed paths → package set → dependents; run pyright -p per package with CI cache on pyright artifacts; full check on main. Pair with TypeScript Project References and AST-Guided Edits.

The failure mode of “just run pyright .”

A single root invocation:

  • Re-walks the entire import graph on every PR
  • Contends for CPU with tests and lint in the same job
  • Encourages teams to flip typeCheckingMode to basic “temporarily”
  • Hides which package introduced the break when the log is 40k lines

Incremental CI keeps strict mode and shrinks the workset, not the rules.

Package graph, not file list

# ✅ Example: git-diff → packages (illustrative)
git diff --name-only origin/main...HEAD \
  | awk -F/ '/^packages\//{print $2}' \
  | sort -u > /tmp/changed_pkgs.txt
# ✅ Expand dependents from a simple adjacency file
# packages/deps.json: {"payments": ["ledger", "api"], "api": []}
import json
from pathlib import Path

deps = json.loads(Path("packages/deps.json").read_text())
# reverse edges: who imports me?
rev: dict[str, set[str]] = {k: set() for k in deps}
for pkg, uses in deps.items():
    for u in uses:
        rev.setdefault(u, set()).add(pkg)

def closure(changed: set[str]) -> set[str]:
    out = set(changed)
    stack = list(changed)
    while stack:
        p = stack.pop()
        for d in rev.get(p, ()):
            if d not in out:
                out.add(d)
                stack.append(d)
    return out
# ✅ CI job sketch
# - restore cache key: pyright-${{ hashFiles('**/pyrightconfig.json', 'uv.lock') }}
# - for pkg in $(cat affected); do pyright -p packages/$pkg; done
# - on main: pyright -p .  # full

Generate deps.json from your packaging tool (Pants, Bazel, uv workspaces, poetry plugins) so humans do not hand-edit edges forever. Treat drift as a CI failure.

Config that keeps strictness

{
  "include": ["src"],
  "exclude": ["**/tests/fixtures", "**/generated"],
  "typeCheckingMode": "strict",
  "venvPath": ".",
  "venv": ".venv",
  "reportMissingImports": true,
  "pythonVersion": "3.12"
}

Per-package configs should extend a shared base rather than fork rules:

{
  "extends": "../../pyright.base.json",
  "include": ["src"],
  "executionEnvironments": [{ "root": "src" }]
}
Mode When
Affected + dependents PR / push branches
Full workspace main, nightly, release
Changed files only ❌ misses cross-package breakages

pyright $(git diff --name-only) on raw files without dependents — you green PRs that break importers.

Caching that actually hits

  • Cache key: pyright-${{ runner.os }}-${{ hashFiles('**/pyrightconfig*.json', 'uv.lock', '**/py.typed') }}
  • Store the pyright binary / nodeenv separately from package type artifacts
  • Prefer uv sync --frozen before pyright so stub packages are identical across runners
  • Shard by package on large PRs (matrix.package) but merge results into one check run

Agent-friendly build graph

Coding agents thrash CI when they cannot see package boundaries. Check in a machine-readable graph (same idea as TypeScript Project References) and teach agents to edit within one package + run that package’s pyright locally before opening a PR. Pair with AST-Guided Edits so refactors do not spray Any across the monorepo.

Escape hatches that do not rot

Allow # pyright: ignore only with a tracking issue ID in the same line. Ban repo-wide typeCheckingMode: off. If a generated client is untyped, put it under exclude and wrap it with a typed facade package.

Nightly full gate as the contract

PR incremental checks are a filter, not the source of truth. Nightly (and every merge to main) must still run the full strict workspace. If nightly fails while PRs were green, you have a graph bug—usually a missing reverse edge—or an unchecked generated path. Page the owning platform team; do not silently downgrade strictness overnight.

Measuring the win

Track PR wall-clock for the typecheck job before/after incremental rollout. Healthy targets: median PR typecheck under 5–8 minutes on a 1M-LOC monorepo, with nightly full under whatever budget finance already accepted. Publish a weekly dashboard of “PRs that would have missed a dependent break under file-only mode” by replaying diffs — that proves why dependents stay mandatory.

Closing checklist

✅ Dos
– ✅ Maintain an explicit package dependency graph
– ✅ Typecheck dependents of changed packages
– ✅ Cache pyright / nodeenv between CI runs
– ✅ Keep main full-strict as the source of truth
– ✅ Fail CI on config drift (pyrightconfig owned)
– ✅ Shard by package on large affected sets
– ✅ Require issue IDs on inline ignores

❌ Don’ts
– ❌ Don’t lower typeCheckingMode to save wall time
– ❌ Don’t skip dependents to “go faster”
– ❌ Don’t commit generated files into the typecheck set without need
– ❌ Don’t run one giant process with no cache on every PR
– ❌ Don’t let agents open PRs without a local package-scoped pyright run

Related reading

Last updated on September 11, 2026


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply