Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,444 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Assemble the unified CI review comment for a pull request.
|
||||
|
||||
Every CI job that wants to appear in the review comment emits a
|
||||
``review_status`` output: a JSON array of objects, each with a ``source``
|
||||
(the workflow name, used for dedup) and a ``results`` array of typed
|
||||
result objects::
|
||||
|
||||
[
|
||||
{
|
||||
"source": "review-label-gate",
|
||||
"results": [
|
||||
{"kind": "action_required", "title": "...", "summary": "...",
|
||||
"how_to_fix": "..."},
|
||||
{"kind": "info", "title": "...", "summary": "..."}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "ci-timings",
|
||||
"results": [
|
||||
{"kind": "warning", "title": "CI timings", "summary": "...",
|
||||
"detail": "...", "link": "..."}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
Each result object has:
|
||||
|
||||
kind: "error" | "action_required" | "warning" | "info" | "debug"
|
||||
title: section heading
|
||||
summary: one-line description
|
||||
detail: markdown detail (optional)
|
||||
how_to_fix: markdown checklist (optional)
|
||||
link: URL (optional)
|
||||
link_label: label for the link (optional, default "View logs")
|
||||
|
||||
The assembler flattens all results into a flat list of ReviewItems,
|
||||
grouped by severity in the comment. Jobs that failed (from the
|
||||
``needs`` context) but didn't emit any status get synthesized ❌ Error
|
||||
items. Jobs that DID emit a status are excluded from the synthesized
|
||||
error list — their own output is the authority for their classification.
|
||||
|
||||
Exits 0 always — comment posting is best-effort (fork PRs are read-only).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
# Hidden marker the comment system uses to find-and-edit its
|
||||
# previous comment instead of stacking new ones on each run.
|
||||
MARKER = "<!-- hermes-ci-review-bot -->"
|
||||
|
||||
# Severity ordering for display.
|
||||
_SEVERITY_ORDER = ["error", "action_required", "warning", "info", "debug"]
|
||||
|
||||
# Severities that trigger the "blocking issues" layout (vs. the
|
||||
# "looks good!" banner).
|
||||
_BLOCKING_SEVERITIES = ("error", "action_required", "warning")
|
||||
|
||||
_SEVERITY_GROUP_HEADER = {
|
||||
"error": "## ❌ Job failures",
|
||||
"action_required": "## ⚠️ Action required",
|
||||
"warning": "## ⚠️ Warnings",
|
||||
"info": "## ℹ️ Details",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReviewItem:
|
||||
"""A single piece of review information with a severity tag."""
|
||||
|
||||
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.)
|
||||
link: str = "" # optional URL emitted by the job (e.g. report URL)
|
||||
link_label: str = "View report" # label for the emitted link
|
||||
how_to_fix: str = "" # optional markdown checklist for action_required items
|
||||
source: str = "" # workflow that declared this status (for dedup)
|
||||
job_url: str = "" # auto-attached per-job log link (from the live poller)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Collectors — each returns a list of ReviewItems (possibly empty)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def collect_from_statuses(review_statuses_json: str) -> tuple[list[ReviewItem], set[str]]:
|
||||
"""Parse the nested review_status JSON into flat ReviewItems.
|
||||
|
||||
The input is a JSON array of ``{source, results: [...]}`` objects.
|
||||
Each entry in ``results`` becomes one ReviewItem, tagged with the
|
||||
parent's ``source``.
|
||||
|
||||
Returns ``(items, sources)`` where ``sources`` is the set of source
|
||||
values — used by :func:`collect_failed_jobs` to exclude jobs that
|
||||
already declared their own status (so a failing job that emitted an
|
||||
``action_required`` status doesn't also show as a synthesized ❌ Error).
|
||||
"""
|
||||
if not review_statuses_json:
|
||||
return [], set()
|
||||
try:
|
||||
data = json.loads(review_statuses_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return [], set()
|
||||
if not isinstance(data, list):
|
||||
return [], set()
|
||||
|
||||
items: list[ReviewItem] = []
|
||||
sources: set[str] = set()
|
||||
|
||||
for entry in data:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
source = entry.get("source", "")
|
||||
if source:
|
||||
sources.add(source)
|
||||
for r in entry.get("results", []):
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
kind = r.get("kind", "info")
|
||||
if kind not in _SEVERITY_ORDER:
|
||||
kind = "info"
|
||||
items.append(ReviewItem(
|
||||
severity=kind,
|
||||
title=r.get("title", "Unknown"),
|
||||
summary=r.get("summary", ""),
|
||||
detail=r.get("detail", ""),
|
||||
link=r.get("link", ""),
|
||||
link_label=r.get("link_label", "View logs"),
|
||||
how_to_fix=r.get("how_to_fix", ""),
|
||||
source=source,
|
||||
))
|
||||
|
||||
return items, sources
|
||||
|
||||
|
||||
def collect_failed_jobs(
|
||||
needs_json: str,
|
||||
run_url: str,
|
||||
exclude_sources: set[str] | None = None,
|
||||
job_urls: dict[str, str] | None = None,
|
||||
) -> list[ReviewItem]:
|
||||
"""Build error items for failed CI jobs from the ``needs`` context.
|
||||
|
||||
``needs_json`` is the JSON string emitted by ``all-checks-pass`` — a
|
||||
``{job_name: result}`` dict where result is ``success`` / ``failure``
|
||||
/ ``skipped``. Only ``failure`` entries become error items.
|
||||
|
||||
``exclude_sources`` is a set of ``source`` values from status objects
|
||||
declared by workflow_call jobs. Job names containing any of these
|
||||
source strings are excluded — their failure is already covered by their
|
||||
own status output.
|
||||
|
||||
``job_urls`` is an optional ``{job_name: html_url}`` dict from the
|
||||
live poller. When a job's name is in this dict, the ❌ Error link
|
||||
points directly to that job's logs page instead of the whole run.
|
||||
Falls back to ``run_url`` when no per-job URL is available.
|
||||
"""
|
||||
if not needs_json:
|
||||
return []
|
||||
try:
|
||||
needs = json.loads(needs_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
|
||||
# Pre-normalize exclude sources once: lowercase + hyphens→spaces, so
|
||||
# "review-label-gate" matches "Review label gate / Review label gate".
|
||||
norm_sources = {
|
||||
src.lower().replace("-", " ") for src in (exclude_sources or set())
|
||||
}
|
||||
|
||||
items: list[ReviewItem] = []
|
||||
for name, result in sorted(needs.items()):
|
||||
if result != "failure":
|
||||
continue
|
||||
if norm_sources:
|
||||
norm = name.lower().replace("-", " ")
|
||||
if any(src in norm for src in norm_sources):
|
||||
continue
|
||||
job_url = (job_urls or {}).get(name, run_url)
|
||||
items.append(ReviewItem(
|
||||
severity="error",
|
||||
title=name,
|
||||
summary=f"Job **{name}** failed.",
|
||||
job_url=job_url,
|
||||
))
|
||||
return items
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _render_item(item: ReviewItem) -> str:
|
||||
"""Render a single ReviewItem as a markdown block.
|
||||
|
||||
The group header (``## ❌ Job failures`` etc.) carries the severity
|
||||
emoji, so items don't repeat it. Links are shown inline next to the
|
||||
title. Layout per item::
|
||||
|
||||
### {title} · [View report](url) · [View job](url)
|
||||
|
||||
{summary}
|
||||
|
||||
{detail}
|
||||
|
||||
**How to fix:**
|
||||
|
||||
{how_to_fix}
|
||||
"""
|
||||
title = f"### {item.title}"
|
||||
# Build inline links next to the title.
|
||||
links: list[str] = []
|
||||
if item.link:
|
||||
links.append(f"[{item.link_label}]({item.link})")
|
||||
if item.job_url:
|
||||
links.append(f"[View job]({item.job_url})")
|
||||
if links:
|
||||
title += " · " + " · ".join(links)
|
||||
|
||||
parts = [title, "", item.summary]
|
||||
|
||||
if item.detail:
|
||||
parts += ["", item.detail]
|
||||
if item.how_to_fix:
|
||||
parts += ["", "**How to fix:**", "", item.how_to_fix]
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _render_group(header: str, items: list[ReviewItem]) -> str:
|
||||
"""Render a severity group: ``##`` header + items separated by ``---``."""
|
||||
blocks = [_render_item(i) for i in items]
|
||||
return f"{header}\n\n" + "\n\n---\n\n".join(blocks)
|
||||
|
||||
|
||||
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 "### debug info\n\n" + "\n\n".join(blocks)
|
||||
|
||||
|
||||
def _render_pending_items(pending_jobs: list[str]) -> str:
|
||||
"""Render the dimmed ``<sub>`` items for jobs still running."""
|
||||
job_list = ", ".join(f"`{j}`" for j in sorted(pending_jobs))
|
||||
return f"\n\n---\n\n<sub>Still running {len(pending_jobs)} job{'s' if len(pending_jobs) != 1 else ''}: {job_list}</sub>\n"
|
||||
|
||||
|
||||
def render_comment(
|
||||
items: list[ReviewItem],
|
||||
pending_jobs: list[str] | None = None,
|
||||
commit_info: str = "",
|
||||
waiting: bool = False,
|
||||
) -> str:
|
||||
"""Render the full comment body from a list of review items.
|
||||
|
||||
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 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, an "all good!"
|
||||
banner is shown at the top. Info items remain visible and debug items
|
||||
follow in collapsible ``<details>`` blocks.
|
||||
|
||||
``waiting`` means a workflow run is still queued or in progress even
|
||||
though no individual job is visibly pending — GitHub has not spawned
|
||||
the jobs yet. The comment must not look final in that state, so the
|
||||
"all good!" banner is replaced by a waiting note and a dimmed footer
|
||||
marks the comment as still live.
|
||||
"""
|
||||
pending = pending_jobs or []
|
||||
|
||||
# Group by severity
|
||||
by_severity: dict[str, list[ReviewItem]] = {s: [] for s in _SEVERITY_ORDER}
|
||||
for item in items:
|
||||
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"
|
||||
|
||||
if commit_info:
|
||||
body += f"{commit_info}\n\n"
|
||||
|
||||
if not items and not pending:
|
||||
if waiting:
|
||||
return f"{body}<sub>waiting for jobs to start…</sub>"
|
||||
return f"{body}all good!"
|
||||
|
||||
sections: list[str] = []
|
||||
|
||||
for sev in _BLOCKING_SEVERITIES:
|
||||
group = by_severity.get(sev, [])
|
||||
if group:
|
||||
sections.append(_render_group(_SEVERITY_GROUP_HEADER[sev], group))
|
||||
|
||||
if 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)
|
||||
elif waiting:
|
||||
body += "\n\n---\n\n<sub>waiting for more jobs to start…</sub>\n"
|
||||
|
||||
if sections:
|
||||
body += "\n\n---\n\n".join(sections)
|
||||
|
||||
return body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Assembly
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _attach_job_urls(items: list[ReviewItem], job_urls: dict[str, str], run_url: str) -> None:
|
||||
"""Fill in per-job log links for all items.
|
||||
|
||||
Uses the same case-insensitive, hyphen-normalized matching as
|
||||
:func:`collect_failed_jobs`: the item's ``source`` is matched against
|
||||
job names in ``job_urls``. Sets ``job_url`` on the item — this is
|
||||
separate from ``link`` (the job-emitted URL, e.g. a report artifact),
|
||||
so both can appear in the rendered comment.
|
||||
"""
|
||||
if not job_urls and not run_url:
|
||||
return
|
||||
# Pre-normalize job_url keys once.
|
||||
norm_urls: dict[str, str] = {}
|
||||
for name, url in job_urls.items():
|
||||
norm_urls[name.lower().replace("-", " ")] = url
|
||||
|
||||
for item in items:
|
||||
if item.job_url:
|
||||
continue
|
||||
src = item.source.lower().replace("-", " ")
|
||||
# Try exact match first, then substring match.
|
||||
if src in norm_urls:
|
||||
item.job_url = norm_urls[src]
|
||||
continue
|
||||
for norm_name, url in norm_urls.items():
|
||||
if src and src in norm_name:
|
||||
item.job_url = url
|
||||
break
|
||||
# If no per-job URL found, fall back to run_url for items with a source.
|
||||
if not item.job_url and item.source and run_url:
|
||||
item.job_url = run_url
|
||||
|
||||
|
||||
def assemble(
|
||||
needs_json: str = "",
|
||||
run_url: str = "",
|
||||
job_urls: dict[str, str] | None = None,
|
||||
review_statuses_json: str = "",
|
||||
pending_jobs: list[str] | None = None,
|
||||
commit_info: str = "",
|
||||
waiting: bool = False,
|
||||
) -> str:
|
||||
"""Assemble the full comment body from all available inputs."""
|
||||
items: list[ReviewItem] = []
|
||||
|
||||
# 1. Structured statuses from workflow_call jobs (review-labels, etc.)
|
||||
status_items, sources = collect_from_statuses(review_statuses_json)
|
||||
items.extend(status_items)
|
||||
|
||||
# 2. Synthesized error items for failed jobs not covered by statuses
|
||||
items.extend(collect_failed_jobs(needs_json, run_url, exclude_sources=sources, job_urls=job_urls))
|
||||
|
||||
# 3. Attach per-job log links to all items (not just synthesized errors)
|
||||
_attach_job_urls(items, job_urls or {}, run_url)
|
||||
|
||||
return render_comment(items, pending_jobs, commit_info, waiting=waiting)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--needs-json",
|
||||
default="",
|
||||
help="JSON string of {job_name: result} from the all-checks-pass job.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--run-url",
|
||||
default="",
|
||||
help="URL to the CI run summary page (for failed job links).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--review-statuses-json",
|
||||
default="",
|
||||
help="JSON array of {source, results: [...]} objects from workflow_call jobs.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pending-jobs",
|
||||
default="",
|
||||
help="Comma-separated list of job names still running (shown in a dimmed footer).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="Output file for the assembled comment body.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
pending = [j.strip() for j in args.pending_jobs.split(",") if j.strip()] if args.pending_jobs else None
|
||||
|
||||
body = assemble(
|
||||
needs_json=args.needs_json,
|
||||
run_url=args.run_url,
|
||||
review_statuses_json=args.review_statuses_json,
|
||||
pending_jobs=pending,
|
||||
)
|
||||
|
||||
args.output.write_text(body, encoding="utf-8")
|
||||
print(f"Wrote {len(body)} chars to {args.output}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reject profile export archives before publication.
|
||||
|
||||
``.gitignore`` and ``.dockerignore`` are useful first-line filters, but both
|
||||
can be bypassed (for example with ``git add -f`` or a non-standard build
|
||||
context). This check is the blocking, executable policy at the CI and image
|
||||
publication boundaries. It intentionally checks the filesystem rather than
|
||||
Git's index so a generated archive cannot enter a build after checkout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_PROFILE_ARCHIVE_SUFFIXES = (".tar.gz", ".tgz")
|
||||
|
||||
|
||||
def find_forbidden_profile_archives(root: Path) -> list[Path]:
|
||||
"""Return profile archive paths anywhere in the checkout."""
|
||||
root = root.resolve()
|
||||
if not root.is_dir():
|
||||
raise ValueError(f"repository root is not a directory: {root}")
|
||||
|
||||
offenders: list[Path] = []
|
||||
for directory, dirnames, filenames in os.walk(root, followlinks=False):
|
||||
dirnames[:] = [
|
||||
name
|
||||
for name in dirnames
|
||||
if name not in {".git", ".venv", "venv", "node_modules", "__pycache__"}
|
||||
]
|
||||
for name in (*dirnames, *filenames):
|
||||
if name.casefold().endswith(_PROFILE_ARCHIVE_SUFFIXES):
|
||||
offenders.append((Path(directory) / name).relative_to(root))
|
||||
|
||||
return sorted(offenders, key=lambda path: path.as_posix().casefold())
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Reject profile export archives in the checkout."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--root",
|
||||
type=Path,
|
||||
default=Path.cwd(),
|
||||
help="repository root to inspect (default: current directory)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
offenders = find_forbidden_profile_archives(args.root)
|
||||
except ValueError as exc:
|
||||
parser.error(str(exc))
|
||||
|
||||
if not offenders:
|
||||
print("No profile export archives detected in the checkout.")
|
||||
return 0
|
||||
|
||||
print(
|
||||
"::error::profile export archives are forbidden "
|
||||
"in source and Docker build contexts"
|
||||
)
|
||||
for path in offenders:
|
||||
print(f" {path.as_posix()}")
|
||||
print(
|
||||
"Move the archive outside the checkout or pass an explicit external "
|
||||
"output path to the profile export command."
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,340 @@
|
||||
#!/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.
|
||||
* ``python_prod`` — Python changes OUTSIDE tests/ — gates jobs that ship or
|
||||
run the product (Desktop E2E backend, Docker image) but never import the
|
||||
test suite. A tests-only PR keeps ``python`` (pytest must run) while
|
||||
skipping those product jobs.
|
||||
* ``docker_meta`` — Dockerfiles etc.
|
||||
* ``docker`` — any product change + docker meta
|
||||
* ``nix`` — ``nix flake check``: the flake inputs and any product change.
|
||||
* ``frontend`` — TS typecheck matrix + desktop build.
|
||||
* ``site`` — Docusaurus + generated skill docs.
|
||||
* ``scan`` — supply-chain scan (Python files, .pth, setup hooks).
|
||||
* ``deps`` — pyproject.toml dependency bounds check.
|
||||
* ``uv_lock`` — ``uv lock --check``. Re-resolves the whole graph against
|
||||
PyPI, so a diff that touches neither ``pyproject.toml`` nor ``uv.lock``
|
||||
must not run it.
|
||||
* ``npm_lock`` — semantic package-lock.json diff PR comment.
|
||||
* ``installer`` — PowerShell installer tests (Windows runner).
|
||||
* ``desktop_updater`` — the Windows desktop-update hand-off script and the
|
||||
tests that drive the REAL ``windows.ps1`` (``-SelfTestUi`` / pipe drain /
|
||||
retry policy). These are integration tests of a PowerShell process on a
|
||||
shared runner; running them on every Python PR made their timing noise
|
||||
everyone's problem. They still run on push (fail-open) and whenever the
|
||||
script, its siblings, or their tests change.
|
||||
* ``rust`` — ``cargo test`` for the Tauri bootstrap installer. ``.rs``
|
||||
lives under ``apps/``, so without this lane a Rust change matched ``frontend``
|
||||
and only the TypeScript matrix ran.
|
||||
* ``mcp_catalog`` — bundled MCP catalog / installer review.
|
||||
|
||||
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.
|
||||
* ``nix/``, ``flake.nix`` and ``flake.lock`` are the exception the other way:
|
||||
only the flake reads them, so they skip the Python lanes and run ``nix``
|
||||
alone. ``pyproject.toml`` and ``uv.lock`` are flake inputs too, but the
|
||||
packaging tests read them, so they keep every Python lane.
|
||||
* ``website/static/oauth/`` is python-relevant too: it publishes the OAuth
|
||||
Client ID Metadata Document that ``tests/tools/test_mcp_cimd.py`` checks
|
||||
against the pinned callback ports in ``tools/mcp_oauth.py``.
|
||||
* ``website/docs/`` and ``website/scripts/`` are python-relevant for the same
|
||||
reason: the docs tree generates ``llms.txt``, and
|
||||
``tests/website/test_generate_llms_txt.py`` asserts every page reaches it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
_FRONTEND = ("ui-tui/", "web/", "apps/") # TS typecheck-matrix packages
|
||||
_ROOT_NPM = {"package.json", "package-lock.json"} # shifts every package's tree
|
||||
_DOCKER_META = ("docker/", ".hadolint.yml", "Dockerfile") # docker setup
|
||||
_NIX_PATHS = ("nix/",) # nix files
|
||||
_NIX_FILES = {"flake.nix", "flake.lock"} # base nix files
|
||||
_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
|
||||
# Published artifacts that live under website/ but that Python asserts about.
|
||||
# The OAuth Client ID Metadata Document is cross-checked against the pinned
|
||||
# callback ports in tools/mcp_oauth.py, so editing it alone must still run the
|
||||
# Python lane — otherwise dropping a redirect URI goes green here and breaks
|
||||
# every CIMD login on main.
|
||||
# website/docs/ and website/scripts/ are asserted about the same way. The docs
|
||||
# tree generates llms.txt — the index every LLM (Hermes included, via the
|
||||
# hermes-agent skill) reads to learn what Hermes can do — and
|
||||
# tests/website/test_generate_llms_txt.py holds every page to appearing in it.
|
||||
# Skipping Python on a docs-only PR is how the index drifted to 53% coverage.
|
||||
_PY_RELEVANT_SITE = (
|
||||
"website/static/oauth/",
|
||||
"website/docs/",
|
||||
"website/scripts/",
|
||||
)
|
||||
|
||||
# CI-sensitive files: eslint config, workflow files, composite actions.
|
||||
# Changes here can influence what code the autofix job executes and pushes to
|
||||
# main, so they require explicit maintainer review (ci-reviewed label).
|
||||
#
|
||||
# package.json is deliberately NOT listed here: npm scripts only execute on the
|
||||
# unprivileged generate-patch runner (contents: read), never on the privileged
|
||||
# apply-patch job. The two-job split means a malicious package.json script
|
||||
# can't get push access — it runs on an ephemeral runner with zero write perms.
|
||||
_CI_REVIEW_FILES = {
|
||||
".prettierrc",
|
||||
}
|
||||
_CI_REVIEW_PATHS = (".github/workflows/", ".github/actions/")
|
||||
|
||||
# Supply-chain scan: files that can execute code at install/import time.
|
||||
_SCAN_EXTS = (".py", ".pth")
|
||||
_SCAN_FILES = {"setup.cfg", "pyproject.toml"}
|
||||
|
||||
# MCP catalog files that require explicit security review.
|
||||
_MCP_CATALOG_PATHS = ("optional-mcps/",)
|
||||
_MCP_CATALOG_FILES = {"hermes_cli/mcp_catalog.py"}
|
||||
|
||||
# Windows installer + its PowerShell tests. These only run on a Windows runner,
|
||||
# so they get their own lane rather than riding along with ``python``.
|
||||
_INSTALLER_PATHS = ("scripts/tests/",)
|
||||
_INSTALLER_FILES = {"scripts/install.ps1", "scripts/install.cmd"}
|
||||
|
||||
# Windows desktop-update hand-off (scripts/desktop-update/windows.ps1 + the
|
||||
# Electron side that launches it) and the pytest files that spawn it.
|
||||
_DESKTOP_UPDATER_PATHS = ("scripts/desktop-update/",)
|
||||
_DESKTOP_UPDATER_TEST_PREFIX = "tests/test_desktop_update_"
|
||||
_DESKTOP_UPDATER_FILES = {
|
||||
"apps/desktop/electron/updater-process.ts",
|
||||
"apps/desktop/electron/managed-ssh-update.ts",
|
||||
"tests/conftest.py",
|
||||
"pyproject.toml",
|
||||
}
|
||||
|
||||
# Rust crates — currently just the Tauri bootstrap installer (Hermes-Setup).
|
||||
# These live under ``apps/``, so before this lane existed a ``.rs`` edit matched
|
||||
# ``frontend`` and nothing more: the TypeScript matrix built, cargo never ran,
|
||||
# and the crate's unit tests had never executed in CI at all.
|
||||
_RUST_PATHS = ("apps/bootstrap-installer/src-tauri/",)
|
||||
_RUST_FILENAMES = {"Cargo.toml", "Cargo.lock"}
|
||||
|
||||
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 _is_nix(p: str) -> bool:
|
||||
return p.startswith(_NIX_PATHS) or p in _NIX_FILES
|
||||
|
||||
|
||||
def _py_irrelevant(p: str) -> bool:
|
||||
if p.startswith(_PY_RELEVANT_SITE):
|
||||
return False
|
||||
return (
|
||||
_is_docs(p)
|
||||
or p in _ROOT_NPM
|
||||
or p.startswith(_PY_SKIP)
|
||||
or p.startswith(_DOCKER_META)
|
||||
or _is_nix(p)
|
||||
)
|
||||
|
||||
|
||||
def _py_test_only(p: str) -> bool:
|
||||
"""Is ``p`` inside the test suite (never shipped / imported by the product)?
|
||||
|
||||
Product jobs (Desktop E2E's ``hermes serve`` backend, the Docker image)
|
||||
run installed code — nothing under ``tests/`` is packaged or importable
|
||||
there. scripts/run_tests.sh and run_tests_parallel.py are deliberately
|
||||
NOT test-only: they are runner infrastructure, and a bad edit there can
|
||||
mask real failures, so they stay conservative (python_prod=true).
|
||||
"""
|
||||
return p.startswith("tests/")
|
||||
|
||||
|
||||
def _is_scan(p: str) -> bool:
|
||||
return p.endswith(_SCAN_EXTS) or p in _SCAN_FILES
|
||||
|
||||
|
||||
def _is_mcp_catalog(p: str) -> bool:
|
||||
return p.startswith(_MCP_CATALOG_PATHS) or p in _MCP_CATALOG_FILES
|
||||
|
||||
|
||||
def _is_installer(p: str) -> bool:
|
||||
return p.startswith(_INSTALLER_PATHS) or p in _INSTALLER_FILES
|
||||
|
||||
|
||||
def _is_desktop_updater(p: str) -> bool:
|
||||
return (
|
||||
p.startswith(_DESKTOP_UPDATER_PATHS)
|
||||
or p.startswith(_DESKTOP_UPDATER_TEST_PREFIX)
|
||||
or p in _DESKTOP_UPDATER_FILES
|
||||
)
|
||||
|
||||
|
||||
def _is_rust(p: str) -> bool:
|
||||
return (
|
||||
p.endswith(".rs")
|
||||
or p.startswith(_RUST_PATHS)
|
||||
or os.path.basename(p) in _RUST_FILENAMES
|
||||
)
|
||||
|
||||
|
||||
def _is_ci_review(p: str) -> bool:
|
||||
if p in _CI_REVIEW_FILES or p.startswith(_CI_REVIEW_PATHS):
|
||||
return True
|
||||
# Any eslint config file at any path — eslint configs can define custom
|
||||
# fix functions that execute arbitrary code, so they all require review.
|
||||
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()]
|
||||
python = any(not _py_irrelevant(f) for f in files)
|
||||
python_prod = any(not _py_irrelevant(f) and not _py_test_only(f) for f in files)
|
||||
frontend = any(f.startswith(_FRONTEND) or f in _ROOT_NPM for f in files)
|
||||
deps = any(f == "pyproject.toml" for f in files)
|
||||
npm_lock = any(f.split("/")[-1] == "package-lock.json" for f in files)
|
||||
docker_meta = any(f.startswith(_DOCKER_META) for f in files)
|
||||
|
||||
ret = {
|
||||
"python": python,
|
||||
"python_prod": python_prod,
|
||||
"docker": docker_meta or python_prod or frontend,
|
||||
"docker_meta": docker_meta,
|
||||
"frontend": frontend,
|
||||
"site": any(f.startswith(_SITE) for f in files),
|
||||
"scan": any(_is_scan(f) for f in files),
|
||||
"deps": deps,
|
||||
"uv_lock": any(f in ("pyproject.toml", "uv.lock") for f in files),
|
||||
"npm_lock": npm_lock,
|
||||
"installer": any(_is_installer(f) for f in files),
|
||||
"desktop_updater": any(_is_desktop_updater(f) for f in files),
|
||||
"rust": any(_is_rust(f) for f in files),
|
||||
"mcp_catalog": any(_is_mcp_catalog(f) for f in files),
|
||||
"ci_review": any(_is_ci_review(f) for f in files),
|
||||
"nix": python_prod or frontend or any(_is_nix(f) for f in files)
|
||||
}
|
||||
if not files or any(f.startswith(".github/") for f in files):
|
||||
ret["python"] = True
|
||||
ret["python_prod"] = True
|
||||
ret["docker"] = True
|
||||
ret["docker_meta"] = True
|
||||
ret["frontend"] = True
|
||||
ret["site"] = True
|
||||
ret["scan"] = True
|
||||
ret["deps"] = True
|
||||
ret["uv_lock"] = True
|
||||
ret["npm_lock"] = True
|
||||
ret["installer"] = True
|
||||
ret["desktop_updater"] = True
|
||||
ret["rust"] = True
|
||||
ret["nix"] = True
|
||||
ret["ci_review"] = True
|
||||
|
||||
# explicitly skip mcp catalog here. it's not needed unless those files are modified.
|
||||
return ret
|
||||
|
||||
|
||||
def _pull_request_number() -> str | None:
|
||||
"""Read the PR number from the Actions event payload, if present."""
|
||||
event_path = os.environ.get("GITHUB_EVENT_PATH")
|
||||
if not event_path:
|
||||
return None
|
||||
try:
|
||||
with open(event_path, encoding="utf-8") as fh:
|
||||
payload = json.load(fh)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
number = (payload.get("pull_request") or {}).get("number")
|
||||
return str(number) if number else None
|
||||
|
||||
|
||||
def pull_request_changed_files() -> list[str]:
|
||||
"""Recover the PR file list when the compare API returned nothing.
|
||||
|
||||
``detect-changes`` calls ``repos/.../compare/base...head`` with raw SHAs.
|
||||
A fork force-push can 404 for ~30s until GitHub attaches the new head SHA
|
||||
to the base repo, so the action fails open with an empty file list. That
|
||||
forces ``ci_review=true`` and blocks the PR on a ``ci-reviewed`` label
|
||||
even when no CI-sensitive file changed.
|
||||
|
||||
The pull-request files endpoint already knows the PR's files (it is how
|
||||
this action used to classify), so use it as a fallback on pull_request
|
||||
events only. Push/dispatch keep the empty-diff fail-open.
|
||||
"""
|
||||
if os.environ.get("EVENT_NAME") != "pull_request":
|
||||
return []
|
||||
repo = os.environ.get("REPO") or os.environ.get("GITHUB_REPOSITORY") or ""
|
||||
pr = _pull_request_number()
|
||||
if not repo or not pr:
|
||||
return []
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"gh",
|
||||
"api",
|
||||
"--paginate",
|
||||
f"repos/{repo}/pulls/{pr}/files",
|
||||
"--jq",
|
||||
".[].filename",
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return []
|
||||
if completed.returncode != 0:
|
||||
return []
|
||||
return [line.strip() for line in completed.stdout.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
files = sys.stdin.read().splitlines()
|
||||
if not any(f.strip() for f in files):
|
||||
recovered = pull_request_changed_files()
|
||||
if recovered:
|
||||
print(
|
||||
f"compare API returned no files; recovered {len(recovered)} "
|
||||
"path(s) from the pull request files endpoint",
|
||||
file=sys.stderr,
|
||||
)
|
||||
files = recovered
|
||||
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")
|
||||
print(out) # echo for local runs + CI step logs
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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())
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Emit review_status JSON for the review-labels workflow.
|
||||
|
||||
Builds a JSON array with one entry::
|
||||
|
||||
[
|
||||
{
|
||||
"source": "review-label-gate",
|
||||
"results": [
|
||||
{"kind": "action_required", "title": "...", "summary": "...",
|
||||
"how_to_fix": "..."},
|
||||
{"kind": "info", "title": "...", "summary": "..."}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
The ``source`` field is the workflow name that declared the status; the
|
||||
assembler uses it to exclude the corresponding job from the synthesized
|
||||
❌ Error list (the job already has its own status section).
|
||||
|
||||
The array can contain 0 to 3 results — one per lane that ran
|
||||
(``ci_review``, ``mcp_catalog``, ``supply_chain``). When the ``ci-reviewed`` label is
|
||||
present, the kind is ``info``; when missing, it's ``action_required``
|
||||
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
|
||||
# GitHub Actions API. The ci.yml job key is ``review-labels`` with
|
||||
# ``name: Review label gate``, and the reusable workflow's job is also
|
||||
# ``name: Review label gate``, so the API shows the job as
|
||||
# "Review label gate / Review label gate". Normalizing "review-label-gate"
|
||||
# (lowercase, hyphens→spaces) gives "review label gate", which is a
|
||||
# substring of "review label gate / review label gate".
|
||||
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:
|
||||
result = {
|
||||
"kind": "info",
|
||||
"title": "CI-sensitive file review",
|
||||
"summary": (
|
||||
"PR touches sensitive files, but the `ci-reviewed` label has been "
|
||||
"added, approving them."
|
||||
),
|
||||
}
|
||||
else:
|
||||
result = {
|
||||
"kind": "action_required",
|
||||
"title": "CI-sensitive file review",
|
||||
"summary": (
|
||||
"This PR changes CI-sensitive files (eslint config, "
|
||||
"workflow YAMLs, or composite actions). These influence "
|
||||
"what the js-autofix job executes and pushes to main."
|
||||
),
|
||||
"how_to_fix": (
|
||||
"Add the `ci-reviewed` label after verifying:\n"
|
||||
"- no new eslint rules with custom `fix` functions that write outside linted paths,\n"
|
||||
"- 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": "debug",
|
||||
"title": "MCP catalog security review",
|
||||
"summary": "`ci-reviewed` label is present.",
|
||||
})
|
||||
else:
|
||||
results.append({
|
||||
"kind": "action_required",
|
||||
"title": "MCP catalog security review",
|
||||
"summary": (
|
||||
"This PR changes the bundled MCP catalog or MCP catalog "
|
||||
"installer code. MCP entries can define local commands "
|
||||
"that users later install into `mcp_servers`, so this "
|
||||
"needs explicit maintainer review before merge."
|
||||
),
|
||||
"how_to_fix": (
|
||||
"Add the `ci-reviewed` label after verifying:\n"
|
||||
"- any new/changed `optional-mcps/**/manifest.yaml` command and args are expected,\n"
|
||||
"- stdio transports do not use shell+egress/exfiltration payloads,\n"
|
||||
"- git install refs are pinned and bootstrap commands are minimal,\n"
|
||||
"- requested env vars/secrets match the upstream MCP's documented needs."
|
||||
),
|
||||
})
|
||||
|
||||
if supply_chain and not label_present:
|
||||
results.append({
|
||||
"kind": "action_required",
|
||||
"title": "Critical supply chain risk",
|
||||
"summary": "Critical supply chain risk patterns were detected in this PR.",
|
||||
"how_to_fix": (
|
||||
"Review the flagged code carefully. If it is intentional, add the "
|
||||
"`ci-reviewed` label to confirm maintainer review."
|
||||
),
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def build_statuses(
|
||||
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 full review_status array (one entry with a results list)."""
|
||||
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}]
|
||||
|
||||
|
||||
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_files, args.repo_url, args.base_sha, args.head_sha,
|
||||
)
|
||||
json_str = json.dumps(statuses)
|
||||
|
||||
if args.output == "-":
|
||||
print(json_str)
|
||||
else:
|
||||
# GITHUB_OUTPUT format: key=value\n
|
||||
with open(args.output, "a", encoding="utf-8") as f:
|
||||
f.write(f"review_status={json_str}\n")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""List the test files that carry a given OS marker.
|
||||
|
||||
Used by ``.github/workflows/tests-os.yml`` to scope what the macOS and
|
||||
Windows lanes import.
|
||||
|
||||
Why scope at all, when ``pytest -m macos_only`` already selects correctly?
|
||||
Because ``-m`` filters AFTER collection, and collection IMPORTS every test
|
||||
module under ``tests/``. On the Linux lane that is fine (it runs them all
|
||||
anyway), but on the macOS/Windows lanes it would drag ~900 unrelated modules
|
||||
through import on a host they were never expected to import on — one
|
||||
unrelated ImportError would fail a job whose actual subject passed. Narrowing
|
||||
the paths keeps each lane's failure signal about its own tests.
|
||||
|
||||
``-m`` is still passed by the workflow and remains the authoritative
|
||||
selector: this script only decides which files get imported, never which
|
||||
tests run. Over-selecting here is harmless (``-m`` drops the extras); the
|
||||
failure mode to care about is UNDER-selecting, which is why the workflow
|
||||
fails the job when zero tests end up selected.
|
||||
|
||||
Usage:
|
||||
python scripts/ci/list_os_marked_tests.py macos_only [tests_root]
|
||||
|
||||
Prints one path per line (POSIX separators, repo-relative), sorted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_VALID_MARKERS = ("linux_only", "macos_only", "windows_only")
|
||||
|
||||
|
||||
def find_marked_files(marker: str, root: Path) -> list[Path]:
|
||||
"""Return every ``test_*.py`` under *root* that references *marker*.
|
||||
|
||||
Matches the marker as a whole word so ``macos_only`` doesn't pick up a
|
||||
hypothetical ``macos_only_extra``. Catches both the decorator form
|
||||
(``@pytest.mark.macos_only``, on a function or a class) and the
|
||||
module-level ``pytestmark`` form.
|
||||
"""
|
||||
pattern = re.compile(rf"\b{re.escape(marker)}\b")
|
||||
hits: list[Path] = []
|
||||
for path in sorted(root.rglob("test_*.py")):
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
if pattern.search(text):
|
||||
hits.append(path)
|
||||
return hits
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) < 2:
|
||||
print(__doc__, file=sys.stderr)
|
||||
return 2
|
||||
marker = argv[1]
|
||||
if marker not in _VALID_MARKERS:
|
||||
print(
|
||||
f"error: unknown marker {marker!r} (expected one of "
|
||||
f"{', '.join(_VALID_MARKERS)})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
root = Path(argv[2]) if len(argv) > 2 else repo_root / "tests"
|
||||
if not root.exists():
|
||||
print(f"error: no such directory: {root}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
files = find_marked_files(marker, root)
|
||||
if not files:
|
||||
print(
|
||||
f"error: no test file references @pytest.mark.{marker} — the marker "
|
||||
"was probably renamed or dropped. Refusing to emit an empty list, "
|
||||
"which would let the OS lane pass without running anything.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
lines: list[str] = []
|
||||
for path in files:
|
||||
# POSIX separators so the output is safe to paste into a bash
|
||||
# command line on the Windows runner (Git Bash accepts them).
|
||||
#
|
||||
# Relative to the repo root when the path is inside it (the CI case —
|
||||
# pytest is invoked from the repo root). A root outside the repo is a
|
||||
# test/manual invocation; emit it as-is rather than raising, since
|
||||
# ``relative_to`` refuses non-descendant paths.
|
||||
try:
|
||||
rel = path.resolve().relative_to(repo_root)
|
||||
except ValueError:
|
||||
lines.append(path.as_posix())
|
||||
else:
|
||||
lines.append(rel.as_posix())
|
||||
|
||||
# Write bytes with explicit LF rather than print(), which on Windows
|
||||
# translates "\n" to "\r\n" in text mode. The consumer reads this list with
|
||||
# ``$(cat ...)`` in bash, and word splitting uses IFS (space/tab/newline) —
|
||||
# a CR is NOT a separator, so it stays glued to each path and pytest then
|
||||
# fails with "file or directory not found: tests/...py" for a path that
|
||||
# looks correct in the log because the CR is invisible. Emitting bytes makes
|
||||
# the output identical on every host instead of depending on the platform's
|
||||
# newline translation.
|
||||
sys.stdout.buffer.write(b"".join(line.encode("utf-8") + b"\n" for line in lines))
|
||||
sys.stdout.buffer.flush()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -0,0 +1,794 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Live-updating CI review comment.
|
||||
|
||||
Polls the GitHub Actions API for job statuses in the CI run, assembles
|
||||
the review comment from whatever results are available, and upserts it as a
|
||||
PR comment. Repeats every ``--interval`` seconds until all jobs are
|
||||
completed (or ``--timeout`` is reached), so the comment updates in real time
|
||||
as each job finishes.
|
||||
|
||||
The comment is identified by the ``<!-- hermes-ci-review-bot -->`` marker
|
||||
— the same one ``assemble_review_comment.py`` uses — so it replaces any
|
||||
previous comment from an earlier run.
|
||||
|
||||
This runs from ``.github/workflows/ci-review-comment.yml``, a separate
|
||||
``workflow_run`` workflow. Thus ``CI_RUN_ID`` names the CI run to report
|
||||
on, not the run that contains this script. (The variable cannot be
|
||||
called ``GITHUB_RUN_ID``: the Actions runner sets the ``GITHUB_*``
|
||||
defaults itself and ignores an ``env:`` override, so that name would
|
||||
silently resolve to the poller's own run — which stays ``in_progress``
|
||||
for as long as the poller runs, deadlocking it against itself.)
|
||||
The poller reports on runs that
|
||||
it does not belong to. This is also how it covers a workflow that CI does
|
||||
not contain: ``WATCH_WORKFLOWS`` names sibling workflows that the same
|
||||
commit triggered (the Docker image build). Their jobs join the comment.
|
||||
|
||||
Architecture:
|
||||
|
||||
- :func:`classify_jobs` (pure, testable) — takes a list of raw API job
|
||||
dicts and returns ``(completed, pending, job_urls)`` where ``completed``
|
||||
is a ``{name: result}`` dict (for :func:`assemble_review_comment.assemble`)
|
||||
and ``pending`` is a list of job names still running.
|
||||
|
||||
- :func:`select_watched_runs` (pure, testable) — picks the sibling runs
|
||||
to merge in, newest attempt per workflow.
|
||||
|
||||
- :func:`find_comment_id` / :func:`upsert_comment` — thin API wrappers.
|
||||
|
||||
- :func:`fetch_all_review_statuses` — lists all ``review-status-*``
|
||||
artifacts on the CI run (GitHub attaches reusable-workflow
|
||||
artifacts to the caller run), downloads each, parses the
|
||||
``review_status=`` line from ``review-status.json``, and merges into
|
||||
one array. Recomputed from source every poll cycle, so statuses
|
||||
appear as soon as each job uploads its artifact.
|
||||
|
||||
- :func:`run` — the polling loop. Calls the API, classifies,
|
||||
fetches artifacts, assembles, 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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
API_BASE = "https://api.github.com"
|
||||
|
||||
# Job names that are infrastructure (this script, the gate, the detector)
|
||||
# and should never appear in the review comment.
|
||||
_INFRA_JOBS = frozenset({
|
||||
"detect",
|
||||
"all-checks-pass",
|
||||
"comment-pending",
|
||||
"comment-results",
|
||||
"comment-live",
|
||||
"CI review comment (pending)",
|
||||
"CI review comment (results)",
|
||||
"CI review comment (live)",
|
||||
"All required checks pass",
|
||||
"Detect affected areas",
|
||||
})
|
||||
|
||||
# Map GitHub API conclusion values to our result strings.
|
||||
_CONCLUSION_MAP = {
|
||||
"success": "success",
|
||||
"failure": "failure",
|
||||
"skipped": "skipped",
|
||||
"cancelled": "skipped",
|
||||
"neutral": "skipped",
|
||||
"timed_out": "failure",
|
||||
"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.
|
||||
|
||||
Returns ``(completed, pending, job_urls)``:
|
||||
|
||||
- ``completed``: ``{job_name: result}`` where result is
|
||||
``"success"`` / ``"failure"`` / ``"skipped"``. Only non-infra jobs
|
||||
that have finished.
|
||||
- ``pending``: list of job names still running (in_progress / queued
|
||||
/ waiting). Excludes infra jobs.
|
||||
- ``job_urls``: ``{job_name: html_url}`` — direct links to each
|
||||
job's logs page, for the assembler to use in ❌ Error links.
|
||||
|
||||
The API returns orchestrator-level jobs and sub-workflow jobs
|
||||
(workflow_call) in separate runs — :func:`collect_run_jobs` merges
|
||||
them. Each sub-workflow job has a ``_workflow_name`` prefix so the
|
||||
display name is ``"Workflow / job"``.
|
||||
"""
|
||||
completed: dict[str, str] = {}
|
||||
pending: list[str] = []
|
||||
job_urls: dict[str, str] = {}
|
||||
|
||||
for job in api_jobs:
|
||||
name = job.get("name", "unknown")
|
||||
if job.get("_workflow_name"):
|
||||
name = f"{job['_workflow_name']} / {name}"
|
||||
if name in _INFRA_JOBS:
|
||||
continue
|
||||
status = job.get("status", "")
|
||||
conclusion = job.get("conclusion", "")
|
||||
html_url = job.get("html_url", "")
|
||||
|
||||
if html_url:
|
||||
job_urls[name] = html_url
|
||||
|
||||
if status in ("in_progress", "queued", "waiting"):
|
||||
pending.append(name)
|
||||
elif status == "completed":
|
||||
result = _CONCLUSION_MAP.get(conclusion, "skipped")
|
||||
completed[name] = result
|
||||
# else: unknown status → skip
|
||||
|
||||
return completed, pending, job_urls
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _api_request(url: str, token: str) -> dict:
|
||||
"""Authenticated GitHub API GET (single page)."""
|
||||
req = urllib.request.Request(url, headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"User-Agent": "ci-live-comment",
|
||||
})
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
data: dict = json.loads(resp.read())
|
||||
return data
|
||||
|
||||
|
||||
def _api_get_paginated(url: str, token: str, list_key: str | None = None) -> list:
|
||||
"""Authenticated GitHub API GET with pagination."""
|
||||
results: list = []
|
||||
while url:
|
||||
req = urllib.request.Request(url, headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"User-Agent": "ci-live-comment",
|
||||
})
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
data = json.loads(resp.read())
|
||||
link_header = resp.headers.get("Link", "")
|
||||
|
||||
if list_key:
|
||||
results.extend(data.get(list_key, []))
|
||||
elif isinstance(data, list):
|
||||
results.extend(data)
|
||||
else:
|
||||
return data
|
||||
|
||||
next_url = None
|
||||
for part in link_header.split(","):
|
||||
part = part.strip()
|
||||
if 'rel="next"' in part:
|
||||
next_url = part[part.find("<") + 1:part.find(">")]
|
||||
break
|
||||
url = next_url
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def select_watched_runs(
|
||||
runs: list[dict], watch_names: list[str], exclude_run_id: str = "",
|
||||
) -> list[dict]:
|
||||
"""Pick the sibling runs whose jobs belong in the comment.
|
||||
|
||||
``runs`` is the API's run list for one commit. ``watch_names`` holds
|
||||
workflow names from ``WATCH_WORKFLOWS``. One commit can have more than
|
||||
one run of the same workflow, after a rerun or a new push. Thus this
|
||||
keeps only the newest run for each workflow name. An older attempt
|
||||
reports results that a rerun replaced.
|
||||
|
||||
``exclude_run_id`` removes the CI run itself when its name is also in
|
||||
``watch_names``.
|
||||
"""
|
||||
newest: dict[str, dict] = {}
|
||||
wanted = {n.strip() for n in watch_names if n.strip()}
|
||||
|
||||
for candidate in runs:
|
||||
name = str(candidate.get("name", ""))
|
||||
if name not in wanted:
|
||||
continue
|
||||
if exclude_run_id and str(candidate.get("id", "")) == str(exclude_run_id):
|
||||
continue
|
||||
current = newest.get(name)
|
||||
if current is None or str(candidate.get("created_at", "")) > str(current.get("created_at", "")):
|
||||
newest[name] = candidate
|
||||
|
||||
return list(newest.values())
|
||||
|
||||
|
||||
def runs_all_completed(runs: list[dict]) -> bool:
|
||||
"""True only when every run in the list reports ``status: completed``.
|
||||
|
||||
The job list alone cannot answer "is CI done": a run that GitHub just
|
||||
created has no jobs yet, and a mid-run poll can catch the moment where
|
||||
every visible job finished but a downstream sub-workflow has not
|
||||
spawned its jobs. Both look identical to "all done" at the job level.
|
||||
The run's own ``status`` is the authoritative signal, so the poller
|
||||
must not exit while any relevant run is still ``queued`` or
|
||||
``in_progress``. An empty list is not done — it means the poller has
|
||||
no run information at all.
|
||||
"""
|
||||
return bool(runs) and all(str(r.get("status", "")) == "completed" for r in runs)
|
||||
|
||||
|
||||
def collect_run_jobs(
|
||||
token: str, repo: str, run_id: str, watch_workflows: list[str] | None = None,
|
||||
) -> tuple[list[dict], bool]:
|
||||
"""Collect all jobs in the CI run + any watched sibling runs.
|
||||
|
||||
Returns ``(jobs, runs_completed)``: a flat list of job dicts (same
|
||||
shape as the API returns, plus ``_workflow_name`` on jobs from a
|
||||
watched run), and whether the CI run and every selected watched run
|
||||
report ``status: completed`` (see :func:`runs_all_completed`).
|
||||
|
||||
Reusable-workflow (``workflow_call``) jobs need no special handling:
|
||||
GitHub flattens them into the caller run's job list, already named
|
||||
``\"Workflow / job\"``. Watched runs are separate top-level runs
|
||||
(the Docker image build), so their jobs are fetched per run and
|
||||
prefixed here.
|
||||
"""
|
||||
owner, repo_name = repo.split("/")
|
||||
run_info = _api_request(f"{API_BASE}/repos/{owner}/{repo_name}/actions/runs/{run_id}", token)
|
||||
head_sha = run_info.get("head_sha", "")
|
||||
|
||||
# CI run jobs (includes every reusable-workflow job).
|
||||
all_jobs: list[dict] = []
|
||||
orch_jobs = _api_get_paginated(
|
||||
f"{API_BASE}/repos/{owner}/{repo_name}/actions/runs/{run_id}/jobs",
|
||||
token, list_key="jobs",
|
||||
)
|
||||
|
||||
# Skip workflow-call placeholder steps (they're sub-workflow triggers,
|
||||
# not review signal), but KEEP in_progress / 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/workflows/") for s in steps):
|
||||
continue
|
||||
all_jobs.append(job)
|
||||
|
||||
if not watch_workflows or not head_sha:
|
||||
return all_jobs, runs_all_completed([run_info])
|
||||
|
||||
# Watched sibling runs for the same commit. A run can be absent on the
|
||||
# first polls. Then classify_jobs() shows nothing for it.
|
||||
sibling_runs = _api_get_paginated(
|
||||
f"{API_BASE}/repos/{owner}/{repo_name}/actions/runs?head_sha={head_sha}&per_page=100",
|
||||
token, list_key="workflow_runs",
|
||||
)
|
||||
relevant_runs = [run_info]
|
||||
for watched in select_watched_runs(sibling_runs, watch_workflows, exclude_run_id=run_id):
|
||||
relevant_runs.append(watched)
|
||||
watched_jobs = _api_get_paginated(
|
||||
f"{API_BASE}/repos/{owner}/{repo_name}/actions/runs/{watched['id']}/jobs",
|
||||
token, list_key="jobs",
|
||||
)
|
||||
for job in watched_jobs:
|
||||
job["_workflow_name"] = watched.get("name", "")
|
||||
all_jobs.append(job)
|
||||
|
||||
return all_jobs, runs_all_completed(relevant_runs)
|
||||
|
||||
|
||||
def find_comment_id(token: str, repo: str, pr_number: str) -> int | None:
|
||||
"""Find our existing review comment by marker prefix."""
|
||||
owner, repo_name = repo.split("/")
|
||||
comments = _api_get_paginated(
|
||||
f"{API_BASE}/repos/{owner}/{repo_name}/issues/{pr_number}/comments",
|
||||
token,
|
||||
)
|
||||
for c in comments:
|
||||
body = c.get("body", "") if isinstance(c, dict) else ""
|
||||
if body.startswith("<!-- hermes-ci-review-bot -->"):
|
||||
return c.get("id") if isinstance(c, dict) else None
|
||||
return None
|
||||
|
||||
|
||||
def upsert_comment(
|
||||
token: str, repo: str, pr_number: str, body: str, comment_id: int | None = None
|
||||
) -> int | None:
|
||||
"""Create or update the review comment. Returns the comment ID."""
|
||||
owner, repo_name = repo.split("/")
|
||||
if comment_id is None:
|
||||
comment_id = find_comment_id(token, repo, pr_number)
|
||||
|
||||
if comment_id:
|
||||
url = f"{API_BASE}/repos/{owner}/{repo_name}/issues/comments/{comment_id}"
|
||||
method = "PATCH"
|
||||
else:
|
||||
url = f"{API_BASE}/repos/{owner}/{repo_name}/issues/{pr_number}/comments"
|
||||
method = "POST"
|
||||
|
||||
data = json.dumps({"body": body}).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=data, method=method, headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "ci-live-comment",
|
||||
})
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
result = json.loads(resp.read())
|
||||
return result.get("id")
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f" API error {e.code}: {e.reason}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Artifact fetching (dynamic review-status artifacts)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Prefix for all review-status artifacts uploaded by status-producing jobs.
|
||||
# Each job uploads a ``review-status-<name>`` artifact containing a
|
||||
# ``review-status.json`` file in GITHUB_OUTPUT format:
|
||||
# review_status=<json array of {source, results: [...]} objects>
|
||||
_REVIEW_STATUS_ARTIFACT_PREFIX = "review-status-"
|
||||
|
||||
|
||||
def _list_artifacts(token: str, repo: str, run_id: str) -> list[dict]:
|
||||
"""List artifacts for a given run (paginated)."""
|
||||
owner, repo_name = repo.split("/")
|
||||
return _api_get_paginated(
|
||||
f"{API_BASE}/repos/{owner}/{repo_name}/actions/runs/{run_id}/artifacts",
|
||||
token, list_key="artifacts",
|
||||
)
|
||||
|
||||
|
||||
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
"""Redirect handler that never follows — used to capture the Location."""
|
||||
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
def _download_artifact(
|
||||
token: str, repo: str, artifact: dict, dest_dir: Path,
|
||||
) -> Path | None:
|
||||
"""Download a single artifact zip via the API and extract it.
|
||||
|
||||
Returns the path to ``review-status.json`` inside the extracted dir,
|
||||
or ``None`` if the download or extraction failed.
|
||||
"""
|
||||
owner, repo_name = repo.split("/")
|
||||
archive_download_url = artifact.get("archive_download_url", "")
|
||||
if not archive_download_url:
|
||||
return None
|
||||
|
||||
# The archive_download_url is an API URL that 302s to a signed blob
|
||||
# URL. Hop 1 authenticates to the API; hop 2 follows the redirect
|
||||
# WITHOUT the Authorization header — the blob rejects a request that
|
||||
# carries both a SAS token and an Authorization header (401).
|
||||
opener = urllib.request.build_opener(_NoRedirectHandler)
|
||||
location = ""
|
||||
try:
|
||||
opener.open(urllib.request.Request(archive_download_url, headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"User-Agent": "ci-live-comment",
|
||||
}), timeout=30)
|
||||
except urllib.error.HTTPError as e:
|
||||
location = e.headers.get("Location", "") if e.code == 302 else ""
|
||||
except Exception:
|
||||
location = ""
|
||||
if not location:
|
||||
return None
|
||||
|
||||
zip_path = dest_dir / f"{artifact['name']}.zip"
|
||||
try:
|
||||
# No auth headers here; further redirects are safe to follow.
|
||||
with urllib.request.urlopen(
|
||||
urllib.request.Request(location, headers={"User-Agent": "ci-live-comment"}),
|
||||
timeout=60,
|
||||
) as resp:
|
||||
zip_path.write_bytes(resp.read())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
extract_dir = dest_dir / artifact["name"]
|
||||
extract_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
if any(".." in name or name.startswith("/") for name in zf.namelist()):
|
||||
return None
|
||||
zf.extractall(extract_dir)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
status_file = extract_dir / "review-status.json"
|
||||
return status_file if status_file.exists() else None
|
||||
|
||||
|
||||
def _parse_status_file(status_file: Path) -> list[dict]:
|
||||
"""Parse a review-status.json file in GITHUB_OUTPUT format."""
|
||||
try:
|
||||
content = status_file.read_text(encoding="utf-8").strip()
|
||||
if content.startswith("review_status="):
|
||||
content = content[len("review_status="):]
|
||||
statuses = json.loads(content)
|
||||
if isinstance(statuses, list):
|
||||
return statuses
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
def fetch_all_review_statuses(
|
||||
token: str, repo: str, run_id: str,
|
||||
) -> list[dict]:
|
||||
"""Fetch and merge all review-status artifacts from the run.
|
||||
|
||||
Lists artifacts with the ``review-status-`` prefix on the orchestrator
|
||||
run, downloads each, parses the ``review-status.json`` inside, and
|
||||
merges into a single flat array. GitHub attaches artifacts uploaded by
|
||||
reusable workflow jobs to the caller run, so one listing covers every
|
||||
status-producing job.
|
||||
|
||||
Returns the merged list of ``{source, results: [...]}`` objects.
|
||||
Artifacts that don't exist yet or fail to parse are silently skipped.
|
||||
"""
|
||||
all_statuses: list[dict] = []
|
||||
temp_base = Path("/tmp/review-status-artifacts")
|
||||
|
||||
try:
|
||||
artifacts = _list_artifacts(token, repo, run_id)
|
||||
except Exception:
|
||||
return all_statuses
|
||||
|
||||
rs_artifacts = [
|
||||
a for a in artifacts
|
||||
if a.get("name", "").startswith(_REVIEW_STATUS_ARTIFACT_PREFIX)
|
||||
]
|
||||
if not rs_artifacts:
|
||||
return all_statuses
|
||||
|
||||
# Clean temp dir for this run's artifacts.
|
||||
run_dl_dir = temp_base / str(run_id)
|
||||
if run_dl_dir.exists():
|
||||
shutil.rmtree(run_dl_dir)
|
||||
run_dl_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for artifact in rs_artifacts:
|
||||
status_file = _download_artifact(token, repo, artifact, run_dl_dir)
|
||||
if status_file is None:
|
||||
continue
|
||||
statuses = _parse_status_file(status_file)
|
||||
all_statuses.extend(statuses)
|
||||
|
||||
# A re-run can leave several non-expired artifacts with the same name,
|
||||
# each carrying the same source — dedupe by source so the comment
|
||||
# doesn't render duplicate sections.
|
||||
seen: set[str] = set()
|
||||
deduped: list[dict] = []
|
||||
for status in all_statuses:
|
||||
src = status.get("source", "")
|
||||
if src in seen:
|
||||
continue
|
||||
if src:
|
||||
seen.add(src)
|
||||
deduped.append(status)
|
||||
return deduped
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Comment assembly
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _import_assembler():
|
||||
"""Import assemble_review_comment.py from the same directory."""
|
||||
here = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(here))
|
||||
import assemble_review_comment as asm
|
||||
return asm
|
||||
|
||||
|
||||
def build_comment_body(
|
||||
asm_mod,
|
||||
completed: dict[str, str],
|
||||
pending: list[str],
|
||||
run_url: str,
|
||||
job_urls: dict[str, str],
|
||||
review_statuses_json: str,
|
||||
commit_info: str = "",
|
||||
waiting: bool = False,
|
||||
) -> str:
|
||||
"""Assemble the comment body from current job states + static inputs."""
|
||||
needs_json = json.dumps(completed) if completed else ""
|
||||
|
||||
return asm_mod.assemble(
|
||||
needs_json=needs_json,
|
||||
run_url=run_url,
|
||||
job_urls=job_urls,
|
||||
review_statuses_json=review_statuses_json,
|
||||
pending_jobs=pending if pending else None,
|
||||
commit_info=commit_info,
|
||||
waiting=waiting,
|
||||
)
|
||||
|
||||
|
||||
def _commit_info_for_state(commit_info: str, pending: bool) -> 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run(
|
||||
token: str,
|
||||
repo: str,
|
||||
run_id: str,
|
||||
pr_number: str,
|
||||
run_url: str,
|
||||
commit_info: str = "",
|
||||
interval: int = 15,
|
||||
timeout: int = 1800,
|
||||
dry_run: bool = False,
|
||||
watch_workflows: list[str] | None = None,
|
||||
) -> int:
|
||||
"""Poll for job statuses and update the PR comment until all done.
|
||||
|
||||
Always returns 0. The poller reports on the CI run from a different run.
|
||||
Thus a failed CI job is not a failure of this job. The CI run has its
|
||||
own gate, which reports that. Comment posting is best-effort.
|
||||
"""
|
||||
asm = _import_assembler()
|
||||
start = time.time()
|
||||
last_body = ""
|
||||
quiet_grace_used = False
|
||||
prev_completed: dict[str, str] = {}
|
||||
prev_pending: list[str] = []
|
||||
prev_artifact_count = 0
|
||||
|
||||
while True:
|
||||
elapsed = time.time() - start
|
||||
if elapsed > timeout:
|
||||
print(f"Timeout ({timeout}s) reached — stopping poll.", file=sys.stderr)
|
||||
break
|
||||
|
||||
try:
|
||||
jobs, runs_completed = collect_run_jobs(token, repo, run_id, watch_workflows)
|
||||
except Exception as e:
|
||||
print(f" API error collecting jobs: {e}", file=sys.stderr)
|
||||
time.sleep(interval)
|
||||
continue
|
||||
|
||||
completed, pending, job_urls = classify_jobs(jobs)
|
||||
total = len(completed) + len(pending)
|
||||
infra_count = len(jobs) - total
|
||||
print(f" [{elapsed:.0f}s] fetched {len(jobs)} jobs from API "
|
||||
f"({infra_count} infra filtered) → {len(completed)} completed, "
|
||||
f"{len(pending)} pending ({total} review jobs)")
|
||||
|
||||
# Log transitions since last poll.
|
||||
new_completed = {k: v for k, v in completed.items() if k not in prev_completed}
|
||||
new_pending = [j for j in pending if j not in prev_pending]
|
||||
gone_pending = [j for j in prev_pending if j not in pending and j not in completed]
|
||||
if new_completed:
|
||||
parts = [f"{name}={result}" for name, result in new_completed.items()]
|
||||
print(f" → {len(new_completed)} job(s) newly completed: {', '.join(parts)}")
|
||||
if new_pending:
|
||||
print(f" → {len(new_pending)} job(s) newly appeared: {', '.join(new_pending)}")
|
||||
if gone_pending:
|
||||
print(f" → {len(gone_pending)} job(s) disappeared from pending: {', '.join(gone_pending)}")
|
||||
|
||||
# Dynamically fetch all review-status artifacts from the run.
|
||||
artifact_statuses = fetch_all_review_statuses(token, repo, run_id)
|
||||
artifact_count_changed = len(artifact_statuses) != prev_artifact_count
|
||||
if artifact_count_changed:
|
||||
print(f" Found {len(artifact_statuses)} review status entries from artifacts "
|
||||
f"(was {prev_artifact_count} last poll)")
|
||||
prev_artifact_count = len(artifact_statuses)
|
||||
|
||||
merged_json = json.dumps(artifact_statuses) if artifact_statuses else ""
|
||||
# The run status is authoritative for "done": an empty job list on
|
||||
# a run that is still queued/in_progress means GitHub has not
|
||||
# spawned the jobs yet, not that everything passed.
|
||||
all_done = not pending and runs_completed
|
||||
current_commit_info = _commit_info_for_state(commit_info, pending=not all_done)
|
||||
|
||||
body = build_comment_body(
|
||||
asm, completed, pending, run_url, job_urls,
|
||||
merged_json,
|
||||
current_commit_info,
|
||||
waiting=not runs_completed,
|
||||
)
|
||||
|
||||
if body != last_body:
|
||||
change_reasons = []
|
||||
if new_completed:
|
||||
change_reasons.append(f"{len(new_completed)} new completion(s)")
|
||||
if new_pending:
|
||||
change_reasons.append(f"{len(new_pending)} new pending job(s)")
|
||||
if gone_pending:
|
||||
change_reasons.append(f"{len(gone_pending)} job(s) left pending")
|
||||
if artifact_count_changed:
|
||||
change_reasons.append("artifact statuses updated")
|
||||
if not change_reasons:
|
||||
change_reasons.append("initial post")
|
||||
reason = "; ".join(change_reasons)
|
||||
|
||||
if dry_run:
|
||||
print(f" Comment body changed ({reason}) — DRY RUN:")
|
||||
print("--- DRY RUN — comment body ---")
|
||||
print(body)
|
||||
print("--- END ---")
|
||||
else:
|
||||
cid = upsert_comment(token, repo, pr_number, body)
|
||||
if cid:
|
||||
print(f" Updated comment {cid} ({reason})")
|
||||
else:
|
||||
print(f" Failed to update comment ({reason}, will retry)", file=sys.stderr)
|
||||
last_body = body
|
||||
else:
|
||||
if pending:
|
||||
print(f" No change since last poll. Still waiting on: {', '.join(pending)}")
|
||||
else:
|
||||
print(" No change since last poll.")
|
||||
|
||||
prev_completed = completed
|
||||
prev_pending = pending
|
||||
|
||||
if all_done and not quiet_grace_used:
|
||||
quiet_grace_used = True
|
||||
print(" No jobs pending and runs report completed — "
|
||||
"waiting 10s for downstream jobs to appear.")
|
||||
time.sleep(10)
|
||||
continue
|
||||
|
||||
if all_done:
|
||||
failed = [name for name, result in completed.items() if result == "failure"]
|
||||
if failed:
|
||||
print(f" All jobs done, {len(failed)} failed: {', '.join(failed)}")
|
||||
else:
|
||||
print(" All jobs completed — done.")
|
||||
break
|
||||
|
||||
if not pending:
|
||||
print(" No visible jobs pending, but a run is still queued or "
|
||||
"in progress — waiting for its jobs to appear.")
|
||||
|
||||
quiet_grace_used = False
|
||||
time.sleep(interval)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def parse_watch_workflows(raw: str) -> list[str]:
|
||||
"""Parse the ``WATCH_WORKFLOWS`` value into workflow names.
|
||||
|
||||
One name per line. Not comma-separated: a workflow name can contain a
|
||||
comma ("Docker Build, Test, and Publish").
|
||||
"""
|
||||
return [name.strip() for name in raw.splitlines() if name.strip()]
|
||||
|
||||
|
||||
def resolve_pr_number(token: str, repo: str, head_sha: str) -> str:
|
||||
"""Find the PR number for a commit when the event payload has none.
|
||||
|
||||
``workflow_run.pull_requests`` is empty for some runs. The poller has no
|
||||
comment to post without a number.
|
||||
"""
|
||||
if not head_sha:
|
||||
return ""
|
||||
owner, repo_name = repo.split("/")
|
||||
try:
|
||||
results = _api_get_paginated(
|
||||
f"{API_BASE}/repos/{owner}/{repo_name}/commits/{head_sha}/pulls",
|
||||
token,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" API error resolving PR number: {e}", file=sys.stderr)
|
||||
return ""
|
||||
for item in results:
|
||||
if isinstance(item, dict) and item.get("state") == "open":
|
||||
return str(item.get("number", ""))
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--interval", type=int, default=15,
|
||||
help="Seconds between polls (default: 15).")
|
||||
parser.add_argument("--timeout", type=int, default=1800,
|
||||
help="Max seconds to poll before giving up (default: 1800).")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Print comment body instead of posting to PR.")
|
||||
args = parser.parse_args()
|
||||
|
||||
token = os.environ.get("GITHUB_TOKEN", "")
|
||||
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||
run_id = os.environ.get("CI_RUN_ID", "")
|
||||
pr_number = os.environ.get("PR_NUMBER", "")
|
||||
run_url = os.environ.get("RUN_URL", "")
|
||||
|
||||
# Sibling workflows to merge into the comment, one name per line. Their
|
||||
# runs are separate from the CI run, so the poller resolves them by name.
|
||||
watch_workflows = parse_watch_workflows(os.environ.get("WATCH_WORKFLOWS", ""))
|
||||
|
||||
if not args.dry_run:
|
||||
if not token:
|
||||
print("GITHUB_TOKEN is required", file=sys.stderr)
|
||||
return 1
|
||||
if not repo:
|
||||
print("GITHUB_REPOSITORY is required", file=sys.stderr)
|
||||
return 1
|
||||
if not run_id:
|
||||
print("CI_RUN_ID is required", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Build commit info line from env vars (set by ci-review-comment.yml).
|
||||
commit_sha = os.environ.get("COMMIT_SHA", "")
|
||||
commit_msg = os.environ.get("COMMIT_MESSAGE", "")
|
||||
|
||||
if not pr_number and not args.dry_run:
|
||||
pr_number = resolve_pr_number(token, repo, commit_sha)
|
||||
if not pr_number:
|
||||
print("No PR number found — nothing to comment on.", file=sys.stderr)
|
||||
return 0
|
||||
print(f"Resolved PR #{pr_number} from commit {commit_sha[:7]}")
|
||||
|
||||
commit_url = os.environ.get("COMMIT_URL", "")
|
||||
if not commit_url and commit_sha and pr_number:
|
||||
server = os.environ.get("GITHUB_SERVER_URL", "https://github.com")
|
||||
commit_url = f"{server}/{repo}/pull/{pr_number}/commits/{commit_sha}"
|
||||
|
||||
commit_info = ""
|
||||
if commit_sha:
|
||||
short_sha = commit_sha[:7]
|
||||
if commit_msg:
|
||||
# Truncate commit message to first line, max 60 chars.
|
||||
first_line = commit_msg.split("\n")[0][:60]
|
||||
if commit_url:
|
||||
commit_info = f"<sub>running on [{short_sha}]({commit_url}) — {first_line}</sub>"
|
||||
else:
|
||||
commit_info = f"<sub>running on {short_sha} — {first_line}</sub>"
|
||||
elif commit_url:
|
||||
commit_info = f"<sub>running on [{short_sha}]({commit_url})</sub>"
|
||||
else:
|
||||
commit_info = f"<sub>running on {short_sha}</sub>"
|
||||
|
||||
return run(
|
||||
token=token,
|
||||
repo=repo,
|
||||
run_id=run_id,
|
||||
pr_number=pr_number,
|
||||
run_url=run_url,
|
||||
commit_info=commit_info,
|
||||
interval=args.interval,
|
||||
timeout=args.timeout,
|
||||
dry_run=args.dry_run,
|
||||
watch_workflows=watch_workflows,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Semantic diff of npm ``package-lock.json`` files for PR comments.
|
||||
|
||||
``git diff`` on a lockfile is unreadable: npm reorders entries, rewrites
|
||||
integrity hashes, and moves packages between nesting levels, so a one-line
|
||||
``package.json`` bump can produce a thousand-line textual diff. This script
|
||||
ignores the text entirely — it parses the ``packages`` map out of both
|
||||
versions of each lockfile (lockfileVersion 2/3), reduces each to
|
||||
``{install path: version}``, and set-diffs the two dicts. Reordering and
|
||||
hash churn vanish; what's left is the actual dependency change.
|
||||
|
||||
Usage (from a checkout that still has the base ref available):
|
||||
|
||||
python scripts/ci/lockfile_diff.py --base <ref> --head <ref> \
|
||||
--output diff.md [--repo-root .]
|
||||
|
||||
Reads every ``package-lock.json`` tracked at either ref (top-level and
|
||||
nested — the repo has several), diffs each, and writes a Markdown fragment
|
||||
to ``--output``. Exits 0 always; an empty output file means "no version
|
||||
changes" (the caller uses that to decide whether to include the section).
|
||||
The fragment is consumed by ``scripts/ci/assemble_review_comment.py``,
|
||||
which wraps it in a section with a header and action note.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def parse_lockfile(text: str) -> dict[str, str]:
|
||||
"""Reduce lockfile JSON to ``{install path: version}``.
|
||||
|
||||
Keys are the ``packages`` map's keys (e.g. ``node_modules/react`` or
|
||||
``node_modules/foo/node_modules/react``), so the same package deduped
|
||||
at two versions shows up as two distinct entries. The root entry
|
||||
(``""``, the workspace itself) is skipped, as are versionless link
|
||||
entries.
|
||||
"""
|
||||
data = json.loads(text)
|
||||
out: dict[str, str] = {}
|
||||
for path, meta in data.get("packages", {}).items():
|
||||
if not path:
|
||||
continue # root project entry, not a dependency
|
||||
version = meta.get("version")
|
||||
if version:
|
||||
out[path] = version
|
||||
return out
|
||||
|
||||
|
||||
def diff_locks(base: dict[str, str], head: dict[str, str]) -> dict[str, list]:
|
||||
"""Set-diff two ``{path: version}`` maps.
|
||||
|
||||
Returns ``added`` / ``removed`` as ``[(path, version)]`` and
|
||||
``updated`` as ``[(path, base_version, head_version)]``, each sorted
|
||||
by path.
|
||||
"""
|
||||
added = sorted((p, v) for p, v in head.items() if p not in base)
|
||||
removed = sorted((p, v) for p, v in base.items() if p not in head)
|
||||
updated = sorted(
|
||||
(p, base[p], head[p]) for p in base.keys() & head.keys() if base[p] != head[p]
|
||||
)
|
||||
return {"added": added, "removed": removed, "updated": updated}
|
||||
|
||||
|
||||
def _display_name(path: str) -> str:
|
||||
"""``node_modules/foo/node_modules/@scope/bar`` → ``@scope/bar (nested under foo)``."""
|
||||
parts = path.split("node_modules/")
|
||||
name = parts[-1].rstrip("/")
|
||||
if len(parts) > 2:
|
||||
parents = " → ".join(p.rstrip("/") for p in parts[1:-1])
|
||||
return f"{name} *(nested under {parents})*"
|
||||
return name
|
||||
|
||||
|
||||
def render_markdown(diffs: dict[str, dict[str, list]]) -> str:
|
||||
"""Render per-lockfile diffs as a Markdown fragment.
|
||||
|
||||
``diffs`` maps lockfile repo-path → the output of :func:`diff_locks`.
|
||||
Lockfiles with no version changes are omitted. Returns ``""`` when
|
||||
nothing changed anywhere (caller skips the section entirely).
|
||||
|
||||
The output is a fragment — per-lockfile ``####`` subsections with
|
||||
tables — not a standalone comment. The ``assemble_review_comment``
|
||||
script wraps this in a section with its own header and action note,
|
||||
so no top-level header or comment marker is emitted here.
|
||||
"""
|
||||
sections = []
|
||||
for lockfile, d in sorted(diffs.items()):
|
||||
added, removed, updated = d["added"], d["removed"], d["updated"]
|
||||
n = len(added) + len(removed) + len(updated)
|
||||
if n == 0:
|
||||
continue
|
||||
lines = [f"#### `{lockfile}`", ""]
|
||||
lines.append("| Package | Before | After |")
|
||||
lines.append("| --- | --- | --- |")
|
||||
for path, old, new in updated:
|
||||
lines.append(f"| {_display_name(path)} | `{old}` | `{new}` |")
|
||||
for path, version in added:
|
||||
lines.append(f"| ➕ {_display_name(path)} | — | `{version}` |")
|
||||
for path, version in removed:
|
||||
lines.append(f"| ➖ {_display_name(path)} | `{version}` | — |")
|
||||
sections.append("\n".join(lines))
|
||||
|
||||
if not sections:
|
||||
return ""
|
||||
|
||||
return "\n\n".join(sections) + "\n"
|
||||
|
||||
|
||||
def _git_show(ref: str, path: str, repo_root: str) -> str | None:
|
||||
"""Contents of ``path`` at ``ref``, or None if it doesn't exist there."""
|
||||
proc = subprocess.run(
|
||||
["git", "show", f"{ref}:{path}"],
|
||||
capture_output=True,
|
||||
text=True, encoding="utf-8", errors="replace",
|
||||
cwd=repo_root,
|
||||
)
|
||||
return proc.stdout if proc.returncode == 0 else None
|
||||
|
||||
|
||||
def _tracked_lockfiles(ref: str, repo_root: str) -> set[str]:
|
||||
proc = subprocess.run(
|
||||
["git", "ls-tree", "-r", "--name-only", ref],
|
||||
capture_output=True,
|
||||
text=True, encoding="utf-8", errors="replace",
|
||||
cwd=repo_root,
|
||||
check=True,
|
||||
)
|
||||
return {
|
||||
line
|
||||
for line in proc.stdout.splitlines()
|
||||
if line.split("/")[-1] == "package-lock.json"
|
||||
}
|
||||
|
||||
|
||||
def diff_refs(base: str, head: str, repo_root: str = ".") -> dict[str, dict[str, list]]:
|
||||
"""Diff every package-lock.json tracked at either ref."""
|
||||
lockfiles = _tracked_lockfiles(base, repo_root) | _tracked_lockfiles(head, repo_root)
|
||||
diffs = {}
|
||||
for path in sorted(lockfiles):
|
||||
base_text = _git_show(base, path, repo_root)
|
||||
head_text = _git_show(head, path, repo_root)
|
||||
base_map = parse_lockfile(base_text) if base_text else {}
|
||||
head_map = parse_lockfile(head_text) if head_text else {}
|
||||
diffs[path] = diff_locks(base_map, head_map)
|
||||
return diffs
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--base", required=True, help="base git ref (merge base)")
|
||||
ap.add_argument("--head", required=True, help="head git ref")
|
||||
ap.add_argument("--output", required=True, help="markdown output path")
|
||||
ap.add_argument("--repo-root", default=".", help="repository root")
|
||||
args = ap.parse_args()
|
||||
|
||||
diffs = diff_refs(args.base, args.head, args.repo_root)
|
||||
markdown = render_markdown(diffs)
|
||||
with open(args.output, "w", encoding="utf-8") as fh:
|
||||
fh.write(markdown)
|
||||
|
||||
if markdown:
|
||||
changed = sum(len(v) for d in diffs.values() for v in d.values())
|
||||
print(f"{changed} package version change(s) — report written to {args.output}")
|
||||
else:
|
||||
print("No package version changes.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,337 @@
|
||||
#!/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] | None:
|
||||
"""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)
|
||||
return None
|
||||
|
||||
|
||||
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, encoding="utf-8", errors="replace",
|
||||
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)
|
||||
if comment is None:
|
||||
# A fork PR gets no CI review comment (the live poller needs a
|
||||
# write token there), so there is no marker to patch. The
|
||||
# evidence stays available in the workflow artifact.
|
||||
print(
|
||||
f"PR #{pr_number} has no CI review comment with an E2E evidence "
|
||||
"marker; the evidence stays in the workflow artifact."
|
||||
)
|
||||
return False
|
||||
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())
|
||||
@@ -0,0 +1,141 @@
|
||||
# Behavioral test for install.ps1's hermes launcher staging (PR #92092,
|
||||
# reworked for the managed-binary-dir layout).
|
||||
#
|
||||
# Run: powershell.exe -NoProfile -File scripts/ci/test_install_ps1_cli_launchers.ps1
|
||||
#
|
||||
# The test lifts the real Install-HermesCommandLaunchers function from the
|
||||
# PowerShell AST and executes it against a temporary install tree. It never
|
||||
# reads or changes the user's PATH. The staging destination is passed in by
|
||||
# the caller (Set-PathVariable passes $HermesHome\bin -- the managed binary
|
||||
# dir OUTSIDE the git checkout); here it is a sibling temp dir, which also
|
||||
# proves the function stages wherever it is pointed rather than assuming
|
||||
# the legacy in-checkout location.
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$installPs1 = Join-Path (Join-Path $PSScriptRoot '..') 'install.ps1' | Resolve-Path
|
||||
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
|
||||
$installPs1, [ref]$null, [ref]$null)
|
||||
|
||||
$fn = $ast.Find({
|
||||
param($n)
|
||||
$n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
|
||||
$n.Name -eq 'Install-HermesCommandLaunchers'
|
||||
}, $true)
|
||||
|
||||
if (-not $fn) {
|
||||
throw "Install-HermesCommandLaunchers not found in $installPs1"
|
||||
}
|
||||
|
||||
Invoke-Expression $fn.Extent.Text
|
||||
|
||||
$tempBase = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath())
|
||||
$caseRoot = [System.IO.Path]::GetFullPath((Join-Path $tempBase (
|
||||
'hermes-cli-launcher-test-' + [guid]::NewGuid().ToString('N')
|
||||
)))
|
||||
if (-not $caseRoot.StartsWith($tempBase, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Refusing to create test directory outside the system temp directory: $caseRoot"
|
||||
}
|
||||
|
||||
$script:Failures = 0
|
||||
|
||||
function Assert-True {
|
||||
param([bool]$Condition, [string]$Name)
|
||||
if ($Condition) {
|
||||
Write-Host " PASS $Name"
|
||||
} else {
|
||||
Write-Host " FAIL $Name"
|
||||
$script:Failures++
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-BytesEqual {
|
||||
param([byte[]]$Expected, [byte[]]$Actual, [string]$Name)
|
||||
$same = $Expected.Length -eq $Actual.Length
|
||||
if ($same) {
|
||||
for ($i = 0; $i -lt $Expected.Length; $i++) {
|
||||
if ($Expected[$i] -ne $Actual[$i]) {
|
||||
$same = $false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
Assert-True $same $Name
|
||||
}
|
||||
|
||||
try {
|
||||
$installRoot = Join-Path $caseRoot 'hermes-agent'
|
||||
$binDir = Join-Path $caseRoot 'bin'
|
||||
New-Item -ItemType Directory -Force -Path $installRoot | Out-Null
|
||||
|
||||
# Fail-before-PATH-mutation: a missing required source must throw and
|
||||
# must not leave an empty destination for the caller to put on PATH.
|
||||
$missingThrew = $false
|
||||
try {
|
||||
Install-HermesCommandLaunchers -Root $installRoot -Destination $binDir | Out-Null
|
||||
} catch {
|
||||
$missingThrew = $_.Exception.Message -like '*required launcher not found*'
|
||||
}
|
||||
Assert-True $missingThrew 'missing hermes.exe fails the launcher stage'
|
||||
Assert-True (-not (Test-Path -LiteralPath $binDir)) `
|
||||
'failure does not create an empty PATH directory'
|
||||
|
||||
$scriptsDir = Join-Path $installRoot 'venv\Scripts'
|
||||
New-Item -ItemType Directory -Force -Path $scriptsDir | Out-Null
|
||||
$hermesV1 = [byte[]](77, 90, 1)
|
||||
$hermesV2 = [byte[]](77, 90, 2)
|
||||
$acp = [byte[]](77, 90, 3)
|
||||
[System.IO.File]::WriteAllBytes((Join-Path $scriptsDir 'hermes.exe'), $hermesV1)
|
||||
Set-Content -Path (Join-Path $installRoot 'venv\pyvenv.cfg') `
|
||||
-Value "home = X" -Encoding Ascii
|
||||
|
||||
$staged = Install-HermesCommandLaunchers -Root $installRoot -Destination $binDir
|
||||
Assert-True ($staged -eq $binDir) 'returns the destination it staged into'
|
||||
Assert-BytesEqual $hermesV1 `
|
||||
([System.IO.File]::ReadAllBytes((Join-Path $binDir 'hermes.exe'))) `
|
||||
'normal venv: exe copy lands in the destination'
|
||||
Assert-True (-not (Test-Path -LiteralPath (Join-Path $binDir 'hermes-acp.exe'))) `
|
||||
'optional ACP launcher may be absent'
|
||||
|
||||
[System.IO.File]::WriteAllBytes((Join-Path $scriptsDir 'hermes.exe'), $hermesV2)
|
||||
[System.IO.File]::WriteAllBytes((Join-Path $scriptsDir 'hermes-acp.exe'), $acp)
|
||||
Install-HermesCommandLaunchers -Root $installRoot -Destination $binDir | Out-Null
|
||||
Assert-BytesEqual $hermesV2 `
|
||||
([System.IO.File]::ReadAllBytes((Join-Path $binDir 'hermes.exe'))) `
|
||||
'installer refreshes an existing Hermes launcher'
|
||||
Assert-BytesEqual $acp `
|
||||
([System.IO.File]::ReadAllBytes((Join-Path $binDir 'hermes-acp.exe'))) `
|
||||
'installer copies the optional ACP launcher when present'
|
||||
|
||||
# Relocatable venv: exe trampolines die when copied out of venv\Scripts
|
||||
# ('uv trampoline failed to canonicalize script path'), so the stage
|
||||
# must emit .cmd delegators and clear the stale exe copies.
|
||||
Set-Content -Path (Join-Path $installRoot 'venv\pyvenv.cfg') `
|
||||
-Value "home = X`r`nrelocatable = true" -Encoding Ascii
|
||||
Install-HermesCommandLaunchers -Root $installRoot -Destination $binDir | Out-Null
|
||||
Assert-True (Test-Path -LiteralPath (Join-Path $binDir 'hermes.cmd')) `
|
||||
'relocatable venv: .cmd delegator staged'
|
||||
Assert-True (-not (Test-Path -LiteralPath (Join-Path $binDir 'hermes.exe'))) `
|
||||
'relocatable venv: stale exe copy removed'
|
||||
$cmdBody = [System.IO.File]::ReadAllText((Join-Path $binDir 'hermes.cmd'))
|
||||
Assert-True ($cmdBody.Contains((Join-Path $scriptsDir 'hermes.exe')) -and $cmdBody.Contains('%*')) `
|
||||
'delegator invokes the in-venv exe and forwards args'
|
||||
} finally {
|
||||
if (Test-Path -LiteralPath $caseRoot) {
|
||||
$resolvedCase = [System.IO.Path]::GetFullPath($caseRoot)
|
||||
if (-not $resolvedCase.StartsWith($tempBase, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Refusing to remove test directory outside the system temp directory: $resolvedCase"
|
||||
}
|
||||
Remove-Item -LiteralPath $resolvedCase -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
||||
if ($script:Failures -gt 0) {
|
||||
Write-Host ""
|
||||
Write-Host "$script:Failures assertion(s) failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "all assertions passed"
|
||||
@@ -0,0 +1,125 @@
|
||||
# Behavioral test for install.ps1's persisted-User-PATH migration.
|
||||
#
|
||||
# Run: pwsh -NoProfile -File scripts/ci/test_install_ps1_path_migration.ps1
|
||||
#
|
||||
# Not wired into the default CI lane — the Linux runners have no PowerShell
|
||||
# host. It runs on any machine with pwsh (including via nixpkgs#powershell),
|
||||
# and on a Windows runner if one is ever added.
|
||||
#
|
||||
# This is NOT a source-regex test. It parses install.ps1, lifts the real
|
||||
# Set-ManagedNodeFirstOnUserPath body out of the AST, and rewrites *only* the
|
||||
# two registry calls into an in-memory store so the actual shipped logic —
|
||||
# split, dedupe, prepend, change-detection — executes for real. Rewriting from
|
||||
# the AST rather than hand-copying the body means the test cannot silently
|
||||
# drift away from the function it claims to cover.
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$installPs1 = Join-Path $PSScriptRoot '..' 'install.ps1' | Resolve-Path
|
||||
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
|
||||
$installPs1, [ref]$null, [ref]$null)
|
||||
|
||||
$fn = $ast.Find({
|
||||
param($n)
|
||||
$n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
|
||||
$n.Name -eq 'Set-ManagedNodeFirstOnUserPath'
|
||||
}, $true)
|
||||
|
||||
if (-not $fn) {
|
||||
throw "Set-ManagedNodeFirstOnUserPath not found in $installPs1"
|
||||
}
|
||||
|
||||
# Swap the two registry calls for the in-memory store. Both must match, or the
|
||||
# function has changed shape and this harness is no longer exercising it.
|
||||
# Rewrite the whole definition extent (which already carries `function <name>
|
||||
# { param(...) ... }`) so the shipped param block and body run verbatim.
|
||||
$definition = $fn.Extent.Text
|
||||
$reads = ([regex]'\[Environment\]::GetEnvironmentVariable\("Path", "User"\)').Matches($definition).Count
|
||||
$writes = ([regex]'\[Environment\]::SetEnvironmentVariable\("Path", ([^,]+), "User"\)').Matches($definition).Count
|
||||
if ($reads -ne 1 -or $writes -ne 1) {
|
||||
throw "expected exactly one User PATH read and one write in the function body; found $reads read(s), $writes write(s). Update this harness."
|
||||
}
|
||||
|
||||
$definition = $definition -replace `
|
||||
'\[Environment\]::GetEnvironmentVariable\("Path", "User"\)', '$script:FakeUserPath'
|
||||
$definition = $definition -replace `
|
||||
'\[Environment\]::SetEnvironmentVariable\("Path", ([^,]+), "User"\)', '$script:FakeUserPath = $1; $script:FakeWrites++'
|
||||
|
||||
Invoke-Expression $definition
|
||||
|
||||
$NODE = 'C:\Users\me\AppData\Local\hermes\node'
|
||||
$script:Failures = 0
|
||||
|
||||
function Invoke-Migration {
|
||||
param([string]$Start, [string]$NodeDir = $NODE)
|
||||
$script:FakeUserPath = $Start
|
||||
$script:FakeWrites = 0
|
||||
Set-ManagedNodeFirstOnUserPath $NodeDir
|
||||
}
|
||||
|
||||
function Assert-Equal {
|
||||
param($Expected, $Actual, [string]$Name)
|
||||
if ($Expected -ceq $Actual) {
|
||||
Write-Host " PASS $Name"
|
||||
} else {
|
||||
Write-Host " FAIL $Name"
|
||||
Write-Host " expected: [$Expected]"
|
||||
Write-Host " actual: [$Actual]"
|
||||
$script:Failures++
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "install.ps1 Set-ManagedNodeFirstOnUserPath"
|
||||
|
||||
# The regression this function exists for: an install made by an older
|
||||
# install.ps1, which *appended*. A system Node leads and the managed dir is
|
||||
# stranded at the tail, so every new shell resolves the wrong node.exe. An
|
||||
# add-if-missing check would see the entry present and leave it there forever.
|
||||
Invoke-Migration "C:\Program Files\nodejs;C:\Users\me\bin;$NODE"
|
||||
Assert-Equal "$NODE;C:\Program Files\nodejs;C:\Users\me\bin" $script:FakeUserPath `
|
||||
'upgrade from appending installer: managed dir becomes first entry'
|
||||
Assert-Equal 1 (@($script:FakeUserPath -split ';' | Where-Object { $_ -eq $NODE }).Count) `
|
||||
'upgrade: managed dir is not duplicated'
|
||||
Assert-Equal "C:\Program Files\nodejs;C:\Users\me\bin" `
|
||||
(($script:FakeUserPath -split ';' | Where-Object { $_ -ne $NODE }) -join ';') `
|
||||
'upgrade: unrelated entries keep their relative order'
|
||||
Assert-Equal 1 $script:FakeWrites 'upgrade: persists exactly once'
|
||||
|
||||
Invoke-Migration "$NODE;C:\Program Files\nodejs"
|
||||
Assert-Equal "$NODE;C:\Program Files\nodejs" $script:FakeUserPath 'already correct: unchanged'
|
||||
Assert-Equal 0 $script:FakeWrites 'already correct: no registry write'
|
||||
|
||||
Invoke-Migration "C:\Program Files\nodejs"
|
||||
Assert-Equal "$NODE;C:\Program Files\nodejs" $script:FakeUserPath 'fresh install: prepended'
|
||||
|
||||
# Empty segments are legal in a real User PATH (a trailing ';' is common) and
|
||||
# the installer's other PATH code preserves them. Migration must not quietly
|
||||
# rewrite parts of PATH it was not asked to touch.
|
||||
Invoke-Migration "C:\Program Files\nodejs;;C:\Users\me\bin;"
|
||||
Assert-Equal "$NODE;C:\Program Files\nodejs;;C:\Users\me\bin;" $script:FakeUserPath `
|
||||
'empty segments are preserved'
|
||||
|
||||
# Windows paths are case-insensitive, and -ne on strings is too.
|
||||
Invoke-Migration "C:\Program Files\nodejs;c:\users\me\appdata\local\HERMES\Node"
|
||||
Assert-Equal "$NODE;C:\Program Files\nodejs" $script:FakeUserPath `
|
||||
'existing entry in different case is replaced, not duplicated'
|
||||
|
||||
Invoke-Migration "$NODE;C:\Program Files\nodejs;$NODE"
|
||||
Assert-Equal "$NODE;C:\Program Files\nodejs" $script:FakeUserPath 'duplicates collapse'
|
||||
|
||||
Invoke-Migration ""
|
||||
Assert-Equal $NODE $script:FakeUserPath 'empty User PATH'
|
||||
|
||||
Invoke-Migration "C:\Program Files\nodejs" ""
|
||||
Assert-Equal "C:\Program Files\nodejs" $script:FakeUserPath 'empty NodeDir is a no-op'
|
||||
Assert-Equal 0 $script:FakeWrites 'empty NodeDir does not write'
|
||||
|
||||
if ($script:Failures -gt 0) {
|
||||
Write-Host ""
|
||||
Write-Host "$script:Failures assertion(s) failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "all assertions passed"
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user