mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
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:
parent
351afd353d
commit
45540cfb5e
7 changed files with 272 additions and 5 deletions
68
scripts/ci/classify_changes.py
Normal file
68
scripts/ci/classify_changes.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue