#!/usr/bin/env python3 """upstream-sync.py — fetch upstream tags + run plugin matrix against new ones. Exit 0 if every new tag passed boot-smoke + tests + map-check. Exit 1 if any tag failed. Posts a JSON report on stdout (and to --report-json file when set) that lists the per-tag verdict for downstream tooling. Used by: - Makefile `make sync-upstream` target (manual) - .github/workflows/upstream-drift.yml (daily cron) Tooling-light: only stdlib + requests + pytest (already plugin deps). """ import argparse import json import os import subprocess import sys from datetime import datetime, timezone from pathlib import Path PLUGIN_REPO = Path(__file__).resolve().parent.parent FORK_REPO = PLUGIN_REPO.parent / "hermes-webui" def _git(*args, cwd=None, check=True): return subprocess.run( ["git", *args], cwd=cwd or PLUGIN_REPO, capture_output=True, text=True, check=check, ).stdout.strip() def _fetch_upstream(): """git fetch upstream in the fork repo. Returns list of new tags vs HEAD.""" if not (FORK_REPO / ".git").exists(): return [] _git("fetch", "upstream", "--tags", cwd=str(FORK_REPO), check=False) raw = _git("tag", "--list", "v*", cwd=str(FORK_REPO), check=False) return sorted(raw.splitlines()) if raw else [] def _current_tested(): """Return manifest.yaml's tested_versions list.""" mf = PLUGIN_REPO / "manifest.yaml" if not mf.exists(): return [] tested = [] in_block = False for line in mf.read_text().splitlines(): if line.strip().startswith("tested_versions:"): in_block = True continue if in_block: if line.strip().startswith("- "): tested.append(line.strip()[2:].strip()) elif line and not line[0].isspace(): break return tested def _run_check(name, cmd): """Run a check command, return {ok, name, summary}.""" try: out = subprocess.run(cmd, cwd=PLUGIN_REPO, capture_output=True, text=True, timeout=180) return {"name": name, "ok": out.returncode == 0, "summary": (out.stdout + out.stderr)[-200:]} except subprocess.TimeoutExpired: return {"name": name, "ok": False, "summary": "timeout"} def main(): ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--report-json", default=None, help="Write full report here") ap.add_argument("--tag", default=None, help="Test against this specific tag only") args = ap.parse_args() all_tags = _fetch_upstream() tested = set(_current_tested()) candidates = [args.tag] if args.tag else [t for t in all_tags if t not in tested] if not candidates: report = {"status": "no-new-tags", "all_tags": all_tags[-5:], "tested": list(tested), "generated_at": datetime.now(timezone.utc).isoformat()} if args.report_json: Path(args.report_json).write_text(json.dumps(report, indent=2)) print(json.dumps(report, indent=2)) sys.exit(0) matrix = [] overall_ok = True for tag in candidates[-3:]: # cap at 3 newest to avoid runaway runs checks = [] # ① Connection map fresh against current plugin checks.append(_run_check("connection-map-check", ["python3", "scripts/ast-connection-map.py", "--check"])) # ② Unit + integration tests pass checks.append(_run_check("pytest-unit", ["python3", "-m", "pytest", "tests/unit", "-q"])) checks.append(_run_check("pytest-integration", ["python3", "-m", "pytest", "tests/integration", "-q"])) checks.append(_run_check("pytest-evals", ["python3", "-m", "pytest", "tests/evals", "-q"])) # ③ Boot smoke (uses fork at current state — caller's responsibility to checkout the tag) # The CI workflow handles the checkout-each-tag dance. checks.append(_run_check("boot-smoke", ["python3", "scripts/boot-smoke.py"])) tag_ok = all(c["ok"] for c in checks) overall_ok = overall_ok and tag_ok matrix.append({"tag": tag, "ok": tag_ok, "checks": checks}) report = { "status": "ok" if overall_ok else "fail", "tested": list(tested), "new_candidates": candidates, "matrix": matrix, "generated_at": datetime.now(timezone.utc).isoformat(), } if args.report_json: Path(args.report_json).write_text(json.dumps(report, indent=2)) print(json.dumps(report, indent=2)) sys.exit(0 if overall_ok else 1) if __name__ == "__main__": main()