Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+444
View File
@@ -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())
+340
View File
@@ -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())
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Select Desktop E2E visual evidence and build its CI review status."""
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
from pathlib import Path
SOURCE = "playwright e2e"
EVIDENCE_START = "<!-- hermes-e2e-evidence:start -->"
EVIDENCE_END = "<!-- hermes-e2e-evidence:end -->"
def _files(root: Path, pattern: str) -> list[Path]:
return sorted(path for path in root.rglob(pattern) if path.is_file()) if root.exists() else []
def _is_explicit_screenshot(path: Path) -> bool:
"""Exclude Playwright's automatic and visual-comparator PNG outputs."""
return not (
path.name.startswith(("test-finished-", "test-failed-"))
or path.name.endswith(("-actual.png", "-expected.png", "-diff.png"))
)
def build_manifest(results_dir: Path) -> dict:
"""Record stable screenshot names from one E2E run for main/PR comparison."""
screenshots = [path for path in _files(results_dir, "*.png") if _is_explicit_screenshot(path)]
return {"version": 1, "screenshot_names": sorted({path.name for path in screenshots})}
def _base_screenshot_names(path: Path | None) -> set[str] | None:
"""Return ``None`` when main evidence is unavailable (never guess newness)."""
if path is None or not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return None
names = data.get("screenshot_names", []) if isinstance(data, dict) else []
if not isinstance(data, dict) or not isinstance(names, list):
return None
return {name for name in names if isinstance(name, str)}
def _stage_name(kind: str, path: Path, results_dir: Path) -> str:
relative = path.relative_to(results_dir).as_posix()
digest = hashlib.sha256(relative.encode("utf-8")).hexdigest()[:12]
return f"{kind}-{digest}-{path.name}"
def select_evidence(results_dir: Path, base_manifest: Path | None = None) -> dict:
"""Select only screenshots new to main, plus every generated visual diff."""
base_names = _base_screenshot_names(base_manifest)
screenshots = [] if base_names is None else [
path for path in _files(results_dir, "*.png")
if _is_explicit_screenshot(path) and path.name not in base_names
]
diffs: list[dict[str, Path]] = []
for diff in _files(results_dir, "*-diff.png"):
stem = diff.with_name(diff.name.removesuffix("-diff.png"))
entry = {"diff": diff}
for kind in ("actual", "expected"):
candidate = stem.with_name(f"{stem.name}-{kind}.png")
if candidate.is_file():
entry[kind] = candidate
diffs.append(entry)
return {"screenshots": screenshots, "diffs": diffs}
def stage_evidence(results_dir: Path, evidence_dir: Path, selection: dict) -> dict:
"""Copy selected PNGs into a flat, path-safe evidence artifact."""
evidence_dir.mkdir(parents=True, exist_ok=True)
staged: dict[Path, str] = {}
def stage(kind: str, path: Path) -> str:
if path in staged:
return staged[path]
name = _stage_name(kind, path, results_dir)
shutil.copyfile(path, evidence_dir / name)
staged[path] = name
return name
manifest = {"version": 1, "screenshots": [], "diffs": []}
for screenshot in selection["screenshots"]:
manifest["screenshots"].append({
"name": screenshot.name,
"file": stage("screenshot", screenshot),
})
for diff in selection["diffs"]:
entry = {"name": diff["diff"].name.removesuffix("-diff.png"), "diff": stage("diff", diff["diff"])}
for kind in ("actual", "expected"):
if kind in diff:
entry[kind] = stage(kind, diff[kind])
manifest["diffs"].append(entry)
(evidence_dir / "e2e-evidence.json").write_text(
json.dumps(manifest, sort_keys=True) + "\n", encoding="utf-8"
)
return manifest
def build_status(selection: dict, artifact_url: str = "") -> list[dict]:
"""Return the review status. The trusted publisher replaces its marker."""
screenshots = selection["screenshots"]
diffs = selection["diffs"]
if not screenshots and not diffs:
return []
summary_parts = []
if screenshots:
summary_parts.append(
f"{len(screenshots)} new screenshot{'s' if len(screenshots) != 1 else ''} vs main"
)
if diffs:
summary_parts.append(f"{len(diffs)} visual diff{'s' if len(diffs) != 1 else ''}")
result: dict[str, str] = {
"kind": "info",
"title": "Desktop E2E visual evidence",
"summary": "; ".join(summary_parts) + ".",
"detail": "\n".join((EVIDENCE_START, "<sub>inline evidence is publishing...</sub>", EVIDENCE_END)),
}
if artifact_url:
result["link"] = artifact_url
result["link_label"] = "View test artifacts"
return [{"source": SOURCE, "results": [result]}]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--results-dir", type=Path, required=True)
parser.add_argument("--base-manifest", type=Path)
parser.add_argument("--manifest-output", type=Path, required=True)
parser.add_argument("--evidence-dir", type=Path, required=True)
parser.add_argument("--artifact-url", default="")
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
args.manifest_output.write_text(
json.dumps(build_manifest(args.results_dir), sort_keys=True) + "\n", encoding="utf-8"
)
selection = select_evidence(args.results_dir, args.base_manifest)
stage_evidence(args.results_dir, args.evidence_dir, selection)
args.output.write_text(
json.dumps(build_status(selection, args.artifact_url)) + "\n", encoding="utf-8"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+214
View File
@@ -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())
+115
View File
@@ -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))
+794
View File
@@ -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())
+174
View File
@@ -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())
+337
View File
@@ -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"![{item.label}]({url})",
"",
"</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