mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
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.
56 lines
2.5 KiB
Python
56 lines
2.5 KiB
Python
"""Contract tests for scripts/ci/classify_changes.py.
|
|
|
|
Each case asserts the *relationship* between a changed-file set and the lanes
|
|
that must run — the safety contract of the gating, not a snapshot. Governing
|
|
invariant: fail open. We may run a lane we didn't need, never skip one a
|
|
change could have broken.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_PATH = Path(__file__).resolve().parents[2] / "scripts" / "ci" / "classify_changes.py"
|
|
_spec = importlib.util.spec_from_file_location("classify_changes", _PATH)
|
|
_mod = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(_mod)
|
|
classify = _mod.classify
|
|
|
|
ALL = {"python": True, "frontend": True, "site": True}
|
|
|
|
|
|
def _lanes(python=False, frontend=False, site=False) -> dict[str, bool]:
|
|
return {"python": python, "frontend": frontend, "site": site}
|
|
|
|
|
|
CASES = {
|
|
"docs-only → nothing heavy": (["README.md", "docs/guide.md"], _lanes()),
|
|
"python source → python": (["run_agent.py"], _lanes(python=True)),
|
|
"dep manifest → python": (["pyproject.toml"], _lanes(python=True)),
|
|
"uv.lock → python": (["uv.lock"], _lanes(python=True)),
|
|
"ts package → frontend": (["apps/desktop/src/app.tsx"], _lanes(frontend=True)),
|
|
"ui-tui → frontend": (["ui-tui/src/entry.ts"], _lanes(frontend=True)),
|
|
# Lockfile bump shifts every TS package's tree, but not the Python suite.
|
|
"root lockfile → frontend, not python": (["package-lock.json"], _lanes(frontend=True)),
|
|
"website → site": (["website/docs/intro.md"], _lanes(site=True)),
|
|
# SKILL.md reads like docs, but the skill-doc tests read skills/, so a
|
|
# skill edit must still run Python.
|
|
"skill md → python + site": (["skills/github/SKILL.md"], _lanes(python=True, site=True)),
|
|
# Unknown top-level file keeps Python on rather than risk a silent skip.
|
|
"unknown toplevel → python": (["Makefile"], _lanes(python=True)),
|
|
"mixed docs+python → python": (["README.md", "agent/x.py"], _lanes(python=True)),
|
|
"mixed docs+frontend → frontend": (["README.md", "apps/x.tsx"], _lanes(frontend=True)),
|
|
# Fail open: CI-config / empty / blank diffs run everything.
|
|
".github change → all": ([".github/workflows/tests.yml"], ALL),
|
|
"action change → all": ([".github/actions/detect-changes/action.yml"], ALL),
|
|
"empty diff → all": ([], ALL),
|
|
"blank lines → all": (["", " "], ALL),
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("files,expected", CASES.values(), ids=CASES.keys())
|
|
def test_classify(files, expected):
|
|
assert classify(files) == expected
|