#!/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. == 'true'``. Lanes: * ``python`` — pytest / ruff / ty / footguns. * ``python_prod`` — Python changes OUTSIDE tests/. A tests-only PR keeps ``python`` (pytest must run) while this stays off. * ``docker_meta`` — Dockerfiles etc. * ``docker`` — the image build: docker meta, the dependency manifests, PM, and the image's own tests. * ``nix`` — ``nix flake check``: the flake files and the dependency manifests. * ``e2e``, ``e2e_upgrade``, ``e2e_desktop_core``, ``e2e_desktop_update`` — the end-to-end suites. Each runs on a pull request only when the PR edits that suite or the code the suite exists to guard (``_E2E_LANES``), or carries the ``run-e2e`` label. * ``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`` — ``PM 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. * ``bootstrap`` — the bootstrap installer lane: install.sh sandbox install, pin-fragment drift check, and shipped version-stamp verification. * ``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. ``docker``, ``nix`` and the E2E lanes take most of the larger-runner minutes. An ordinary product change does not start them on a pull request. Every push to main runs them all (a push has no diff, so every lane is on), so a regression they catch shows up on main after the merge. A stable-release run forces every lane on. Contract — *fail open, never closed*. We may run a lane we didn't need, but must never skip one a change could break: * The slow lanes above are the one deliberate exception. On a pull request they skip changes that could still break them, and main is where those changes meet the suite. Their path lists decide which PRs run them early. * 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/") # TS typecheck-matrix packages # Shipped page outside those packages, exercised by the desktop Electron suite. _FRONTEND_FILES = {"scripts/desktop-update/ui.html"} _ROOT_NPM = {"package.json", "package-lock.json"} # shifts every package's tree _DOCKER_META = ("docker/", ".hadolint.yaml", "Dockerfile", ".dockerignore") # docker setup _NIX_PATHS = ("nix/",) # nix files _NIX_FILES = {"flake.nix", "flake.lock"} # base nix files _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 # Published artifacts that live under website/ but that Python asserts about. # The OAuth Client ID Metadata Document is cross-checked against the pinned # callback ports in tools/mcp_oauth.py, so editing it alone must still run the # Python lane — otherwise dropping a redirect URI goes green here and breaks # every CIMD login on main. # website/docs/ and website/scripts/ are asserted about the same way. The docs # tree generates llms.txt — the index every LLM (Hermes included, via the # hermes-agent skill) reads to learn what Hermes can do — and # tests/website/test_generate_llms_txt.py holds every page to appearing in it. # Skipping Python on a docs-only PR is how the index drifted to 53% coverage. _PY_RELEVANT_SITE = ( "website/static/oauth/", "website/docs/", "website/scripts/", ) # Cross-language contract files: data committed under a frontend tree that a # pytest pins against the Python side (emitter inventory, command registry). # Editing only the JSON in an apps/-only PR would otherwise skip the one test # that can catch the drift, so these force the Python lane too. _PY_RELEVANT_CONTRACT_FILES = { # tests/tui_gateway/contracts/test_generated.py (rendered from tui_gateway/contracts) "apps/shared/src/gateway-contract.generated.ts", "apps/shared/src/gateway-contract.openrpc.json", # tests/hermes_cli/test_desktop_slash_registry.py "apps/desktop/src/lib/desktop-slash-registry.json", # tests/tui_gateway/test_show_reasoning_display_gate.py (card-tool names vs the gateway lifecycle set) "apps/desktop/src/lib/tool-render-class.ts", # tests/website/test_catalog_rules_mirror.py (docs page mirrors the canonical rules) "plugin-catalog/README.md", } # Supply-chain scan: files that can execute code at install/import time. _SCAN_EXTS = (".py", ".pth") _SCAN_FILES = {"setup.cfg", "pyproject.toml"} # Bootstrap installer: the POSIX shell installer, the dev-checkout wrapper # that carries the same pin fragment, and the Tauri app's non-Rust sources # (the .rs/Cargo files are the ``rust`` lane's job). Changes here get the # bootstrap-installer.yml lane — a real sandboxed install + stamp check. _BOOTSTRAP_PATHS = ("apps/bootstrap-installer/",) _BOOTSTRAP_FILES = {"scripts/install.sh", "setup-hermes.sh"} # Windows desktop-update hand-off (scripts/desktop-update/windows.ps1 + the # Electron side that launches it) and the pytest files that spawn it. # tests/_fixtures/ holds the conftest's platform gating, so it re-arms the lane too. _DESKTOP_UPDATER_PATHS = ("scripts/desktop-update/", "tests/_fixtures/") _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 crates — currently just the Tauri bootstrap installer (Hermes-Setup). # These live under ``apps/``, so before this lane existed a ``.rs`` edit matched # ``frontend`` and nothing more: the TypeScript matrix built, cargo never ran, # and the crate's unit tests had never executed in CI at all. _RUST_PATHS = ("apps/bootstrap-installer/src-tauri/",) _RUST_FILENAMES = {"Cargo.toml", "Cargo.lock"} # The slow lanes and the paths that start each one on a pull request. A path # belongs here when the lane is the only CI that exercises it for real: the # suite itself, its harness, and the product code the suite exists to guard. # Everything else reaches these lanes on the next push to main. _DEP_MANIFESTS = ("pyproject.toml", "uv.lock", "setup.py") _NPM_MANIFESTS = ("package.json", "package-lock.json") # Shared pytest harness: a bad edit here can break every Python E2E suite. _PY_TEST_HARNESS = ( "tests/conftest.py", "tests/_fixtures/", "tests/fakes/", "tests/e2e/conftest.py", "tests/e2e/core/_", "scripts/run_tests.sh", "scripts/run_tests_parallel.py", ) # Both Desktop suites package the app and share this harness (the update # suite imports the core suite's harness and provider). _DESKTOP_E2E_SHARED = ( "apps/desktop/package.json", "apps/desktop/electron-builder.config.cjs", "apps/desktop/scripts/", "apps/desktop/e2e/core/harness", "apps/desktop/e2e/core/provider", "apps/desktop/e2e/electron-binary", "apps/desktop/e2e/fix-electron-tracing", "apps/desktop/e2e/run-tmp", ) _E2E_LANES: dict[str, tuple[str, ...]] = { "e2e": ( *_PY_TEST_HARNESS, "tests/e2e/", # The state.db torture chamber and the compaction/exactly-once # suites are the only tests that run real concurrent writers. "hermes_state", ), "e2e_upgrade": ( *_PY_TEST_HARNESS, *_DEP_MANIFESTS, "tests/e2e/core/upgrade/", "tests/e2e/core/windows_update/", "tests/compat/", "pm/", "scripts/install.", "setup-hermes.sh", "hermes_cli/update_", "hermes_cli/_update_", "hermes_cli/old_updater", "hermes_cli/_old_updater", "hermes_cli/post_update", "hermes_cli/config_migrations", "hermes_cli/subcommands/update", "hermes_cli/install_", "hermes_cli/_install_", "hermes_cli/main_install", ), "e2e_desktop_core": ( *_DESKTOP_E2E_SHARED, "apps/desktop/e2e/", "apps/desktop/electron/backend-", "apps/shared/src/", "apps/desktop/src/store/session", "apps/desktop/src/store/transcript", # fleet-condensed-default.spec.ts: the profile rail's doors per gateway. "apps/desktop/src/app/chat/sidebar/profile-switcher", "apps/desktop/src/app/chat/sidebar/fleet-", ), "e2e_desktop_update": ( *_DESKTOP_E2E_SHARED, "apps/desktop/e2e/update/", "apps/desktop/electron/updater", "apps/desktop/electron/update-", "apps/desktop/electron/app-updater", "apps/desktop/electron/gateway-stop-before-update", "apps/desktop/electron/pre-update-", "apps/desktop/electron/install-stamp", "scripts/desktop-update/", "scripts/install.sh", "hermes_cli/desktop_update", "hermes_cli/update_", ), } # The upgrade journeys are their own lane; editing one does not start ``e2e``. # The update suite shares apps/desktop/e2e/ with the core suite but not its specs. _E2E_LANE_EXCLUDES = { "e2e": ("tests/e2e/core/upgrade/", "tests/e2e/core/windows_update/"), "e2e_desktop_core": ("apps/desktop/e2e/update/",), } _DOCKER_PATHS = (*_DOCKER_META, *_DEP_MANIFESTS, *_NPM_MANIFESTS, "pm/", "tests/docker/") _NIX_LANE_PATHS = (*_NIX_PATHS, *_NIX_FILES, *_DEP_MANIFESTS, *_NPM_MANIFESTS) # A pull request with this label runs every slow lane, whatever it touches. RUN_E2E_LABEL = "run-e2e" 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 scripts/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_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 _slow_lanes(files: list[str]) -> dict[str, bool]: """The slow lanes a pull request with this diff runs; see ``_E2E_LANES``.""" lanes = { lane: any( f.startswith(paths) and not f.startswith(_E2E_LANE_EXCLUDES.get(lane, ())) for f in files ) for lane, paths in _E2E_LANES.items() } lanes["docker"] = any(f.startswith(_DOCKER_PATHS) for f in files) lanes["nix"] = any(f.startswith(_NIX_LANE_PATHS) for f in files) return lanes def classify(files: list[str], run_e2e: bool = False) -> dict[str, bool]: """Map changed paths to ``{lane: should_run}``. ``run_e2e`` is the pull request's ``run-e2e`` label: it turns every slow lane on. """ 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 or f.startswith("tests-js/") or (f.startswith("scripts/build/") and f.endswith((".mjs", ".js", ".ts"))) 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_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, "bootstrap": any( f.startswith(_BOOTSTRAP_PATHS) or f in _BOOTSTRAP_FILES for f in files ), "desktop_updater": any(_is_desktop_updater(f) for f in files), "rust": any(_is_rust(f) for f in files), **{lane: run_e2e or on for lane, on in _slow_lanes(files).items()}, } if not files or any(f.startswith(".github/") for f in files): ret["python"] = True ret["python_prod"] = 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["bootstrap"] = True ret["desktop_updater"] = True ret["rust"] = True ret.update(dict.fromkeys(_slow_lanes([]), True)) return ret def _event_payload() -> dict: """The Actions event payload, or ``{}`` when it is absent or unreadable.""" event_path = os.environ.get("GITHUB_EVENT_PATH") if not event_path: return {} try: with open(event_path, encoding="utf-8-sig") as fh: payload = json.load(fh) except (OSError, json.JSONDecodeError): return {} return payload if isinstance(payload, dict) else {} def _pull_request_number() -> str | None: """Read the PR number from the Actions event payload, if present.""" number = (_event_payload().get("pull_request") or {}).get("number") return str(number) if number else None def _gh_lines(*args: str) -> list[str] | None: """Run ``gh`` and return its stdout lines, or ``None`` when it fails.""" try: completed = subprocess.run( ["gh", *args], check=False, capture_output=True, text=True, timeout=30, ) except (OSError, subprocess.TimeoutExpired): return None if completed.returncode != 0: return None return [line.strip() for line in completed.stdout.splitlines() if line.strip()] 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 and runs every lane even when the change is narrow. 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 [] return _gh_lines("api", "--paginate", f"repos/{repo}/pulls/{pr}/files", "--jq", ".[].filename") or [] def pull_request_labels() -> list[str]: """The pull request's labels as they are now, not as the event saw them. A re-run replays the original event payload, so the payload alone would miss a ``run-e2e`` label added after the push. The API answer wins; the payload is the fallback when the API call fails. """ 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 repo and pr: live = _gh_lines("api", f"repos/{repo}/pulls/{pr}", "--jq", ".labels[].name") if live is not None: return live labels = (_event_payload().get("pull_request") or {}).get("labels") or [] return [label["name"] for label in labels if isinstance(label, dict) and label.get("name")] 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, run_e2e=RUN_E2E_LABEL in pull_request_labels()) out = "\n".join(f"{key}={str(value).lower()}" for key, value 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())