| |
| """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. |
| * ``python_prod`` — Python changes OUTSIDE tests/ — gates jobs that ship or |
| run the product (Desktop E2E backend, Docker image) but never import the |
| test suite. A tests-only PR keeps ``python`` (pytest must run) while |
| skipping those product jobs. |
| * ``docker_meta`` — Dockerfiles etc. |
| * ``docker`` — any product change + docker meta |
| * ``nix`` — ``nix flake check``: the flake inputs and any product change. |
| * ``frontend`` — TS typecheck matrix + desktop build. |
| * ``site`` — Docusaurus + generated skill docs. |
| * ``scan`` — supply-chain scan (Python files, .pth, setup hooks). |
| * ``deps`` — pyproject.toml dependency bounds check. |
| * ``uv_lock`` — ``uv lock --check``. Re-resolves the whole graph against |
| PyPI, so a diff that touches neither ``pyproject.toml`` nor ``uv.lock`` |
| must not run it. |
| * ``npm_lock`` — semantic package-lock.json diff PR comment. |
| * ``installer`` — PowerShell installer tests (Windows runner). |
| * ``desktop_updater`` — the Windows desktop-update hand-off script and the |
| tests that drive the REAL ``windows.ps1`` (``-SelfTestUi`` / pipe drain / |
| retry policy). These are integration tests of a PowerShell process on a |
| shared runner; running them on every Python PR made their timing noise |
| everyone's problem. They still run on push (fail-open) and whenever the |
| script, its siblings, or their tests change. |
| * ``rust`` — ``cargo test`` for the Tauri bootstrap installer. ``.rs`` |
| lives under ``apps/``, so without this lane a Rust change matched ``frontend`` |
| and only the TypeScript matrix ran. |
| * ``mcp_catalog`` — bundled MCP catalog / installer review. |
| |
| 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. |
| * ``nix/``, ``flake.nix`` and ``flake.lock`` are the exception the other way: |
| only the flake reads them, so they skip the Python lanes and run ``nix`` |
| alone. ``pyproject.toml`` and ``uv.lock`` are flake inputs too, but the |
| packaging tests read them, so they keep every Python lane. |
| * ``website/static/oauth/`` is python-relevant too: it publishes the OAuth |
| Client ID Metadata Document that ``tests/tools/test_mcp_cimd.py`` checks |
| against the pinned callback ports in ``tools/mcp_oauth.py``. |
| * ``website/docs/`` and ``website/scripts/`` are python-relevant for the same |
| reason: the docs tree generates ``llms.txt``, and |
| ``tests/website/test_generate_llms_txt.py`` asserts every page reaches it. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import os |
| import subprocess |
| import sys |
|
|
| _FRONTEND = ("ui-tui/", "web/", "apps/") |
| |
| _FRONTEND_FILES = {"scripts/desktop-update/ui.html"} |
| _ROOT_NPM = {"package.json", "package-lock.json"} |
| _DOCKER_META = ("docker/", ".hadolint.yml", "Dockerfile") |
| _NIX_PATHS = ("nix/",) |
| _NIX_FILES = {"flake.nix", "flake.lock"} |
| _SITE = ("website/", "skills/", "optional-skills/") |
| |
| _PY_SKIP = ("docs/", "website/") + _FRONTEND |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _PY_RELEVANT_SITE = ( |
| "website/static/oauth/", |
| "website/docs/", |
| "website/scripts/", |
| ) |
| |
| |
| |
| |
| _PY_RELEVANT_CONTRACT_FILES = { |
| |
| "apps/shared/src/gateway-contract.generated.ts", |
| "apps/shared/src/gateway-contract.openrpc.json", |
| |
| "apps/desktop/src/lib/desktop-slash-registry.json", |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| _CI_REVIEW_FILES = { |
| ".prettierrc", |
| } |
| _CI_REVIEW_PATHS = (".github/workflows/", ".github/actions/") |
|
|
| |
| _SCAN_EXTS = (".py", ".pth") |
| _SCAN_FILES = {"setup.cfg", "pyproject.toml"} |
|
|
| |
| _MCP_CATALOG_PATHS = ("optional-mcps/",) |
| _MCP_CATALOG_FILES = {"hermes_cli/mcp_catalog.py"} |
|
|
| |
| |
| _INSTALLER_PATHS = ("scripts/tests/",) |
| _INSTALLER_FILES = {"scripts/install.ps1", "scripts/install.cmd"} |
|
|
| |
| |
| _DESKTOP_UPDATER_PATHS = ("scripts/desktop-update/",) |
| _DESKTOP_UPDATER_TEST_PREFIX = "tests/scripts/desktop_update/" |
| _DESKTOP_UPDATER_FILES = { |
| "apps/desktop/electron/updater-process.ts", |
| "apps/desktop/electron/managed-ssh-update.ts", |
| "tests/conftest.py", |
| "pyproject.toml", |
| } |
|
|
| |
| |
| |
| |
| _RUST_PATHS = ("apps/bootstrap-installer/src-tauri/",) |
| _RUST_FILENAMES = {"Cargo.toml", "Cargo.lock"} |
|
|
| 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 _is_nix(p: str) -> bool: |
| return p.startswith(_NIX_PATHS) or p in _NIX_FILES |
|
|
|
|
| def _py_irrelevant(p: str) -> bool: |
| if p.startswith(_PY_RELEVANT_SITE) or p in _PY_RELEVANT_CONTRACT_FILES: |
| return False |
| return ( |
| _is_docs(p) |
| or p in _ROOT_NPM |
| or p.startswith(_PY_SKIP) |
| or p.startswith(_DOCKER_META) |
| or _is_nix(p) |
| ) |
|
|
|
|
| def _py_test_only(p: str) -> bool: |
| """Is ``p`` inside the test suite (never shipped / imported by the product)? |
| |
| Product jobs (Desktop E2E's ``hermes serve`` backend, the Docker image) |
| run installed code — nothing under ``tests/`` is packaged or importable |
| there. scripts/run_tests.sh and run_tests_parallel.py are deliberately |
| NOT test-only: they are runner infrastructure, and a bad edit there can |
| mask real failures, so they stay conservative (python_prod=true). |
| """ |
| return p.startswith("tests/") |
|
|
|
|
| def _is_scan(p: str) -> bool: |
| return p.endswith(_SCAN_EXTS) or p in _SCAN_FILES |
|
|
|
|
| def _is_mcp_catalog(p: str) -> bool: |
| return p.startswith(_MCP_CATALOG_PATHS) or p in _MCP_CATALOG_FILES |
|
|
|
|
| def _is_installer(p: str) -> bool: |
| return p.startswith(_INSTALLER_PATHS) or p in _INSTALLER_FILES |
|
|
|
|
| def _is_desktop_updater(p: str) -> bool: |
| return ( |
| p.startswith(_DESKTOP_UPDATER_PATHS) |
| or p.startswith(_DESKTOP_UPDATER_TEST_PREFIX) |
| or p in _DESKTOP_UPDATER_FILES |
| ) |
|
|
|
|
| def _is_rust(p: str) -> bool: |
| return ( |
| p.endswith(".rs") |
| or p.startswith(_RUST_PATHS) |
| or os.path.basename(p) in _RUST_FILENAMES |
| ) |
|
|
|
|
| def _is_ci_review(p: str) -> bool: |
| if p in _CI_REVIEW_FILES or p.startswith(_CI_REVIEW_PATHS): |
| return True |
| |
| |
| return os.path.basename(p).startswith("eslint.config.") |
|
|
|
|
| def ci_review_files(files: list[str]) -> list[str]: |
| """Return the CI-sensitive paths that need maintainer review.""" |
| return sorted({f.strip() for f in files if f.strip() and _is_ci_review(f.strip())}) |
|
|
|
|
| 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()] |
| python = any(not _py_irrelevant(f) for f in files) |
| python_prod = any(not _py_irrelevant(f) and not _py_test_only(f) for f in files) |
| frontend = any( |
| f.startswith(_FRONTEND) or f in _ROOT_NPM or f in _FRONTEND_FILES |
| for f in files |
| ) |
| deps = any(f == "pyproject.toml" for f in files) |
| npm_lock = any(f.split("/")[-1] == "package-lock.json" for f in files) |
| docker_meta = any(f.startswith(_DOCKER_META) for f in files) |
| |
| ret = { |
| "python": python, |
| "python_prod": python_prod, |
| "docker": docker_meta or python_prod or frontend, |
| "docker_meta": docker_meta, |
| "frontend": frontend, |
| "site": any(f.startswith(_SITE) for f in files), |
| "scan": any(_is_scan(f) for f in files), |
| "deps": deps, |
| "uv_lock": any(f in ("pyproject.toml", "uv.lock") for f in files), |
| "npm_lock": npm_lock, |
| "installer": any(_is_installer(f) for f in files), |
| "desktop_updater": any(_is_desktop_updater(f) for f in files), |
| "rust": any(_is_rust(f) for f in files), |
| "mcp_catalog": any(_is_mcp_catalog(f) for f in files), |
| "ci_review": any(_is_ci_review(f) for f in files), |
| "nix": python_prod or frontend or any(_is_nix(f) for f in files) |
| } |
| if not files or any(f.startswith(".github/") for f in files): |
| ret["python"] = True |
| ret["python_prod"] = True |
| ret["docker"] = True |
| ret["docker_meta"] = True |
| ret["frontend"] = True |
| ret["site"] = True |
| ret["scan"] = True |
| ret["deps"] = True |
| ret["uv_lock"] = True |
| ret["npm_lock"] = True |
| ret["installer"] = True |
| ret["desktop_updater"] = True |
| ret["rust"] = True |
| ret["nix"] = True |
| ret["ci_review"] = True |
|
|
| |
| return ret |
|
|
|
|
| def _pull_request_number() -> str | None: |
| """Read the PR number from the Actions event payload, if present.""" |
| event_path = os.environ.get("GITHUB_EVENT_PATH") |
| if not event_path: |
| return None |
| try: |
| with open(event_path, encoding="utf-8") as fh: |
| payload = json.load(fh) |
| except (OSError, json.JSONDecodeError): |
| return None |
| number = (payload.get("pull_request") or {}).get("number") |
| return str(number) if number else None |
|
|
|
|
| def pull_request_changed_files() -> list[str]: |
| """Recover the PR file list when the compare API returned nothing. |
| |
| ``detect-changes`` calls ``repos/.../compare/base...head`` with raw SHAs. |
| A fork force-push can 404 for ~30s until GitHub attaches the new head SHA |
| to the base repo, so the action fails open with an empty file list. That |
| forces ``ci_review=true`` and blocks the PR on a ``ci-reviewed`` label |
| even when no CI-sensitive file changed. |
| |
| The pull-request files endpoint already knows the PR's files (it is how |
| this action used to classify), so use it as a fallback on pull_request |
| events only. Push/dispatch keep the empty-diff fail-open. |
| """ |
| if os.environ.get("EVENT_NAME") != "pull_request": |
| return [] |
| repo = os.environ.get("REPO") or os.environ.get("GITHUB_REPOSITORY") or "" |
| pr = _pull_request_number() |
| if not repo or not pr: |
| return [] |
| try: |
| completed = subprocess.run( |
| [ |
| "gh", |
| "api", |
| "--paginate", |
| f"repos/{repo}/pulls/{pr}/files", |
| "--jq", |
| ".[].filename", |
| ], |
| check=False, |
| capture_output=True, |
| text=True, |
| timeout=30, |
| ) |
| except (OSError, subprocess.TimeoutExpired): |
| return [] |
| if completed.returncode != 0: |
| return [] |
| return [line.strip() for line in completed.stdout.splitlines() if line.strip()] |
|
|
|
|
| def main() -> int: |
| files = sys.stdin.read().splitlines() |
| if not any(f.strip() for f in files): |
| recovered = pull_request_changed_files() |
| if recovered: |
| print( |
| f"compare API returned no files; recovered {len(recovered)} " |
| "path(s) from the pull request files endpoint", |
| file=sys.stderr, |
| ) |
| files = recovered |
| lanes = classify(files) |
| out = "\n".join([ |
| *(f"{key}={str(value).lower()}" for key, value in lanes.items()), |
| f"ci_review_files={json.dumps(ci_review_files(files))}", |
| ]) |
| if dest := os.environ.get("GITHUB_OUTPUT"): |
| with open(dest, "a", encoding="utf-8") as fh: |
| fh.write(out + "\n") |
| print(out) |
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|