mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
Merge upstream main into feat/hermes-relay-shared-metrics
# Conflicts: # MANIFEST.in # pyproject.toml # tests/test_project_metadata.py
This commit is contained in:
commit
b4e105031a
506 changed files with 33840 additions and 5678 deletions
18
scripts/capture-cage-terminal.sh
Executable file
18
scripts/capture-cage-terminal.sh
Executable file
|
|
@ -0,0 +1,18 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
out=${1:?usage: $0 OUTPUT.png [command...]}
|
||||
shift
|
||||
mkdir -p "$(dirname "$out")"
|
||||
|
||||
# This runs inside `cage --`, after Ghostty has already opened the isolated
|
||||
# Wayland display. Capture before this script exits so Cage keeps the surface
|
||||
# alive long enough for grim to see it.
|
||||
if (($#)); then
|
||||
"$@"
|
||||
fi
|
||||
if ! command -v grim >/dev/null 2>&1; then
|
||||
echo "grim is required inside the Cage client session" >&2
|
||||
exit 127
|
||||
fi
|
||||
grim "$out"
|
||||
|
|
@ -576,7 +576,6 @@ def main(argv: list[str]) -> int:
|
|||
REPO_ROOT / "plugins",
|
||||
REPO_ROOT / "scripts",
|
||||
REPO_ROOT / "acp_adapter",
|
||||
REPO_ROOT / "acp_registry",
|
||||
]
|
||||
roots = [r for r in roots if r.exists()]
|
||||
elif args.diff:
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ result objects::
|
|||
|
||||
Each result object has:
|
||||
|
||||
kind: "error" | "action_required" | "warning" | "info"
|
||||
kind: "error" | "action_required" | "warning" | "info" | "debug"
|
||||
title: section heading
|
||||
summary: one-line description
|
||||
detail: markdown detail (optional)
|
||||
|
|
@ -56,7 +56,7 @@ from pathlib import Path
|
|||
MARKER = "<!-- hermes-ci-review-bot -->"
|
||||
|
||||
# Severity ordering for display.
|
||||
_SEVERITY_ORDER = ["error", "action_required", "warning", "info"]
|
||||
_SEVERITY_ORDER = ["error", "action_required", "warning", "info", "debug"]
|
||||
|
||||
# Severities that trigger the "blocking issues" layout (vs. the
|
||||
# "looks good!" banner).
|
||||
|
|
@ -74,7 +74,7 @@ _SEVERITY_GROUP_HEADER = {
|
|||
class ReviewItem:
|
||||
"""A single piece of review information with a severity tag."""
|
||||
|
||||
severity: str # "error" | "action_required" | "warning" | "info"
|
||||
severity: str # "error" | "action_required" | "warning" | "info" | "debug"
|
||||
title: str # short section title, e.g. "package-lock.json"
|
||||
summary: str # one-line summary
|
||||
detail: str = "" # optional markdown detail (tables, bullet lists, etc.)
|
||||
|
|
@ -241,15 +241,15 @@ def _render_group(header: str, items: list[ReviewItem]) -> str:
|
|||
return f"{header}\n\n" + "\n\n---\n\n".join(blocks)
|
||||
|
||||
|
||||
def _render_info_details(items: list[ReviewItem]) -> str:
|
||||
"""Render each info item as its own collapsible ``<details>`` block."""
|
||||
def _render_debug_details(items: list[ReviewItem]) -> str:
|
||||
"""Render each debug item as its own collapsible ``<details>`` block."""
|
||||
blocks = []
|
||||
for item in items:
|
||||
inner = _render_item(item)
|
||||
blocks.append(
|
||||
f"<details>\n<summary>{item.title}</summary>\n\n{inner}\n\n</details>"
|
||||
)
|
||||
return "\n\n".join(blocks)
|
||||
return "### debug info\n\n" + "\n\n".join(blocks)
|
||||
|
||||
|
||||
def _render_pending_items(pending_jobs: list[str]) -> str:
|
||||
|
|
@ -263,13 +263,13 @@ def render_comment(items: list[ReviewItem], pending_jobs: list[str] | None = Non
|
|||
|
||||
Items are grouped by severity under ``##`` group headers, separated
|
||||
by ``---``. Errors and action_required items are always visible.
|
||||
Warnings are shown only when present. Info items are in a collapsible
|
||||
``<details>`` block. If ``pending_jobs`` is non-empty, a dimmed
|
||||
Warnings are shown only when present. Info items are visible; debug items
|
||||
are in a collapsible ``<details>`` block. If ``pending_jobs`` is non-empty, a dimmed
|
||||
``<sub>`` footer is appended listing jobs still running.
|
||||
|
||||
When there are no errors, action_required, or warnings (only info
|
||||
items, or nothing at all), a "looks good!" banner is shown at the top,
|
||||
and info items (if any) follow in a collapsible ``<details>`` block.
|
||||
When there are no errors, action_required, or warnings, an "all good!"
|
||||
banner is shown at the top. Info items remain visible and debug items
|
||||
follow in collapsible ``<details>`` blocks.
|
||||
"""
|
||||
pending = pending_jobs or []
|
||||
|
||||
|
|
@ -279,6 +279,7 @@ def render_comment(items: list[ReviewItem], pending_jobs: list[str] | None = Non
|
|||
by_severity.setdefault(item.severity, []).append(item)
|
||||
|
||||
info = by_severity.get("info", [])
|
||||
debug = by_severity.get("debug", [])
|
||||
has_blocking = any(by_severity.get(s) for s in _BLOCKING_SEVERITIES)
|
||||
|
||||
body = f"{MARKER}\n# ૮ >ﻌ< ა ci review\n\n"
|
||||
|
|
@ -287,7 +288,7 @@ def render_comment(items: list[ReviewItem], pending_jobs: list[str] | None = Non
|
|||
body += f"{commit_info}\n\n"
|
||||
|
||||
if not items and not pending:
|
||||
return f"{body}looks good to me!"
|
||||
return f"{body}all good!"
|
||||
|
||||
sections: list[str] = []
|
||||
|
||||
|
|
@ -296,9 +297,12 @@ def render_comment(items: list[ReviewItem], pending_jobs: list[str] | None = Non
|
|||
if group:
|
||||
sections.append(_render_group(_SEVERITY_GROUP_HEADER[sev], group))
|
||||
|
||||
# Info: collapsible <details>
|
||||
if info:
|
||||
sections.append(_render_info_details(info))
|
||||
sections.append(_render_group("## ℹ️ Info", info))
|
||||
|
||||
# Debug: collapsible <details>
|
||||
if debug:
|
||||
sections.append(_render_debug_details(debug))
|
||||
|
||||
if pending:
|
||||
body += _render_pending_items(pending)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ must never skip one a change could break:
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
|
@ -89,6 +90,11 @@ def _is_ci_review(p: str) -> bool:
|
|||
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()]
|
||||
|
|
@ -119,8 +125,12 @@ def classify(files: list[str]) -> dict[str, bool]:
|
|||
|
||||
|
||||
def main() -> int:
|
||||
lanes = classify(sys.stdin.read().splitlines())
|
||||
out = "\n".join(f"{k}={str(v).lower()}" for k, v in lanes.items())
|
||||
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")
|
||||
|
|
|
|||
155
scripts/ci/e2e_screenshot_status.py
Normal file
155
scripts/ci/e2e_screenshot_status.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Select Desktop E2E visual evidence and build its CI review status."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
SOURCE = "playwright e2e"
|
||||
EVIDENCE_START = "<!-- hermes-e2e-evidence:start -->"
|
||||
EVIDENCE_END = "<!-- hermes-e2e-evidence:end -->"
|
||||
|
||||
|
||||
def _files(root: Path, pattern: str) -> list[Path]:
|
||||
return sorted(path for path in root.rglob(pattern) if path.is_file()) if root.exists() else []
|
||||
|
||||
|
||||
def _is_explicit_screenshot(path: Path) -> bool:
|
||||
"""Exclude Playwright's automatic and visual-comparator PNG outputs."""
|
||||
return not (
|
||||
path.name.startswith(("test-finished-", "test-failed-"))
|
||||
or path.name.endswith(("-actual.png", "-expected.png", "-diff.png"))
|
||||
)
|
||||
|
||||
|
||||
def build_manifest(results_dir: Path) -> dict:
|
||||
"""Record stable screenshot names from one E2E run for main/PR comparison."""
|
||||
screenshots = [path for path in _files(results_dir, "*.png") if _is_explicit_screenshot(path)]
|
||||
return {"version": 1, "screenshot_names": sorted({path.name for path in screenshots})}
|
||||
|
||||
|
||||
def _base_screenshot_names(path: Path | None) -> set[str] | None:
|
||||
"""Return ``None`` when main evidence is unavailable (never guess newness)."""
|
||||
if path is None or not path.is_file():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
names = data.get("screenshot_names", []) if isinstance(data, dict) else []
|
||||
if not isinstance(data, dict) or not isinstance(names, list):
|
||||
return None
|
||||
return {name for name in names if isinstance(name, str)}
|
||||
|
||||
|
||||
def _stage_name(kind: str, path: Path, results_dir: Path) -> str:
|
||||
relative = path.relative_to(results_dir).as_posix()
|
||||
digest = hashlib.sha256(relative.encode("utf-8")).hexdigest()[:12]
|
||||
return f"{kind}-{digest}-{path.name}"
|
||||
|
||||
|
||||
def select_evidence(results_dir: Path, base_manifest: Path | None = None) -> dict:
|
||||
"""Select only screenshots new to main, plus every generated visual diff."""
|
||||
base_names = _base_screenshot_names(base_manifest)
|
||||
screenshots = [] if base_names is None else [
|
||||
path for path in _files(results_dir, "*.png")
|
||||
if _is_explicit_screenshot(path) and path.name not in base_names
|
||||
]
|
||||
diffs: list[dict[str, Path]] = []
|
||||
for diff in _files(results_dir, "*-diff.png"):
|
||||
stem = diff.with_name(diff.name.removesuffix("-diff.png"))
|
||||
entry = {"diff": diff}
|
||||
for kind in ("actual", "expected"):
|
||||
candidate = stem.with_name(f"{stem.name}-{kind}.png")
|
||||
if candidate.is_file():
|
||||
entry[kind] = candidate
|
||||
diffs.append(entry)
|
||||
return {"screenshots": screenshots, "diffs": diffs}
|
||||
|
||||
|
||||
def stage_evidence(results_dir: Path, evidence_dir: Path, selection: dict) -> dict:
|
||||
"""Copy selected PNGs into a flat, path-safe evidence artifact."""
|
||||
evidence_dir.mkdir(parents=True, exist_ok=True)
|
||||
staged: dict[Path, str] = {}
|
||||
|
||||
def stage(kind: str, path: Path) -> str:
|
||||
if path in staged:
|
||||
return staged[path]
|
||||
name = _stage_name(kind, path, results_dir)
|
||||
shutil.copyfile(path, evidence_dir / name)
|
||||
staged[path] = name
|
||||
return name
|
||||
|
||||
manifest = {"version": 1, "screenshots": [], "diffs": []}
|
||||
for screenshot in selection["screenshots"]:
|
||||
manifest["screenshots"].append({
|
||||
"name": screenshot.name,
|
||||
"file": stage("screenshot", screenshot),
|
||||
})
|
||||
for diff in selection["diffs"]:
|
||||
entry = {"name": diff["diff"].name.removesuffix("-diff.png"), "diff": stage("diff", diff["diff"])}
|
||||
for kind in ("actual", "expected"):
|
||||
if kind in diff:
|
||||
entry[kind] = stage(kind, diff[kind])
|
||||
manifest["diffs"].append(entry)
|
||||
|
||||
(evidence_dir / "e2e-evidence.json").write_text(
|
||||
json.dumps(manifest, sort_keys=True) + "\n", encoding="utf-8"
|
||||
)
|
||||
return manifest
|
||||
|
||||
|
||||
def build_status(selection: dict, artifact_url: str = "") -> list[dict]:
|
||||
"""Return the review status. The trusted publisher replaces its marker."""
|
||||
screenshots = selection["screenshots"]
|
||||
diffs = selection["diffs"]
|
||||
if not screenshots and not diffs:
|
||||
return []
|
||||
|
||||
summary_parts = []
|
||||
if screenshots:
|
||||
summary_parts.append(
|
||||
f"{len(screenshots)} new screenshot{'s' if len(screenshots) != 1 else ''} vs main"
|
||||
)
|
||||
if diffs:
|
||||
summary_parts.append(f"{len(diffs)} visual diff{'s' if len(diffs) != 1 else ''}")
|
||||
|
||||
result: dict[str, str] = {
|
||||
"kind": "info",
|
||||
"title": "Desktop E2E visual evidence",
|
||||
"summary": "; ".join(summary_parts) + ".",
|
||||
"detail": "\n".join((EVIDENCE_START, "<sub>inline evidence is publishing...</sub>", EVIDENCE_END)),
|
||||
}
|
||||
if artifact_url:
|
||||
result["link"] = artifact_url
|
||||
result["link_label"] = "View test artifacts"
|
||||
return [{"source": SOURCE, "results": [result]}]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--results-dir", type=Path, required=True)
|
||||
parser.add_argument("--base-manifest", type=Path)
|
||||
parser.add_argument("--manifest-output", type=Path, required=True)
|
||||
parser.add_argument("--evidence-dir", type=Path, required=True)
|
||||
parser.add_argument("--artifact-url", default="")
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
args.manifest_output.write_text(
|
||||
json.dumps(build_manifest(args.results_dir), sort_keys=True) + "\n", encoding="utf-8"
|
||||
)
|
||||
selection = select_evidence(args.results_dir, args.base_manifest)
|
||||
stage_evidence(args.results_dir, args.evidence_dir, selection)
|
||||
args.output.write_text(
|
||||
json.dumps(build_status(selection, args.artifact_url)) + "\n", encoding="utf-8"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -27,8 +27,10 @@ with the verification checklist.
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from urllib.parse import quote
|
||||
|
||||
# The source identifier used for error-synthesis exclusion. This must
|
||||
# match (as a normalized substring) the job name as it appears in the
|
||||
|
|
@ -41,24 +43,57 @@ import sys
|
|||
SOURCE = "review-label-gate"
|
||||
|
||||
|
||||
def _ci_review_detail(
|
||||
files_json: str, repo_url: str, base_sha: str, head_sha: str,
|
||||
) -> str:
|
||||
"""Render links to the changed CI-sensitive files that triggered review."""
|
||||
try:
|
||||
files = json.loads(files_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return ""
|
||||
if not isinstance(files, list) or not repo_url or not base_sha or not head_sha:
|
||||
return ""
|
||||
|
||||
links = []
|
||||
for path in files:
|
||||
if not isinstance(path, str) or not path:
|
||||
continue
|
||||
label = path.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
|
||||
path_hash = hashlib.sha256(path.encode()).hexdigest()
|
||||
url = (
|
||||
f"{repo_url}/compare/{quote(base_sha, safe='')}...{quote(head_sha, safe='')}"
|
||||
f"#diff-{path_hash}"
|
||||
)
|
||||
links.append(f"- [`{label}`]({url})")
|
||||
return "**Sensitive files changed:**\n" + "\n".join(links) if links else ""
|
||||
|
||||
|
||||
def build_results(
|
||||
ci_review: bool,
|
||||
mcp_catalog: bool,
|
||||
supply_chain: bool,
|
||||
label_present: bool,
|
||||
ci_review_files: str = "[]",
|
||||
repo_url: str = "",
|
||||
base_sha: str = "",
|
||||
head_sha: str = "",
|
||||
) -> list[dict]:
|
||||
"""Build the list of result objects for this source."""
|
||||
results: list[dict] = []
|
||||
|
||||
if ci_review:
|
||||
detail = _ci_review_detail(ci_review_files, repo_url, base_sha, head_sha)
|
||||
if label_present:
|
||||
results.append({
|
||||
result = {
|
||||
"kind": "info",
|
||||
"title": "CI-sensitive file review",
|
||||
"summary": "`ci-reviewed` label is present.",
|
||||
})
|
||||
"summary": (
|
||||
"PR touches sensitive files, but the `ci-reviewed` label has been "
|
||||
"added, approving them."
|
||||
),
|
||||
}
|
||||
else:
|
||||
results.append({
|
||||
result = {
|
||||
"kind": "action_required",
|
||||
"title": "CI-sensitive file review",
|
||||
"summary": (
|
||||
|
|
@ -72,12 +107,15 @@ def build_results(
|
|||
"- no workflow changes that widen permissions or remove guards,\n"
|
||||
"- no composite action changes that alter what gets executed."
|
||||
),
|
||||
})
|
||||
}
|
||||
if detail:
|
||||
result["detail"] = detail
|
||||
results.append(result)
|
||||
|
||||
if mcp_catalog:
|
||||
if label_present:
|
||||
results.append({
|
||||
"kind": "info",
|
||||
"kind": "debug",
|
||||
"title": "MCP catalog security review",
|
||||
"summary": "`ci-reviewed` label is present.",
|
||||
})
|
||||
|
|
@ -119,9 +157,16 @@ def build_statuses(
|
|||
mcp_catalog: bool,
|
||||
supply_chain: bool,
|
||||
label_present: bool,
|
||||
ci_review_files: str = "[]",
|
||||
repo_url: str = "",
|
||||
base_sha: str = "",
|
||||
head_sha: str = "",
|
||||
) -> list[dict]:
|
||||
"""Build the full review_status array (one entry with a results list)."""
|
||||
results = build_results(ci_review, mcp_catalog, supply_chain, label_present)
|
||||
results = build_results(
|
||||
ci_review, mcp_catalog, supply_chain, label_present,
|
||||
ci_review_files, repo_url, base_sha, head_sha,
|
||||
)
|
||||
if not results:
|
||||
return []
|
||||
return [{"source": SOURCE, "results": results}]
|
||||
|
|
@ -131,18 +176,27 @@ def main() -> int:
|
|||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--ci-review", action="store_true",
|
||||
help="Whether CI-sensitive files changed.")
|
||||
parser.add_argument("--ci-review-files", default="[]",
|
||||
help="JSON list of CI-sensitive files changed.")
|
||||
parser.add_argument("--mcp-catalog", action="store_true",
|
||||
help="Whether the MCP catalog / installer changed.")
|
||||
parser.add_argument("--supply-chain", action="store_true",
|
||||
help="Whether the critical supply-chain scanner found a risk.")
|
||||
parser.add_argument("--label-present", action="store_true",
|
||||
help="Whether the ci-reviewed label is present.")
|
||||
parser.add_argument("--repo-url", default="",
|
||||
help="Repository URL used for changed-file links.")
|
||||
parser.add_argument("--base-sha", default="",
|
||||
help="Pull request base SHA used for changed-file links.")
|
||||
parser.add_argument("--head-sha", default="",
|
||||
help="Pull request head SHA used for changed-file links.")
|
||||
parser.add_argument("--output", default="-",
|
||||
help="Output file ('-' for stdout, or a GITHUB_OUTPUT path).")
|
||||
args = parser.parse_args()
|
||||
|
||||
statuses = build_statuses(
|
||||
args.ci_review, args.mcp_catalog, args.supply_chain, args.label_present
|
||||
args.ci_review, args.mcp_catalog, args.supply_chain, args.label_present,
|
||||
args.ci_review_files, args.repo_url, args.base_sha, args.head_sha,
|
||||
)
|
||||
json_str = json.dumps(statuses)
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ Architecture:
|
|||
the status objects into the review statuses array.
|
||||
|
||||
- :func:`run` — the polling loop. Calls the API, classifies, assembles,
|
||||
upserts, sleeps, repeats. Exits when all jobs are completed.
|
||||
upserts, sleeps, repeats. Before its final exit, it gives downstream jobs
|
||||
a short grace period to appear.
|
||||
|
||||
The orchestrator job names (detect, all-checks-pass, comment-live, etc.)
|
||||
are excluded from the comment — they're infrastructure, not review signal.
|
||||
|
|
@ -71,7 +72,6 @@ _CONCLUSION_MAP = {
|
|||
"action_required": "skipped",
|
||||
}
|
||||
|
||||
|
||||
def classify_jobs(api_jobs: list[dict]) -> tuple[dict[str, str], list[str], dict[str, str]]:
|
||||
"""Classify raw API job dicts into completed + pending + job_urls.
|
||||
|
||||
|
|
@ -197,7 +197,7 @@ def collect_run_jobs(token: str, repo: str, run_id: str) -> list[dict]:
|
|||
# queued jobs so the poller knows they're still running.
|
||||
for job in orch_jobs:
|
||||
steps = job.get("steps") or []
|
||||
if any(s.get("name", "").startswith("Run ./.github/") for s in steps):
|
||||
if any(s.get("name", "").startswith("Run ./.github/workflows/") for s in steps):
|
||||
continue
|
||||
all_jobs.append(job)
|
||||
|
||||
|
|
@ -352,6 +352,13 @@ def _merge_statuses(
|
|||
return json.dumps(merged) if merged else ""
|
||||
|
||||
|
||||
def _commit_info_for_state(commit_info: str, pending: list[str]) -> str:
|
||||
"""Use past tense in the final comment after every CI job completes."""
|
||||
if pending:
|
||||
return commit_info
|
||||
return commit_info.replace("<sub>running on ", "<sub>ran on ", 1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Polling loop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -376,6 +383,7 @@ def run(
|
|||
asm = _import_assembler()
|
||||
start = time.time()
|
||||
last_body = ""
|
||||
quiet_grace_used = False
|
||||
|
||||
# Parse the base statuses once (from review-labels, lockfile-diff, etc.)
|
||||
try:
|
||||
|
|
@ -404,17 +412,18 @@ def run(
|
|||
|
||||
# Try to fetch ci-timings artifact statuses (may not exist yet).
|
||||
artifact_statuses = _fetch_artifact_statuses(
|
||||
token, repo, run_id, "ci-timings-report",
|
||||
token, repo, run_id, "ci-timings-review-status",
|
||||
)
|
||||
if artifact_statuses:
|
||||
print(f" Found ci-timings artifact with {len(artifact_statuses)} status entries")
|
||||
|
||||
merged_json = _merge_statuses(base_statuses, artifact_statuses)
|
||||
current_commit_info = _commit_info_for_state(commit_info, pending)
|
||||
|
||||
body = build_comment_body(
|
||||
asm, completed, pending, run_url, job_urls,
|
||||
merged_json,
|
||||
commit_info,
|
||||
current_commit_info,
|
||||
)
|
||||
|
||||
if body != last_body:
|
||||
|
|
@ -432,6 +441,12 @@ def run(
|
|||
else:
|
||||
print(" No change since last poll.")
|
||||
|
||||
if not pending and not quiet_grace_used:
|
||||
quiet_grace_used = True
|
||||
print(" No visible jobs pending — waiting 10s for downstream jobs to appear.")
|
||||
time.sleep(10)
|
||||
continue
|
||||
|
||||
if not pending:
|
||||
# Check if any dependency failed. If so, exit non-zero so the
|
||||
# run shows as failed — this lets ``gh run rerun --failed``
|
||||
|
|
@ -444,6 +459,7 @@ def run(
|
|||
print(" All jobs completed — done.")
|
||||
break
|
||||
|
||||
quiet_grace_used = False
|
||||
time.sleep(interval)
|
||||
|
||||
return 0
|
||||
|
|
|
|||
328
scripts/ci/publish_e2e_evidence.py
Normal file
328
scripts/ci/publish_e2e_evidence.py
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Publish validated E2E evidence as GitHub attachments and update its PR comment.
|
||||
|
||||
This script only runs from the trusted ``workflow_run`` publisher. It never
|
||||
checks out PR code: it accepts the small evidence artifact produced by the
|
||||
untrusted E2E workflow, validates its manifest and PNG bytes, uploads the
|
||||
approved files as GitHub attachments, and replaces the placeholder in the
|
||||
source PR's CI review comment with those attachment URLs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
API_BASE = "https://api.github.com"
|
||||
EVIDENCE_START = "<!-- hermes-e2e-evidence:start -->"
|
||||
EVIDENCE_END = "<!-- hermes-e2e-evidence:end -->"
|
||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
MAX_FILES = 20
|
||||
MAX_FILE_BYTES = 5 * 1024 * 1024
|
||||
MAX_TOTAL_BYTES = 20 * 1024 * 1024
|
||||
MAX_DIMENSION = 8_000
|
||||
COMMENT_LOOKUP_ATTEMPTS = 6
|
||||
COMMENT_LOOKUP_DELAY_SECONDS = 2
|
||||
_SAFE_FILE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*\.png$")
|
||||
_ATTACHMENT_URL = re.compile(r"^!\[[^\]\r\n]*\]\((https://github\.com/user-attachments/assets/[0-9a-fA-F-]+)\)$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EvidenceFile:
|
||||
"""One validated PNG and the label used when rendering the PR comment."""
|
||||
|
||||
filename: str
|
||||
label: str
|
||||
|
||||
|
||||
def _api_request(
|
||||
url: str,
|
||||
token: str,
|
||||
method: str = "GET",
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Send one authenticated GitHub API request and return its JSON object."""
|
||||
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
method=method,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"Content-Type": "application/json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"User-Agent": "hermes-e2e-evidence-publisher",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(request) as response:
|
||||
parsed = json.loads(response.read())
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError(f"Expected an object from {url}")
|
||||
return parsed
|
||||
|
||||
|
||||
def _read_png(path: Path) -> bytes:
|
||||
"""Read a bounded PNG, rejecting corrupt and unexpectedly large images."""
|
||||
if not path.is_file() or path.is_symlink():
|
||||
raise ValueError(f"Evidence file is not a regular file: {path.name}")
|
||||
size = path.stat().st_size
|
||||
if size == 0 or size > MAX_FILE_BYTES:
|
||||
raise ValueError(f"Evidence file has invalid size: {path.name}")
|
||||
data = path.read_bytes()
|
||||
if not data.startswith(PNG_SIGNATURE) or len(data) < 24 or data[12:16] != b"IHDR":
|
||||
raise ValueError(f"Evidence file is not a PNG: {path.name}")
|
||||
width = int.from_bytes(data[16:20], "big")
|
||||
height = int.from_bytes(data[20:24], "big")
|
||||
if not 0 < width <= MAX_DIMENSION or not 0 < height <= MAX_DIMENSION:
|
||||
raise ValueError(f"Evidence image has invalid dimensions: {path.name}")
|
||||
return data
|
||||
|
||||
|
||||
def _manifest_files(manifest: dict[str, Any]) -> list[EvidenceFile]:
|
||||
"""Flatten a version-one manifest into ordered, reviewer-facing images."""
|
||||
if manifest.get("version") != 1:
|
||||
raise ValueError("Unsupported E2E evidence manifest version")
|
||||
|
||||
files: list[EvidenceFile] = []
|
||||
screenshots = manifest.get("screenshots", [])
|
||||
diffs = manifest.get("diffs", [])
|
||||
if not isinstance(screenshots, list) or not isinstance(diffs, list):
|
||||
raise ValueError("Evidence manifest lists are malformed")
|
||||
|
||||
for entry in screenshots:
|
||||
if not isinstance(entry, dict) or not isinstance(entry.get("name"), str) or not isinstance(entry.get("file"), str):
|
||||
raise ValueError("Evidence screenshot entry is malformed")
|
||||
files.append(EvidenceFile(entry["file"], f"new screenshot: {entry['name']}"))
|
||||
|
||||
for entry in diffs:
|
||||
if not isinstance(entry, dict) or not isinstance(entry.get("name"), str) or not isinstance(entry.get("diff"), str):
|
||||
raise ValueError("Evidence visual-diff entry is malformed")
|
||||
files.append(EvidenceFile(entry["diff"], f"visual diff: {entry['name']}"))
|
||||
for kind in ("actual", "expected"):
|
||||
value = entry.get(kind)
|
||||
if value is not None:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("Evidence visual-diff companion is malformed")
|
||||
files.append(EvidenceFile(value, f"visual {kind}: {entry['name']}"))
|
||||
|
||||
names = [item.filename for item in files]
|
||||
if len(files) > MAX_FILES or len(set(names)) != len(names):
|
||||
raise ValueError("Evidence manifest has too many or duplicate files")
|
||||
if any(not _SAFE_FILE.fullmatch(name) for name in names):
|
||||
raise ValueError("Evidence manifest contains an unsafe filename")
|
||||
return files
|
||||
|
||||
|
||||
def load_evidence(evidence_dir: Path) -> tuple[list[EvidenceFile], dict[str, bytes]]:
|
||||
"""Load the manifest and return only the validated files it declares."""
|
||||
manifest_path = evidence_dir / "e2e-evidence.json"
|
||||
if not manifest_path.is_file() or manifest_path.is_symlink():
|
||||
raise ValueError("E2E evidence manifest is missing")
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("E2E evidence manifest is not JSON") from exc
|
||||
if not isinstance(manifest, dict):
|
||||
raise ValueError("E2E evidence manifest is not an object")
|
||||
|
||||
files = _manifest_files(manifest)
|
||||
payloads: dict[str, bytes] = {}
|
||||
total = 0
|
||||
for item in files:
|
||||
path = evidence_dir / item.filename
|
||||
if path.parent != evidence_dir:
|
||||
raise ValueError("Evidence file escaped its artifact directory")
|
||||
payload = _read_png(path)
|
||||
total += len(payload)
|
||||
if total > MAX_TOTAL_BYTES:
|
||||
raise ValueError("E2E evidence exceeds the total size limit")
|
||||
payloads[item.filename] = payload
|
||||
return files, payloads
|
||||
|
||||
|
||||
def render_evidence(files: list[EvidenceFile], attachment_urls: dict[str, str]) -> str:
|
||||
"""Render validated GitHub attachment URLs inside the review-comment marker."""
|
||||
blocks = [EVIDENCE_START]
|
||||
for item in files:
|
||||
url = attachment_urls.get(item.filename)
|
||||
if url is None:
|
||||
raise ValueError(f"Missing attachment URL for {item.filename}")
|
||||
blocks.extend((
|
||||
"<details>",
|
||||
f"<summary>{item.label}</summary>",
|
||||
"",
|
||||
f"",
|
||||
"",
|
||||
"</details>",
|
||||
))
|
||||
blocks.append(EVIDENCE_END)
|
||||
return "\n".join(blocks)
|
||||
|
||||
|
||||
def render_upload_failure(error: Exception) -> str:
|
||||
"""Render an escaped upload error inside the review-comment marker."""
|
||||
return "\n".join((
|
||||
EVIDENCE_START,
|
||||
"<sub>inline evidence upload failed.</sub>",
|
||||
"",
|
||||
f"<pre>{html.escape(str(error))}</pre>",
|
||||
EVIDENCE_END,
|
||||
))
|
||||
|
||||
|
||||
def replace_evidence_marker(comment: str, evidence: str) -> str:
|
||||
"""Replace exactly the pending-evidence region in a CI review comment."""
|
||||
pattern = re.compile(f"{re.escape(EVIDENCE_START)}.*?{re.escape(EVIDENCE_END)}", re.DOTALL)
|
||||
result, count = pattern.subn(evidence, comment, count=1)
|
||||
if count != 1:
|
||||
raise ValueError("CI review comment does not contain one evidence marker")
|
||||
return result
|
||||
|
||||
|
||||
def _find_review_comment(comments: object) -> dict[str, Any] | None:
|
||||
"""Find a live CI review comment only after it contains this marker."""
|
||||
if not isinstance(comments, list):
|
||||
raise ValueError("GitHub comments response is malformed")
|
||||
for item in comments:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
body = str(item.get("body", ""))
|
||||
if body.startswith("<!-- hermes-ci-review-bot -->") and EVIDENCE_START in body and EVIDENCE_END in body:
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def _wait_for_review_comment(token: str, source_repo: str, pr_number: str) -> dict[str, Any]:
|
||||
"""Wait briefly for GitHub's comment API to expose the completed marker."""
|
||||
request = urllib.request.Request(
|
||||
f"{API_BASE}/repos/{source_repo}/issues/{pr_number}/comments?per_page=100",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"User-Agent": "hermes-e2e-evidence-publisher",
|
||||
},
|
||||
)
|
||||
for attempt in range(COMMENT_LOOKUP_ATTEMPTS):
|
||||
with urllib.request.urlopen(request) as response:
|
||||
comment = _find_review_comment(json.loads(response.read()))
|
||||
if comment is not None:
|
||||
return comment
|
||||
if attempt + 1 < COMMENT_LOOKUP_ATTEMPTS:
|
||||
time.sleep(COMMENT_LOOKUP_DELAY_SECONDS)
|
||||
raise ValueError("CI review comment with E2E evidence marker is missing")
|
||||
|
||||
|
||||
def upload_evidence(
|
||||
files: list[EvidenceFile],
|
||||
evidence_dir: Path,
|
||||
source_repo: str,
|
||||
session_token: str,
|
||||
) -> dict[str, str]:
|
||||
"""Upload validated files through gh-image and accept only attachment URLs."""
|
||||
environment = os.environ.copy()
|
||||
environment["GH_SESSION_TOKEN"] = session_token
|
||||
attachment_urls: dict[str, str] = {}
|
||||
for item in files:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"gh",
|
||||
"image",
|
||||
"--repo",
|
||||
source_repo,
|
||||
str(evidence_dir / item.filename),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
output = "; ".join(
|
||||
value.strip()
|
||||
for value in (exc.stdout, exc.stderr)
|
||||
if value and value.strip()
|
||||
)
|
||||
message = f"Failed to upload {item.filename} with gh image (exit code {exc.returncode})"
|
||||
if output:
|
||||
message = f"{message}: {output}"
|
||||
print(message, file=sys.stderr)
|
||||
raise RuntimeError(message) from exc
|
||||
match = _ATTACHMENT_URL.fullmatch(result.stdout.strip())
|
||||
if match is None:
|
||||
raise ValueError(f"gh-image returned an invalid attachment reference for {item.filename}")
|
||||
attachment_urls[item.filename] = match.group(1)
|
||||
return attachment_urls
|
||||
|
||||
|
||||
def publish(
|
||||
token: str,
|
||||
source_repo: str,
|
||||
evidence_dir: Path,
|
||||
pr_number: str,
|
||||
session_token: str,
|
||||
) -> bool:
|
||||
"""Publish evidence and patch its source PR comment; false means nothing to show."""
|
||||
files, _ = load_evidence(evidence_dir)
|
||||
if not files:
|
||||
print("No inline E2E evidence to publish.")
|
||||
return False
|
||||
comment = _wait_for_review_comment(token, source_repo, pr_number)
|
||||
try:
|
||||
attachment_urls = upload_evidence(
|
||||
files, evidence_dir, source_repo, session_token
|
||||
)
|
||||
except Exception as exc:
|
||||
body = replace_evidence_marker(
|
||||
str(comment.get("body", "")), render_upload_failure(exc)
|
||||
)
|
||||
_api_request(
|
||||
f"{API_BASE}/repos/{source_repo}/issues/comments/{comment['id']}",
|
||||
token,
|
||||
method="PATCH",
|
||||
payload={"body": body},
|
||||
)
|
||||
raise
|
||||
evidence = render_evidence(files, attachment_urls)
|
||||
body = replace_evidence_marker(str(comment.get("body", "")), evidence)
|
||||
_api_request(
|
||||
f"{API_BASE}/repos/{source_repo}/issues/comments/{comment['id']}",
|
||||
token,
|
||||
method="PATCH",
|
||||
payload={"body": body},
|
||||
)
|
||||
print(f"Published {len(files)} E2E evidence image attachment(s).")
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--evidence-dir", type=Path, required=True)
|
||||
parser.add_argument("--source-repo", required=True)
|
||||
parser.add_argument("--pr-number", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
token = os.environ.get("GITHUB_TOKEN", "")
|
||||
if not token:
|
||||
parser.error("GITHUB_TOKEN is required")
|
||||
session_token = os.environ.get("GH_SESSION_TOKEN", "")
|
||||
if not session_token:
|
||||
parser.error("GH_SESSION_TOKEN is required")
|
||||
publish(token, args.source_repo, args.evidence_dir, args.pr_number, session_token)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -234,7 +234,8 @@ def collect_timings(token: str, repo: str, run_id: str, head_sha: str) -> dict:
|
|||
"""Collect job/step timings from the GitHub API.
|
||||
|
||||
1. Get orchestrator run's direct jobs (detect, all-checks-pass, etc.).
|
||||
Skip workflow-call placeholder jobs (step name starts with "Run ./.github/").
|
||||
Skip workflow-call placeholder jobs (step name starts with
|
||||
"Run ./.github/workflows/").
|
||||
2. Find sub-workflow runs via head_sha + event=workflow_call.
|
||||
3. Get each sub-workflow run's jobs with full step timing.
|
||||
"""
|
||||
|
|
@ -251,7 +252,7 @@ def collect_timings(token: str, repo: str, run_id: str, head_sha: str) -> dict:
|
|||
direct = []
|
||||
for job in orch_jobs:
|
||||
steps = job.get("steps") or []
|
||||
if any(s.get("name", "").startswith("Run ./.github/") for s in steps):
|
||||
if any(s.get("name", "").startswith("Run ./.github/workflows/") for s in steps):
|
||||
continue # workflow-call placeholder
|
||||
if job.get("status") in ("in_progress", "queued"):
|
||||
continue # skip self / unfinished
|
||||
|
|
@ -909,7 +910,7 @@ def generate_review_status(
|
|||
"""Produce a review_status JSON array for the CI timings review section.
|
||||
|
||||
Returns a list with one ``{source, results: [...]}`` entry. The
|
||||
result kind is ``"info"`` or ``"warning"`` (timings is never error —
|
||||
result kind is ``"debug"`` or ``"warning"`` (timings is never error —
|
||||
it's an observability job). *summary* is a single short line suitable
|
||||
for the PR comment. *detail* has the per-job deltas as a markdown
|
||||
fragment.
|
||||
|
|
@ -917,7 +918,7 @@ def generate_review_status(
|
|||
stats = compute_stats(timings, baseline)
|
||||
|
||||
if baseline is None:
|
||||
severity = "info"
|
||||
severity = "debug"
|
||||
summary = f"Wall time {fmt_dur(stats['wall'])} (no baseline yet)."
|
||||
else:
|
||||
wall = stats["wall"]
|
||||
|
|
@ -928,10 +929,10 @@ def generate_review_status(
|
|||
if pct > _TIMINGS_WARN_PCT * 100:
|
||||
severity = "warning"
|
||||
else:
|
||||
severity = "info"
|
||||
severity = "debug"
|
||||
else:
|
||||
wall_str = f"Wall time {fmt_dur(wall)}."
|
||||
severity = "info"
|
||||
severity = "debug"
|
||||
|
||||
if stats["slower"]:
|
||||
wall_str += f" {stats['slower']} job(s) slower,"
|
||||
|
|
@ -998,6 +999,8 @@ def main():
|
|||
help="Markdown summary output path (default: ci-timings-summary.md)")
|
||||
parser.add_argument("--review-status-out", default="",
|
||||
help="If set, write a review-status JSON for the unified PR comment.")
|
||||
parser.add_argument("--review-status-only", action="store_true",
|
||||
help="Write review status from existing timings without regenerating the report.")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Collect or load timings
|
||||
|
|
@ -1044,6 +1047,16 @@ def main():
|
|||
else:
|
||||
print(f"No baseline file at {args.baseline} — generating current-only report")
|
||||
|
||||
if args.review_status_only:
|
||||
if not args.review_status_out:
|
||||
parser.error("--review-status-only requires --review-status-out")
|
||||
report_url = os.environ.get("CI_TIMINGS_REPORT_URL", "")
|
||||
statuses = generate_review_status(timings, baseline, report_url)
|
||||
with open(args.review_status_out, "w", encoding="utf-8") as f:
|
||||
f.write(f"review_status={json.dumps(statuses)}\n")
|
||||
print(f"Wrote review status to {args.review_status_out}")
|
||||
return
|
||||
|
||||
# Generate HTML
|
||||
html = generate_html(timings, baseline)
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ NO_SKILLS=false
|
|||
BRANCH="main"
|
||||
INSTALL_COMMIT=""
|
||||
ENSURE_DEPS=""
|
||||
POSTINSTALL_MODE=false
|
||||
|
||||
MANIFEST_MODE=false
|
||||
STAGE_NAME=""
|
||||
JSON_OUTPUT=false
|
||||
|
|
@ -150,10 +150,7 @@ while [[ $# -gt 0 ]]; do
|
|||
ENSURE_DEPS="$2"
|
||||
shift 2
|
||||
;;
|
||||
--postinstall)
|
||||
POSTINSTALL_MODE=true
|
||||
shift
|
||||
;;
|
||||
|
||||
-h|--help)
|
||||
echo "Hermes Agent Installer"
|
||||
echo ""
|
||||
|
|
@ -190,9 +187,7 @@ while [[ $# -gt 0 ]]; do
|
|||
echo " --ensure DEPS Install only specified deps (comma-separated)"
|
||||
echo " Supported: node, browser, ripgrep, ffmpeg"
|
||||
echo " Does NOT clone repo or create venv"
|
||||
echo " --postinstall Run post-install setup only (for pip users)"
|
||||
echo " Installs optional deps + runs hermes setup"
|
||||
echo " Does NOT clone repo or create venv"
|
||||
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
|
|
@ -2584,29 +2579,6 @@ ensure_mode() {
|
|||
done
|
||||
}
|
||||
|
||||
postinstall_mode() {
|
||||
print_banner
|
||||
detect_os
|
||||
|
||||
log_info "Post-install mode: setting up Hermes for pip install"
|
||||
|
||||
check_node
|
||||
check_network_prerequisites
|
||||
install_system_packages
|
||||
|
||||
if [ "$HAS_NODE" = true ] && [ "$SKIP_BROWSER" = false ]; then
|
||||
ensure_browser
|
||||
fi
|
||||
|
||||
HERMES_CMD="$(command -v hermes 2>/dev/null || echo "")"
|
||||
if [ -n "$HERMES_CMD" ]; then
|
||||
log_info "Running hermes setup..."
|
||||
"$HERMES_CMD" setup
|
||||
else
|
||||
log_warn "hermes command not found on PATH"
|
||||
log_info "Try: python -m hermes_cli.main setup"
|
||||
fi
|
||||
}
|
||||
|
||||
# Clear the cached Electron download + any half-written unpacked output so the
|
||||
# next `npm run pack` re-downloads and re-stages from scratch. A corrupt zip in
|
||||
|
|
@ -3150,8 +3122,6 @@ elif [ -n "$STAGE_NAME" ]; then
|
|||
run_stage_protocol "$STAGE_NAME"
|
||||
elif [ -n "$ENSURE_DEPS" ]; then
|
||||
ensure_mode
|
||||
elif [ "$POSTINSTALL_MODE" = true ]; then
|
||||
postinstall_mode
|
||||
else
|
||||
main
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -34,11 +34,6 @@ REPO_ROOT = Path(__file__).resolve().parent.parent
|
|||
VERSION_FILE = REPO_ROOT / "hermes_cli" / "__init__.py"
|
||||
PYPROJECT_FILE = REPO_ROOT / "pyproject.toml"
|
||||
|
||||
# ACP Registry manifest must stay version-locked with pyproject.toml.
|
||||
# tests/acp/test_registry_manifest.py enforces this lockstep so the release
|
||||
# bump touches both files atomically.
|
||||
ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Git email → GitHub username mapping
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -924,6 +919,7 @@ LEGACY_AUTHOR_MAP = {
|
|||
"thomasjhon6666@gmail.com": "ThomassJonax",
|
||||
"focusflow.app.help@gmail.com": "yes999zc",
|
||||
"rob@atlas.lan": "rmoen",
|
||||
"huajiang@tubi.tv": "thirstycrow", # PR #23630 salvage (config-aware memory status labels)
|
||||
# Slack ephemeral slash-ack salvage (May 2026)
|
||||
"probepark@users.noreply.github.com": "probepark",
|
||||
# Slack batch salvage (May 2026)
|
||||
|
|
@ -2204,70 +2200,6 @@ def update_version_files(semver: str, calver_date: str):
|
|||
)
|
||||
desktop_pkg.write_text(pkg_text, encoding="utf-8")
|
||||
|
||||
# Update ACP Registry manifest + npm launcher (must stay version-locked
|
||||
# with pyproject — enforced by tests/acp/test_registry_manifest.py).
|
||||
_update_acp_registry_versions(semver)
|
||||
|
||||
|
||||
def _update_acp_registry_versions(semver: str) -> None:
|
||||
"""Bump the ACP Registry manifest's version + uvx package pin in lockstep
|
||||
with pyproject.
|
||||
|
||||
Skips silently if the manifest is missing — older release branches predate
|
||||
the ACP Registry assets.
|
||||
"""
|
||||
if ACP_REGISTRY_MANIFEST.exists():
|
||||
manifest = json.loads(ACP_REGISTRY_MANIFEST.read_text(encoding="utf-8"))
|
||||
manifest["version"] = semver
|
||||
uvx = manifest.get("distribution", {}).get("uvx", {})
|
||||
if "package" in uvx:
|
||||
uvx["package"] = f"hermes-agent[acp]=={semver}"
|
||||
# Preserve trailing newline + 2-space indent the file already uses.
|
||||
ACP_REGISTRY_MANIFEST.write_text(
|
||||
json.dumps(manifest, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def build_release_artifacts(semver: str) -> list[Path]:
|
||||
"""Build sdist/wheel artifacts for the current release.
|
||||
|
||||
Tries ``uv build`` first (matching the CI workflow), falls back to
|
||||
``python -m build`` if uv is unavailable.
|
||||
"""
|
||||
dist_dir = REPO_ROOT / "dist"
|
||||
shutil.rmtree(dist_dir, ignore_errors=True)
|
||||
|
||||
# Prefer uv build (matches CI workflow), fall back to python -m build.
|
||||
uv_bin = shutil.which("uv")
|
||||
if uv_bin:
|
||||
cmd = [uv_bin, "build", "--sdist", "--wheel"]
|
||||
else:
|
||||
cmd = [sys.executable, "-m", "build", "--sdist", "--wheel"]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(REPO_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(" ⚠ Could not build Python release artifacts.")
|
||||
stderr = result.stderr.strip()
|
||||
stdout = result.stdout.strip()
|
||||
if stderr:
|
||||
print(f" {stderr.splitlines()[-1]}")
|
||||
elif stdout:
|
||||
print(f" {stdout.splitlines()[-1]}")
|
||||
print(" Install uv or the 'build' package to attach sdist/wheel assets.")
|
||||
return []
|
||||
|
||||
artifacts = sorted(p for p in dist_dir.iterdir() if p.is_file())
|
||||
matching = [p for p in artifacts if semver in p.name]
|
||||
if not matching:
|
||||
print(" ⚠ Built artifacts did not match the expected release version.")
|
||||
return []
|
||||
return matching
|
||||
|
||||
|
||||
def resolve_author(name: str, email: str) -> str:
|
||||
"""Resolve a git author to a GitHub @mention."""
|
||||
|
|
@ -2607,8 +2539,6 @@ def main():
|
|||
|
||||
# Commit version bump
|
||||
add_files = [str(VERSION_FILE), str(PYPROJECT_FILE)]
|
||||
if ACP_REGISTRY_MANIFEST.exists():
|
||||
add_files.append(str(ACP_REGISTRY_MANIFEST))
|
||||
add_result = git_result("add", *add_files)
|
||||
if add_result.returncode != 0:
|
||||
print(f" ✗ Failed to stage version files: {add_result.stderr.strip()}")
|
||||
|
|
@ -2641,14 +2571,6 @@ def main():
|
|||
print(" Continue manually after fixing access:")
|
||||
print(" git push origin HEAD --tags")
|
||||
|
||||
# Build semver-named Python artifacts so downstream packagers
|
||||
# (e.g. Homebrew) can target them without relying on CalVer tag names.
|
||||
artifacts = build_release_artifacts(new_version)
|
||||
if artifacts:
|
||||
print(" ✓ Built release artifacts:")
|
||||
for artifact in artifacts:
|
||||
print(f" - {artifact.relative_to(REPO_ROOT)}")
|
||||
|
||||
# Create GitHub release
|
||||
changelog_file = REPO_ROOT / ".release_notes.md"
|
||||
changelog_file.write_text(changelog, encoding="utf-8")
|
||||
|
|
@ -2658,7 +2580,6 @@ def main():
|
|||
"--title", f"Hermes Agent v{new_version} ({calver_date})",
|
||||
"--notes-file", str(changelog_file),
|
||||
]
|
||||
gh_cmd.extend(str(path) for path in artifacts)
|
||||
|
||||
gh_bin = shutil.which("gh")
|
||||
if gh_bin:
|
||||
|
|
@ -2683,9 +2604,9 @@ def main():
|
|||
print(" Tag was created locally. Create the release manually:")
|
||||
print(
|
||||
f" gh release create {tag_name} --title 'Hermes Agent v{new_version} ({calver_date})' "
|
||||
f"--notes-file .release_notes.md {' '.join(str(path) for path in artifacts)}"
|
||||
f"--notes-file .release_notes.md"
|
||||
)
|
||||
print(f"\n ✓ Release artifacts prepared for manual publish: v{new_version} ({tag_name})")
|
||||
print(f"\n ✓ Release v{new_version} ({tag_name}) prepared for manual publish.")
|
||||
else:
|
||||
print(f"\n{'='*60}")
|
||||
print(" Dry run complete. To publish, add --publish")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue