The publisher read the PR number from the CI run's pull_requests payload. GitHub keeps that payload empty for fork runs, so the job printed 'No pull request is associated' and stopped on every fork PR. Resolve the PR from the run's head owner, branch, and SHA instead. The SHA match skips runs that a newer push superseded. A fork PR also has no CI review comment, because the live poller skips forks. The publisher now logs this and exits clean instead of raising; the evidence stays in the workflow artifact.
338 lines
13 KiB
Python
338 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Publish validated E2E evidence as GitHub attachments and update its PR comment.
|
|
|
|
This script only runs from the trusted ``workflow_run`` publisher. It never
|
|
checks out PR code: it accepts the small evidence artifact produced by the
|
|
untrusted E2E workflow, validates its manifest and PNG bytes, uploads the
|
|
approved files as GitHub attachments, and replaces the placeholder in the
|
|
source PR's CI review comment with those attachment URLs.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import html
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.request
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
API_BASE = "https://api.github.com"
|
|
EVIDENCE_START = "<!-- hermes-e2e-evidence:start -->"
|
|
EVIDENCE_END = "<!-- hermes-e2e-evidence:end -->"
|
|
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
|
MAX_FILES = 20
|
|
MAX_FILE_BYTES = 5 * 1024 * 1024
|
|
MAX_TOTAL_BYTES = 20 * 1024 * 1024
|
|
MAX_DIMENSION = 8_000
|
|
COMMENT_LOOKUP_ATTEMPTS = 6
|
|
COMMENT_LOOKUP_DELAY_SECONDS = 2
|
|
_SAFE_FILE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*\.png$")
|
|
_ATTACHMENT_URL = re.compile(r"^!\[[^\]\r\n]*\]\((https://github\.com/user-attachments/assets/[0-9a-fA-F-]+)\)$")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EvidenceFile:
|
|
"""One validated PNG and the label used when rendering the PR comment."""
|
|
|
|
filename: str
|
|
label: str
|
|
|
|
|
|
def _api_request(
|
|
url: str,
|
|
token: str,
|
|
method: str = "GET",
|
|
payload: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Send one authenticated GitHub API request and return its JSON object."""
|
|
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
|
request = urllib.request.Request(
|
|
url,
|
|
data=data,
|
|
method=method,
|
|
headers={
|
|
"Authorization": f"Bearer {token}",
|
|
"Accept": "application/vnd.github+json",
|
|
"Content-Type": "application/json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
"User-Agent": "hermes-e2e-evidence-publisher",
|
|
},
|
|
)
|
|
with urllib.request.urlopen(request) as response:
|
|
parsed = json.loads(response.read())
|
|
if not isinstance(parsed, dict):
|
|
raise ValueError(f"Expected an object from {url}")
|
|
return parsed
|
|
|
|
|
|
def _read_png(path: Path) -> bytes:
|
|
"""Read a bounded PNG, rejecting corrupt and unexpectedly large images."""
|
|
if not path.is_file() or path.is_symlink():
|
|
raise ValueError(f"Evidence file is not a regular file: {path.name}")
|
|
size = path.stat().st_size
|
|
if size == 0 or size > MAX_FILE_BYTES:
|
|
raise ValueError(f"Evidence file has invalid size: {path.name}")
|
|
data = path.read_bytes()
|
|
if not data.startswith(PNG_SIGNATURE) or len(data) < 24 or data[12:16] != b"IHDR":
|
|
raise ValueError(f"Evidence file is not a PNG: {path.name}")
|
|
width = int.from_bytes(data[16:20], "big")
|
|
height = int.from_bytes(data[20:24], "big")
|
|
if not 0 < width <= MAX_DIMENSION or not 0 < height <= MAX_DIMENSION:
|
|
raise ValueError(f"Evidence image has invalid dimensions: {path.name}")
|
|
return data
|
|
|
|
|
|
def _manifest_files(manifest: dict[str, Any]) -> list[EvidenceFile]:
|
|
"""Flatten a version-one manifest into ordered, reviewer-facing images."""
|
|
if manifest.get("version") != 1:
|
|
raise ValueError("Unsupported E2E evidence manifest version")
|
|
|
|
files: list[EvidenceFile] = []
|
|
screenshots = manifest.get("screenshots", [])
|
|
diffs = manifest.get("diffs", [])
|
|
if not isinstance(screenshots, list) or not isinstance(diffs, list):
|
|
raise ValueError("Evidence manifest lists are malformed")
|
|
|
|
for entry in screenshots:
|
|
if not isinstance(entry, dict) or not isinstance(entry.get("name"), str) or not isinstance(entry.get("file"), str):
|
|
raise ValueError("Evidence screenshot entry is malformed")
|
|
files.append(EvidenceFile(entry["file"], f"new screenshot: {entry['name']}"))
|
|
|
|
for entry in diffs:
|
|
if not isinstance(entry, dict) or not isinstance(entry.get("name"), str) or not isinstance(entry.get("diff"), str):
|
|
raise ValueError("Evidence visual-diff entry is malformed")
|
|
files.append(EvidenceFile(entry["diff"], f"visual diff: {entry['name']}"))
|
|
for kind in ("actual", "expected"):
|
|
value = entry.get(kind)
|
|
if value is not None:
|
|
if not isinstance(value, str):
|
|
raise ValueError("Evidence visual-diff companion is malformed")
|
|
files.append(EvidenceFile(value, f"visual {kind}: {entry['name']}"))
|
|
|
|
names = [item.filename for item in files]
|
|
if len(files) > MAX_FILES or len(set(names)) != len(names):
|
|
raise ValueError("Evidence manifest has too many or duplicate files")
|
|
if any(not _SAFE_FILE.fullmatch(name) for name in names):
|
|
raise ValueError("Evidence manifest contains an unsafe filename")
|
|
return files
|
|
|
|
|
|
def load_evidence(evidence_dir: Path) -> tuple[list[EvidenceFile], dict[str, bytes]]:
|
|
"""Load the manifest and return only the validated files it declares."""
|
|
manifest_path = evidence_dir / "e2e-evidence.json"
|
|
if not manifest_path.is_file() or manifest_path.is_symlink():
|
|
raise ValueError("E2E evidence manifest is missing")
|
|
try:
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError("E2E evidence manifest is not JSON") from exc
|
|
if not isinstance(manifest, dict):
|
|
raise ValueError("E2E evidence manifest is not an object")
|
|
|
|
files = _manifest_files(manifest)
|
|
payloads: dict[str, bytes] = {}
|
|
total = 0
|
|
for item in files:
|
|
path = evidence_dir / item.filename
|
|
if path.parent != evidence_dir:
|
|
raise ValueError("Evidence file escaped its artifact directory")
|
|
payload = _read_png(path)
|
|
total += len(payload)
|
|
if total > MAX_TOTAL_BYTES:
|
|
raise ValueError("E2E evidence exceeds the total size limit")
|
|
payloads[item.filename] = payload
|
|
return files, payloads
|
|
|
|
|
|
def render_evidence(files: list[EvidenceFile], attachment_urls: dict[str, str]) -> str:
|
|
"""Render validated GitHub attachment URLs inside the review-comment marker."""
|
|
blocks = [EVIDENCE_START]
|
|
for item in files:
|
|
url = attachment_urls.get(item.filename)
|
|
if url is None:
|
|
raise ValueError(f"Missing attachment URL for {item.filename}")
|
|
blocks.extend((
|
|
"<details>",
|
|
f"<summary>{item.label}</summary>",
|
|
"",
|
|
f"",
|
|
"",
|
|
"</details>",
|
|
))
|
|
blocks.append(EVIDENCE_END)
|
|
return "\n".join(blocks)
|
|
|
|
|
|
def render_upload_failure(error: Exception) -> str:
|
|
"""Render an escaped upload error inside the review-comment marker."""
|
|
return "\n".join((
|
|
EVIDENCE_START,
|
|
"<sub>inline evidence upload failed.</sub>",
|
|
"",
|
|
f"<pre>{html.escape(str(error))}</pre>",
|
|
EVIDENCE_END,
|
|
))
|
|
|
|
|
|
def replace_evidence_marker(comment: str, evidence: str) -> str:
|
|
"""Replace exactly the pending-evidence region in a CI review comment."""
|
|
pattern = re.compile(f"{re.escape(EVIDENCE_START)}.*?{re.escape(EVIDENCE_END)}", re.DOTALL)
|
|
result, count = pattern.subn(evidence, comment, count=1)
|
|
if count != 1:
|
|
raise ValueError("CI review comment does not contain one evidence marker")
|
|
return result
|
|
|
|
|
|
def _find_review_comment(comments: object) -> dict[str, Any] | None:
|
|
"""Find a live CI review comment only after it contains this marker."""
|
|
if not isinstance(comments, list):
|
|
raise ValueError("GitHub comments response is malformed")
|
|
for item in comments:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
body = str(item.get("body", ""))
|
|
if body.startswith("<!-- hermes-ci-review-bot -->") and EVIDENCE_START in body and EVIDENCE_END in body:
|
|
return item
|
|
return None
|
|
|
|
|
|
def _wait_for_review_comment(token: str, source_repo: str, pr_number: str) -> dict[str, Any] | None:
|
|
"""Wait briefly for GitHub's comment API to expose the completed marker."""
|
|
request = urllib.request.Request(
|
|
f"{API_BASE}/repos/{source_repo}/issues/{pr_number}/comments?per_page=100",
|
|
headers={
|
|
"Authorization": f"Bearer {token}",
|
|
"Accept": "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
"User-Agent": "hermes-e2e-evidence-publisher",
|
|
},
|
|
)
|
|
for attempt in range(COMMENT_LOOKUP_ATTEMPTS):
|
|
with urllib.request.urlopen(request) as response:
|
|
comment = _find_review_comment(json.loads(response.read()))
|
|
if comment is not None:
|
|
return comment
|
|
if attempt + 1 < COMMENT_LOOKUP_ATTEMPTS:
|
|
time.sleep(COMMENT_LOOKUP_DELAY_SECONDS)
|
|
return None
|
|
|
|
|
|
def upload_evidence(
|
|
files: list[EvidenceFile],
|
|
evidence_dir: Path,
|
|
source_repo: str,
|
|
session_token: str,
|
|
) -> dict[str, str]:
|
|
"""Upload validated files through gh-image and accept only attachment URLs."""
|
|
environment = os.environ.copy()
|
|
environment["GH_SESSION_TOKEN"] = session_token
|
|
attachment_urls: dict[str, str] = {}
|
|
for item in files:
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
"gh",
|
|
"image",
|
|
"--repo",
|
|
source_repo,
|
|
str(evidence_dir / item.filename),
|
|
],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True, encoding="utf-8", errors="replace",
|
|
env=environment,
|
|
)
|
|
except subprocess.CalledProcessError as exc:
|
|
output = "; ".join(
|
|
value.strip()
|
|
for value in (exc.stdout, exc.stderr)
|
|
if value and value.strip()
|
|
)
|
|
message = f"Failed to upload {item.filename} with gh image (exit code {exc.returncode})"
|
|
if output:
|
|
message = f"{message}: {output}"
|
|
print(message, file=sys.stderr)
|
|
raise RuntimeError(message) from exc
|
|
match = _ATTACHMENT_URL.fullmatch(result.stdout.strip())
|
|
if match is None:
|
|
raise ValueError(f"gh-image returned an invalid attachment reference for {item.filename}")
|
|
attachment_urls[item.filename] = match.group(1)
|
|
return attachment_urls
|
|
|
|
|
|
def publish(
|
|
token: str,
|
|
source_repo: str,
|
|
evidence_dir: Path,
|
|
pr_number: str,
|
|
session_token: str,
|
|
) -> bool:
|
|
"""Publish evidence and patch its source PR comment; false means nothing to show."""
|
|
files, _ = load_evidence(evidence_dir)
|
|
if not files:
|
|
print("No inline E2E evidence to publish.")
|
|
return False
|
|
comment = _wait_for_review_comment(token, source_repo, pr_number)
|
|
if comment is None:
|
|
# A fork PR gets no CI review comment (the live poller needs a
|
|
# write token there), so there is no marker to patch. The
|
|
# evidence stays available in the workflow artifact.
|
|
print(
|
|
f"PR #{pr_number} has no CI review comment with an E2E evidence "
|
|
"marker; the evidence stays in the workflow artifact."
|
|
)
|
|
return False
|
|
try:
|
|
attachment_urls = upload_evidence(
|
|
files, evidence_dir, source_repo, session_token
|
|
)
|
|
except Exception as exc:
|
|
body = replace_evidence_marker(
|
|
str(comment.get("body", "")), render_upload_failure(exc)
|
|
)
|
|
_api_request(
|
|
f"{API_BASE}/repos/{source_repo}/issues/comments/{comment['id']}",
|
|
token,
|
|
method="PATCH",
|
|
payload={"body": body},
|
|
)
|
|
raise
|
|
evidence = render_evidence(files, attachment_urls)
|
|
body = replace_evidence_marker(str(comment.get("body", "")), evidence)
|
|
_api_request(
|
|
f"{API_BASE}/repos/{source_repo}/issues/comments/{comment['id']}",
|
|
token,
|
|
method="PATCH",
|
|
payload={"body": body},
|
|
)
|
|
print(f"Published {len(files)} E2E evidence image attachment(s).")
|
|
return True
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--evidence-dir", type=Path, required=True)
|
|
parser.add_argument("--source-repo", required=True)
|
|
parser.add_argument("--pr-number", required=True)
|
|
args = parser.parse_args()
|
|
|
|
token = os.environ.get("GITHUB_TOKEN", "")
|
|
if not token:
|
|
parser.error("GITHUB_TOKEN is required")
|
|
session_token = os.environ.get("GH_SESSION_TOKEN", "")
|
|
if not session_token:
|
|
parser.error("GH_SESSION_TOKEN is required")
|
|
publish(token, args.source_repo, args.evidence_dir, args.pr_number, session_token)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|