Files
hermes-agent/scripts/ci/required_results.py
Teknium 259fe73b0b fix(ci): route update dependencies and reject selected consumer skips
Route the exercised subprocess and provider-migration dependencies to both
real-update journeys. Keep backup routing under an explicit dispatch test.
Use the aggregate's existing detect outputs to reject skipped or missing
update consumers while retaining legitimate unselected PR skips.

Verified with the real classifier and aggregate subprocesses against
b9dec76a042, composite ff5c8f1a64b, and fixed code. Canonical runner:
571 passed, 0 failed, 1 native-Windows skip across CI and marker corpora.

(cherry picked from commit a082ca3c62ed49382f173a07882d4280d0ba7c9c)
(cherry picked from commit ed9f2028256b8b1d6af81ef97cd472f30d2cb042)
(cherry picked from commit 6d5cc740b6a75d476802222c2f4754855a5712b0)
2026-10-06 13:02:14 -07:00

121 lines
4.4 KiB
Python

#!/usr/bin/env python3
"""Strict CI aggregate gate (shared by ci.yaml and the stable orchestrator).
Behavior tests: tests/ci/test_required_results.py
Input: the JSON ``toJSON(needs)`` of the all-checks-pass job on stdin.
Failures always fail the gate. A skipped or missing update/E2E consumer also
fails whenever ``detect.outputs`` selects its lane. Other skips additionally
fail in release mode unless the job is in :data:`EXCLUDED_JOBS` (PR-only jobs
that cannot run on a tag event, plus the deferred Desktop E2E). The OSV scan
is advisory in its findings only — its execution is required.
echo "$NEEDS" | python3 scripts/ci/required_results.py [--release]
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from typing import Any
# Jobs that can never run on a release (push/tag) event, plus the deferred
# Desktop E2E. These are the only skips a strict run tolerates.
PR_ONLY_JOBS = ("history-check", "lockfile-diff", "supply-chain")
DEFERRED_JOBS = ("e2e-desktop",)
EXCLUDED_JOBS = frozenset((*PR_ONLY_JOBS, *DEFERRED_JOBS))
NEEDS_JSON_OUTPUT = "needs-json"
# Reusable-workflow parents of the update/E2E consumers. The actual detect
# outputs already arrive in toJSON(needs); a selected consumer cannot skip
# merely because this is a PR rather than a release.
_UPDATE_CONSUMER_LANES = {
"tests": ("python", "e2e", "e2e_upgrade"),
"tests-os": ("python", "desktop_updater", "e2e", "e2e_upgrade"),
"e2e-desktop-update": ("e2e_desktop_update",),
}
def evaluate_gate(
needs: dict[str, dict[str, Any]] | None,
release: bool = False,
) -> dict[str, Any]:
"""Verdict for a ``needs`` context; see the module docstring.
Returns ``ok``, plus sorted ``failed`` (every non-success, non-allowed
entry) and ``allowed_skips``.
"""
failed: list[str] = []
allowed_skips: list[str] = []
entries = needs or {}
lanes = (entries.get("detect") or {}).get("outputs") or {}
required = {job for job, keys in _UPDATE_CONSUMER_LANES.items()
if any(lanes.get(key) in ("true", True) for key in keys)}
if not entries:
failed.append("<no-needs>")
failed.extend(required - entries.keys())
for name, info in entries.items():
result = (info or {}).get("result")
if result == "success":
continue
if result == "skipped" and name not in required and (not release or name in EXCLUDED_JOBS):
allowed_skips.append(name)
continue
failed.append(name)
return {
"ok": not failed,
"failed": sorted(failed),
"allowed_skips": sorted(allowed_skips),
}
def compact_results(needs: dict[str, dict[str, Any]] | None) -> dict[str, str]:
"""{job_name: result} for every entry, for the PR comment assembler."""
return {name: (info or {}).get("result", "") for name, info in (needs or {}).items()}
def render_report(needs: dict[str, dict[str, Any]] | None, verdict: dict[str, Any]) -> list[str]:
"""Per-job lines plus ::error:: annotations for the failed set."""
lines: list[str] = []
for name in sorted(needs or {}):
result = (needs[name] or {}).get("result", "unknown")
icon = {"success": "✅", "skipped": "⏭️"}.get(result, "❌")
lines.append(f"{icon} {name}: {result}")
if verdict["failed"]:
failed = verdict["failed"]
lines.append(f"::error::{len(failed)} job(s) failed: {', '.join(failed)}")
return lines
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"--release",
action="store_true",
help="Strict mode: a skipped excluded job is the only tolerated skip.",
)
args = parser.parse_args(argv)
needs = json.load(sys.stdin)
verdict = evaluate_gate(needs, release=args.release)
compact = compact_results(needs)
output_file = os.environ.get("GITHUB_OUTPUT")
if output_file:
with open(output_file, "a", encoding="utf-8") as fh:
fh.write(f"{NEEDS_JSON_OUTPUT}={json.dumps(compact)}\n")
print(f"{NEEDS_JSON_OUTPUT}={json.dumps(compact)}")
for line in render_report(needs, verdict):
print(line)
if verdict["ok"]:
print("All checks passed" if args.release else "All checks passed (or were skipped)")
return 0 if verdict["ok"] else 1
if __name__ == "__main__":
sys.exit(main())