Files
hermes-agent/scripts/ci/classify_changes.py
ethernet 1dbe469276 refactor(ci): hoist docker detect-changes into the .py file
The docker.yml gate held its own copy of the build formula, in shell.
classify_changes.py now owns a derived docker lane, and the nix lane in
the next commit derives from the same file. Two formulas in two
languages drift apart, and one Python function with tests does not.
2026-08-18 20:42:06 -04:00

188 lines
7.4 KiB
Python

#!/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.
* ``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
* ``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).
* ``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.
"""
from __future__ import annotations
import json
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
_DOCKER_META = ("docker/", ".hadolint.yml", "Dockerfile") # docker setup
_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
# CI-sensitive files: eslint config, workflow files, composite actions.
# Changes here can influence what code the autofix job executes and pushes to
# main, so they require explicit maintainer review (ci-reviewed label).
#
# package.json is deliberately NOT listed here: npm scripts only execute on the
# unprivileged generate-patch runner (contents: read), never on the privileged
# apply-patch job. The two-job split means a malicious package.json script
# can't get push access — it runs on an ephemeral runner with zero write perms.
_CI_REVIEW_FILES = {
".prettierrc",
}
_CI_REVIEW_PATHS = (".github/workflows/", ".github/actions/")
# Supply-chain scan: files that can execute code at install/import time.
_SCAN_EXTS = (".py", ".pth")
_SCAN_FILES = {"setup.cfg", "pyproject.toml"}
# MCP catalog files that require explicit security review.
_MCP_CATALOG_PATHS = ("optional-mcps/",)
_MCP_CATALOG_FILES = {"hermes_cli/mcp_catalog.py"}
# Windows installer + its PowerShell tests. These only run on a Windows runner,
# so they get their own lane rather than riding along with ``python``.
_INSTALLER_PATHS = ("scripts/tests/",)
_INSTALLER_FILES = {"scripts/install.ps1", "scripts/install.cmd"}
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) or p.startswith(_DOCKER_META)
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_ci_review(p: str) -> bool:
if p in _CI_REVIEW_FILES or p.startswith(_CI_REVIEW_PATHS):
return True
# Any eslint config file at any path — eslint configs can define custom
# fix functions that execute arbitrary code, so they all require review.
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 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),
"mcp_catalog": any(_is_mcp_catalog(f) for f in files),
"ci_review": any(_is_ci_review(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["ci_review"] = True
# explicitly skip mcp catalog here. it's not needed unless those files are modified.
return ret
def main() -> int:
files = sys.stdin.read().splitlines()
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) # echo for local runs + CI step logs
return 0
if __name__ == "__main__":
raise SystemExit(main())