ci: run only the lanes a PR affects (python/frontend/site)

Heavy PR checks run on every PR because the workflows deliberately avoid
`on.paths` filters — a path-gated workflow leaves its required check pending
forever when no matching file changes, blocking merge. So a docs-only PR
still spins up the TypeScript matrix, the full Python suite, and ruff/ty.

Keep every workflow triggering on every PR (checks always report) but gate
the expensive *steps* on what the PR touches. Skipping a step (not the job)
leaves the job green, so required checks never hang — the same idiom already
proven in contributor-check.yml.

A classifier (scripts/ci/classify_changes.py) maps the PR diff to three
lanes — python, frontend, site — surfaced as step outputs by a composite
action (.github/actions/detect-changes). Fail-open: an empty diff or any
.github/ change runs everything; python is a denylist (skipped only when
every file is provably prose or a frontend-only package); skills/**/SKILL.md
counts as python-relevant since the skill-doc tests read that tree. Non-PR
events always run the full pipeline.
This commit is contained in:
Brooklyn Nicholson 2026-06-19 16:46:11 -05:00 committed by ethernet
parent 351afd353d
commit 45540cfb5e
7 changed files with 272 additions and 5 deletions

View file

@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Classify a PR's changed files into CI work lanes.
Reads newline-separated changed paths on stdin and writes ``key=value``
booleans (one per lane) to ``$GITHUB_OUTPUT`` and stdout. The
``detect-changes`` composite action consumes them so steps gate on
``if: steps.changes.outputs.<lane> == 'true'``.
Lanes: ``python`` (pytest / ruff / ty / footguns), ``frontend`` (TS typecheck
matrix + desktop build), ``site`` (Docusaurus + generated skill docs). Docker
is not a lane it builds on push-to-main and release only, never per-PR.
Contract *fail open, never closed*. We may run a lane we didn't need, but
must never skip one a change could break:
* An empty diff, or any ``.github/`` change, runs everything.
* ``python`` is a denylist: skipped only when *every* file is provably prose
or a frontend-only package; an unrecognized path keeps it on.
* ``skills/`` (incl. ``SKILL.md``) is python-relevant the skill-doc tests
read that tree, so a doc-looking edit can still break Python.
"""
from __future__ import annotations
import os
import sys
_FRONTEND = ("ui-tui/", "web/", "apps/") # TS typecheck-matrix packages
_ROOT_NPM = {"package.json", "package-lock.json"} # shifts every package's tree
_SITE = ("website/", "skills/", "optional-skills/") # docs site + skill pages
# Prose/frontend trees that can't touch Python. skills/ is excluded on purpose.
_PY_SKIP = ("docs/", "website/") + _FRONTEND
def _is_docs(p: str) -> bool:
if p.startswith(("skills/", "optional-skills/")):
return False
return p.endswith((".md", ".mdx")) or p.startswith("docs/") or p.startswith("LICENSE")
def _py_irrelevant(p: str) -> bool:
return _is_docs(p) or p in _ROOT_NPM or p.startswith(_PY_SKIP)
def classify(files: list[str]) -> dict[str, bool]:
"""Map changed paths to ``{lane: should_run}``."""
files = [f.strip() for f in files if f.strip()]
if not files or any(f.startswith(".github/") for f in files):
return dict.fromkeys(("python", "frontend", "site"), True)
return {
"python": any(not _py_irrelevant(f) for f in files),
"frontend": any(f.startswith(_FRONTEND) or f in _ROOT_NPM for f in files),
"site": any(f.startswith(_SITE) for f in files),
}
def main() -> int:
lanes = classify(sys.stdin.read().splitlines())
out = "\n".join(f"{k}={str(v).lower()}" for k, v in lanes.items())
if dest := os.environ.get("GITHUB_OUTPUT"):
with open(dest, "a", encoding="utf-8") as fh:
fh.write(out + "\n")
print(out) # echo for local runs + CI step logs
return 0
if __name__ == "__main__":
raise SystemExit(main())