Files
hermes-agent/scripts/render-builds-table.py

744 lines
34 KiB
Python

#!/usr/bin/env python3
"""Render the release download tables into <!-- HERMES_BUILDS_TABLE -->, and
the same rows as standalone pages in the bucket.
Runs after the build jobs of desktop-bundled-release.yml finish, including
failed legs, and edits the GitHub release body in place. The tables
are built from the bucket's ACTUAL object names (scripts/releases/r2.py
list --prefix releases/tag/<tag>/), filtered to the tag's exact version —
a missing artifact shows up as a missing row, never a dead link. The
GitHub release carries the notes only; the binaries live in the R2 bucket
under releases/tag/<tag>/, and the download links point at the R2 public
URL (CLOUDFLARE_R2_PUBLIC_URL / --r2-base-url).
Every run also publishes the same rows as a tiny HTML page in the bucket,
so a build can be read straight from the download origin:
releases/tag/<tag>/index.html every admitted tag, including failed builds
releases/<channel>/index.html latest successful stable or canary build;
failed prerequisites leave it unchanged
releases/commit/<sha>/index.html commit mode: every expected binary of
one commit build, built or not
Tables: Hermes Desktop (bundled) and Hermes Light, one row per (OS,
arch). Feed manifests (latest*/light*/canary*.yml), blockmaps and mac .zip
(an electron-updater delta target, not a user download) stay out of the
tables on purpose; they still live in the bucket for the updater to
consume.
With --pending-run-url, renders a "builds in progress" link to the
workflow run instead of the tables. The builds-pending job runs this
mode as the first job of the run, so the draft body points at the live
run while the matrix builds. The link block keeps the marker wrapper,
so the final render replaces it.
Usage: render-builds-table.py --tag vX.Y.Z [--repo owner/repo] [--r2-base-url URL] [--dry-run]
Idempotent: re-running replaces the previously rendered block (the
marker is kept as an HTML comment wrapper around the tables).
"""
from __future__ import annotations
import argparse
import html
import json
import os
import re
import subprocess
import sys
import tempfile
from pathlib import Path
from urllib.parse import quote
# Direct-script invocation starts with scripts/, not the repository root.
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from scripts.releases import handoff, r2, semver, stable # noqa: E402
MARKER = "<!-- HERMES_BUILDS_TABLE -->"
END_MARKER = "<!-- /HERMES_BUILDS_TABLE -->"
DEFAULT_REPO = "NousResearch/hermes-agent"
# Asset name shapes (electron-builder artifactName in
# apps/desktop/electron-builder.config.cjs):
# Hermes-0.28.0-mac-arm64.dmg (bundled)
# HermesBundled-0.28.0-win-x64.msix (bundled)
_ASSET_RE = re.compile(
r"^(?P<app>HermesBundled|HermesLight)-(?P<version>[^-]+)"
r"-(?P<os>mac|win|linux)-(?P<arch>x64|arm64)\.(?P<ext>dmg|msix|AppImage)$"
)
_OS_LABEL = {"mac": "macOS", "win": "Windows", "linux": "Linux (AppImage)"}
_ARCH_LABEL = {
("mac", "arm64"): "Apple Silicon (M-series)",
("mac", "x64"): "Intel",
("win", "x64"): "x86 (64-bit)",
("win", "arm64"): "ARM (arm64 / aarch64)",
("linux", "x64"): "x86 (64-bit)",
("linux", "arm64"): "arm64",
}
_KIND_LABEL = {"dmg": "DMG", "msix": "MSIX", "AppImage": "AppImage"}
_ROW_ORDER = [("mac", "arm64"), ("mac", "x64"), ("win", "x64"), ("win", "arm64"),
("linux", "x64"), ("linux", "arm64")]
def parse_assets(names: list[str]) -> dict[str, dict[tuple[str, str], tuple[str, str]]]:
"""{app: {(os, arch): (full_key, ext)}} for table-shaped assets only.
Object keys are releases/tag/<tag>/<filename>; the shape match runs on
the basename, but the stored name keeps the full key so the download
link points at the object's real location.
"""
out: dict[str, dict[tuple[str, str], tuple[str, str]]] = {"HermesBundled": {}, "HermesLight": {}}
for name in names:
base = name.rsplit("/", 1)[-1]
m = _ASSET_RE.match(base)
if m:
out[m.group("app")][(m.group("os"), m.group("arch"))] = (name, m.group("ext"))
return out
def table_rows(assets_by_app: dict) -> list[tuple[str, list[tuple[str, str, str, str]]]]:
"""[(section title, [(os label, arch label, kind label, object key), ...]), ...].
The ONE row set every sink renders (release-body markdown, R2 page);
a row exists only for an object that is actually in the bucket.
"""
sections: list[tuple[str, list[tuple[str, str, str, str]]]] = []
for app, title in (("HermesBundled", "Hermes Desktop"), ("HermesLight", "Hermes Light (remote-only client)")):
rows = []
for key in _ROW_ORDER:
entry = assets_by_app.get(app, {}).get(key)
if not entry:
continue
name, ext = entry
rows.append((_OS_LABEL[key[0]], _ARCH_LABEL[key], _KIND_LABEL[ext], name))
if rows:
sections.append((title, rows))
return sections
def render_tables(assets_by_app: dict, base_url: str,
incomplete_jobs: list[str] | None = None,
run_url: str | None = None, *, smoke_results: dict | None = None) -> str:
"""The replacement block: marker + tables + end marker."""
sections = []
for title, rows in table_rows(assets_by_app):
lines = [
f"| {os_name} | {arch} | [{kind}]({r2.public_url_for(base_url, name)}) |"
for os_name, arch, kind, name in rows
]
sections.append(
f"### {title}\n\n| OS | Architecture | Download |\n|---|---|---|\n" + "\n".join(lines)
)
if not sections:
sections.append("No downloadable artifacts were staged for this build.")
if incomplete_jobs:
sections.insert(0, "> **Build incomplete.** Jobs not successful: "
+ ", ".join(incomplete_jobs) + ". The channel page was not advanced.")
if run_url:
sections.append("### Build diagnostics\n\n| Job | Diagnostics |\n|---|---|\n"
+ "\n".join(f"| {job} | [View build run]({run_url}) |"
for job in incomplete_jobs))
sections.append(smoke_markdown(smoke_results))
return MARKER + "\n## Downloads\n\n" + "\n\n".join(sections) + "\n" + END_MARKER
def render_pending(run_url: str) -> str:
"""The placeholder block: a link to the run, in the same marker wrapper."""
return (
MARKER
+ f"\n> 🚧 [Builds in progress]({run_url}) — the download links"
+ " appear here when the build matrix finishes.\n"
+ END_MARKER
)
def filter_names_for_version(names: list[str], version: str) -> list[str]:
"""Table-shaped names whose embedded version equals `version` (exact, not prefix).
Matches on the basename (keys carry the releases/tag/<tag>/ prefix) and
returns the full keys.
"""
out = []
for name in names:
base = name.rsplit("/", 1)[-1]
m = _ASSET_RE.match(base)
if m and m.group("version") == version:
out.append(name)
return out
# ---------------------------------------------------------------------------
# Commit-build summary: every expected binary of a commit run, built or not.
# ---------------------------------------------------------------------------
# A row needs a unique receipt-listed artifact and its uploaded object.
_COMMIT_EXPECTED = [
("Windows x64 (MSIX)", "win32-x64",
r"^HermesBundled-[^-]+-win-x64\.msix$"),
("Windows ARM64 (MSIX)", "win32-arm64",
r"^HermesBundled-[^-]+-win-arm64\.msix$"),
("Windows universal bundle (MSIXBUNDLE)", "windows-universal",
r"^HermesBundled-[^-]+-win\.msixbundle$"),
("macOS Apple Silicon (DMG)", "darwin-arm64",
r"^HermesBundled-[^-]+-mac-arm64\.dmg$"),
("macOS Intel (DMG)", "darwin-x64",
r"^HermesBundled-[^-]+-mac-x64\.dmg$"),
("macOS Apple Silicon (ZIP)", "darwin-arm64",
r"^HermesBundled-[^-]+-mac-arm64\.zip$"),
("macOS Intel (ZIP)", "darwin-x64",
r"^HermesBundled-[^-]+-mac-x64\.zip$"),
("Termux aarch64 (.deb)", "termux", r"^.*\.deb$"),
]
# Linux release legs are disabled; they still get a row so a reader can see
# they were never expected to publish.
_COMMIT_DISABLED = ["Linux x64 (AppImage)", "Linux ARM64 (AppImage)"]
COMMIT_RECEIPT_NAMES = sorted({leg for _label, leg, _pattern in _COMMIT_EXPECTED})
_COMMIT_JOBS = {
"win32-x64": "build-win32", "win32-arm64": "build-win32",
"darwin-x64": "build-darwin", "darwin-arm64": "build-darwin",
"windows-universal": "assemble-win32-bundle", "termux": "termux-deb",
}
_SMOKE_SCOPE = (
"Download availability is independent of smoke status. Each result covers all native legs "
"in that format group. Linux bundles are disabled; unsigned Store envelopes are not install-smoked. "
"No-upload dry tags have no download handoff and are not smoke-qualified."
)
def smoke_rows(results: dict | None) -> list[tuple[str, str]]:
statuses = {"success": "Passed", "failure": "Failed", "cancelled": "Cancelled", "skipped": "Not run"}
return [(label, statuses.get((results or {}).get(job, {}).get("result"), "Not run (no result)"))
for job, label in stable.SMOKE_JOBS.items()]
def smoke_markdown(results: dict | None) -> str:
return ("### Native install/chat smoke\n\n" + _SMOKE_SCOPE
+ "\n\n| Packages | Smoke status |\n|---|---|\n"
+ "\n".join(f"| {label} | {status} |" for label, status in smoke_rows(results)))
def smoke_html(results: dict | None) -> list[str]:
return ["<h2>Native install/chat smoke</h2>", f"<p>{html.escape(_SMOKE_SCOPE)}</p>",
*_table(("Packages", "Smoke status"),
[[html.escape(label), html.escape(status)] for label, status in smoke_rows(results)])]
def commit_expected_rows(names: list[str],
receipts: dict[str, dict | None]) -> list[dict]:
"""Classify artifacts from validated receipts without trusting orphan objects."""
objects = set(names)
rows: list[dict] = []
for label, leg, pattern in _COMMIT_EXPECTED:
receipt = receipts.get(leg)
listed = [r2.commit_key_for(receipt["commit"], row["path"])
for row in receipt["files"]
if re.fullmatch(pattern, row["path"].rsplit("/", 1)[-1])] if receipt else []
if receipt is None:
state, key = "receipt-missing", None
elif not listed:
state, key = "receipt-omits", None
elif len(listed) > 1:
state, key = "ambiguous", None
elif listed[0] not in objects:
state, key = "object-missing", None
else:
state, key = "built", listed[0]
rows.append({"label": label, "leg": leg, "key": key, "state": state})
return rows
def _validated_commit_inputs(commit: str, receipts: dict[str, dict | None]) -> None:
"""The summary and the commit page must never disagree about what was
built, so both validate the SHA and every receipt the same way."""
r2.commit_prefix_for(commit)
for leg, receipt in receipts.items():
if receipt is not None:
handoff.validate_commit_receipt(receipt, commit, leg)
def commit_entries(commit: str, names: list[str], base_url: str,
receipts: dict[str, dict | None],
failed_legs: list[str] | None = None,
run_url: str | None = None,
) -> list[tuple[str, str, str | None, str | None]]:
"""[(label, status, URL or None, link text or None)] for both summary sinks.
Downloads require a receipt-listed object in the bucket. Missing artifacts
link to the actual workflow run, never a guessed download or job URL.
"""
failed = set(failed_legs or [])
entries: list[tuple[str, str, str | None, str | None]] = []
for row in commit_expected_rows(names, receipts):
label, leg, key, state = row["label"], row["leg"], row["key"], row["state"]
if state == "built":
entries.append((label, "✅ Built", r2.public_url_for(base_url, key), key.rsplit("/", 1)[-1]))
continue
elif state == "receipt-missing":
related = failed.intersection({leg, _COMMIT_JOBS[leg]})
blame = f"failed: {', '.join(sorted(related))}" if related \
else "leg incomplete or upload interrupted"
status = f"❌ Not built ({blame})"
elif state == "object-missing":
status = "❌ Not built (receipt present but object missing)"
elif state == "receipt-omits":
status = "❌ Not built (artifact absent from receipt)"
else:
status = "❌ Not built (ambiguous: multiple objects match)"
entries.append((label, status, run_url, "View build run" if run_url else None))
for label in _COMMIT_DISABLED:
entries.append((label, "Disabled (release leg disabled)", None, None))
return entries
def render_commit_summary(names: list[str], base_url: str, commit: str,
receipts: dict[str, dict | None],
failed_legs: list[str] | None = None,
run_url: str | None = None, *, smoke_results: dict | None = None) -> str:
"""Render every expected product without reading or changing a release."""
_validated_commit_inputs(commit, receipts)
lines = [
f"## Commit build `{commit[:12]}`",
"",
"| Binary | Status | Download / diagnostics |",
"|---|---|---|",
]
for label, status, url, link_text in commit_entries(commit, names, base_url, receipts, failed_legs, run_url):
cell = f"[{link_text}]({url})" if url and link_text else "—"
lines.append(f"| {label} | {status} | {cell} |")
return "\n".join([*lines, "", smoke_markdown(smoke_results), ""])
def read_commit_receipts(commit: str,
names: list[str] | None = None) -> dict[str, dict | None]:
"""Missing receipts describe incomplete legs; corrupt receipts raise."""
out: dict[str, dict | None] = {}
for name in (names or COMMIT_RECEIPT_NAMES):
try:
out[name] = handoff.read_commit_receipt(commit, name)
except handoff.MissingReceipt:
out[name] = None
return out
def failed_legs_from_release_needs(release_needs_json: str | None) -> list[str]:
"""Read failure labels from the optional workflow result summary."""
if not release_needs_json:
return []
try:
needs = json.loads(release_needs_json)
except (ValueError, TypeError):
return []
if not isinstance(needs, dict):
return []
return sorted(name for name, info in needs.items()
if isinstance(info, dict) and info.get("result") not in ("success", "skipped"))
def incomplete_release_jobs(release_needs_json: str | None) -> list[str]:
"""Tag pages may describe failures; channel pointers require successful jobs.
Unlike a commit summary, skipped release prerequisites are incomplete too.
Standalone invocations without workflow results retain their existing behavior.
"""
if release_needs_json is None:
return []
try:
needs = json.loads(release_needs_json)
if not isinstance(needs, dict) or not needs:
raise ValueError("missing workflow results")
return [f"{name} ({info.get('result', 'unknown')})" for name, info in sorted(needs.items())
if info.get("result") != "success"]
except (ValueError, TypeError, AttributeError):
return ["workflow results unavailable"]
# ---------------------------------------------------------------------------
# Bucket pages: the same rows as the tables, served from the download origin
# ---------------------------------------------------------------------------
_PAGE_STYLE = (
"body{font:15px/1.5 system-ui,-apple-system,'Segoe UI',sans-serif;margin:2rem auto;"
"max-width:54rem;padding:0 1rem;color:#1a1a1a;background:#fff}"
"h1{font-size:1.4rem}h2{font-size:1.05rem;margin-top:1.75rem}"
"p{color:#444}table{border-collapse:collapse;width:100%}"
"th,td{text-align:left;padding:.45rem .6rem;border-bottom:1px solid #dcdcdc}"
"th{font-weight:600}a{color:#0a58ca}code{font-size:.95em;white-space:pre-wrap;overflow-wrap:anywhere}"
)
# The record a channel page keeps of the release it describes; the write
# guard reads it back so an older tag re-run never regresses the page.
_BUILD_META_RE = re.compile(r'<meta name="hermes-build" content="([^"]*)"')
def _page(title: str, build: str, body: list[str]) -> str:
return (
'<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n'
'<meta name="viewport" content="width=device-width, initial-scale=1">\n'
f'<meta name="hermes-build" content="{html.escape(build, quote=True)}">\n'
f"<title>{html.escape(title)}</title>\n<style>{_PAGE_STYLE}</style>\n</head>\n<body>\n"
+ "\n".join(body)
+ "\n</body>\n</html>\n"
)
def _table(headers: tuple[str, ...], rows: list[list[str]]) -> list[str]:
head = "".join(f"<th>{html.escape(name)}</th>" for name in headers)
body = ["<tr>" + "".join(f"<td>{cell}</td>" for cell in row) + "</tr>" for row in rows]
return [f"<table>\n<thead><tr>{head}</tr></thead>\n<tbody>", *body, "</tbody>\n</table>"]
def _link(url: str) -> str:
return f'<a href="{html.escape(url, quote=True)}">'
def render_page(tag: str, assets_by_app: dict, base_url: str,
incomplete_jobs: list[str] | None = None,
run_url: str | None = None, *, repo: str = DEFAULT_REPO,
smoke_results: dict | None = None) -> str:
"""The tag/channel page: the release-body download table as HTML."""
channel = r2.channel_for_tag(tag)
tag_url = f"https://github.com/{quote(repo, safe='/')}/releases/tag/{quote(tag, safe='')}"
body = [
f"<h1>Hermes Desktop {channel} builds</h1>",
f"<p>Release {_link(tag_url)}<code>{html.escape(tag)}</code></a>. Only objects this release "
"actually staged in the bucket are listed.</p>",
]
if incomplete_jobs:
body.append("<p><strong>Build incomplete.</strong> Jobs not successful: "
+ html.escape(", ".join(incomplete_jobs))
+ ". The channel page was not advanced.</p>")
sections = table_rows(assets_by_app)
if not sections:
body.append("<p>No downloadable artifacts were staged for this build.</p>")
for title, rows in sections:
body.append(f"<h2>{html.escape(title)}</h2>")
body.extend(_table(
("OS", "Architecture", "Download"),
[[html.escape(os_name), html.escape(arch),
f"{_link(r2.public_url_for(base_url, name))}{html.escape(kind)}</a>"]
for os_name, arch, kind, name in rows],
))
if incomplete_jobs and run_url:
body.append("<h2>Build diagnostics</h2>")
body.extend(_table(
("Job", "Diagnostics"),
[[html.escape(job), f"{_link(run_url)}View build run</a>"] for job in incomplete_jobs],
))
body.extend(smoke_html(smoke_results))
return _page(f"Hermes Desktop {channel} builds", tag, body)
def render_commit_page(commit: str, names: list[str], base_url: str,
receipts: dict[str, dict | None],
failed_legs: list[str] | None = None,
run_url: str | None = None, *, repo: str = DEFAULT_REPO,
bundle_env: dict[str, str | None] | None = None,
smoke_results: dict | None = None) -> str:
"""The commit-build page: every expected binary, built or not."""
_validated_commit_inputs(commit, receipts)
commit_url = f"https://github.com/{quote(repo, safe='/')}/commit/{commit}"
rows = []
for label, status, url, link_text in commit_entries(commit, names, base_url, receipts, failed_legs, run_url):
cell = (f"{_link(url)}{html.escape(link_text)}</a>" if url and link_text else "—")
rows.append([html.escape(label), html.escape(status), cell])
body = [
f"<h1>Hermes commit build <code>{html.escape(commit[:12])}</code></h1>",
f"<p>Commit {_link(commit_url)}<code>{html.escape(commit)}</code></a>. Every expected binary is listed; "
"built rows link to downloads; incomplete rows link to the build run when available.</p>",
*_table(("Binary", "Status", "Download / diagnostics"), rows),
]
if bundle_env:
from scripts.releases.bundle_env import validate
body.extend([
"<h2>Bundle environment</h2>",
"<p>Explicit non-secret desktop bundle overrides only. Runtime values override defaults; "
"Unset always removes the variable. String values use JSON notation.</p>",
*_table(("Variable", "Value / action"), [
[f"<code>{html.escape(key)}</code>", "Unset" if value is None else
f"<code>{html.escape(json.dumps(value, ensure_ascii=False))}</code>"]
for key, value in sorted(validate(bundle_env).items())
]),
])
body.extend(smoke_html(smoke_results))
return _page(f"Hermes commit build {commit[:12]}", commit, body)
def recorded_build(page: str | None) -> str | None:
"""The release tag or commit a page in the bucket describes."""
match = _BUILD_META_RE.search(page or "")
return (match.group(1) or None) if match else None
def supersedes(existing_page: str | None, tag: str) -> bool:
"""Whether `tag` may replace the channel page.
Channel pages are mutable pointers: a re-run of an OLDER tag must not
regress the page a newer release already published (the release body
cannot regress — each tag owns its own release). Ordering uses the same
semver authority the feeds do; an absent or unreadable record is written,
because the new page is then the best available information.
"""
recorded = recorded_build(existing_page)
if not recorded:
return True
from hermes_cli.update_channel import canary_timestamp, is_canary_tag
if is_canary_tag(recorded) != is_canary_tag(tag):
return False
if is_canary_tag(tag):
recorded_stamp = canary_timestamp(recorded)
tag_stamp = canary_timestamp(tag)
return recorded_stamp is not None and tag_stamp is not None and recorded_stamp <= tag_stamp
try:
return semver.compare(recorded.lstrip("v"), tag.lstrip("v")) <= 0
except ValueError:
return True
def write_page(key: str, page: str, base_url: str) -> str:
"""PUT one page object at `key` (a FULL key, no tag archive) and return
its public URL. Mutable: the page is replaced by each build of its own
channel/commit."""
with tempfile.NamedTemporaryFile("w", encoding="utf-8", newline="\n",
suffix=".html", delete=False) as handle:
handle.write(page)
path = handle.name
try:
r2.put(tag="", key=key, file=path, key_is_full=True)
finally:
os.unlink(path)
url = r2.public_url_for(base_url, key)
print(f"✓ Page {url} ({len(page)} bytes)")
return url
def existing_page(key: str) -> str | None:
"""The page currently at `key`, or None when nothing is published there."""
try:
creds, base, bucket = r2.credentials()
return r2.get_object(creds, base, bucket, key, r2.amz_timestamp())
except r2.R2RequestError as err:
if err.status == 404:
return None
raise
def write_channel_page(tag: str, assets_by_app: dict, base_url: str,
*, repo: str = DEFAULT_REPO, smoke_results: dict | None = None) -> str | None:
"""Publish releases/<channel>/index.html for the tag's own channel."""
key = r2.channel_page_key_for(r2.channel_for_tag(tag))
if not supersedes(existing_page(key), tag):
print(f"::warning::{key} already describes a newer release; leaving it unchanged")
return None
return write_page(key, render_page(tag, assets_by_app, base_url, repo=repo, smoke_results=smoke_results), base_url)
def r2_object_names_under(prefix: str) -> list[str]:
return r2.list_objects(prefix=prefix)["keys"]
def r2_object_names(tag: str) -> list[str]:
"""Object keys in the R2 staging dir for `tag`, under releases/tag/<tag>/.
A tag prefix and exact version match exclude neighboring releases.
"""
keys = r2_object_names_under(f"releases/tag/{tag}/")
return filter_names_for_version(keys, tag.lstrip("v"))
def splice(body: str, block: str) -> str:
"""Replace the marker (or a previously rendered block) with `block`."""
if END_MARKER in body:
pattern = re.compile(re.escape(MARKER) + r".*?" + re.escape(END_MARKER), re.DOTALL)
return pattern.sub(lambda _m: block, body, count=1)
return body.replace(MARKER, block, 1)
def render_channel_summary(manifest: dict) -> str:
request = manifest["request"]
rows = []
for platform in ("darwin", "win32"):
for arch in ("arm64", "x64"):
selected = [row for row in manifest["packages"] if row["platform"] == platform
and row["arch"] == arch and row["variant"] == "bundled"]
label = f"{platform} {arch}"
if len(selected) != 1:
rows.append(f"| {label} | Unavailable | — |")
continue
item = selected[0]
url = r2.public_url_for(request["publicBase"], item["artifact"]["key"])
rows.append(f"| {label} | {item['version']} | [Download]({url}) |")
return (f"## Channel `{request['channel']}` build `{request['buildId']}`\n\n"
f"Source `{request['commit']}` ({request['sourceVersion']}); sequence {request['sequence']}.\n\n"
"| Native package | Version | Download |\n|---|---|---|\n" + "\n".join(rows) + "\n")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--channel-build")
parser.add_argument("--channel-request-sha256")
parser.add_argument("--tag", required=False, help="Release tag to render the release-body table for")
parser.add_argument("--candidate-manifest-sha256", default=None,
help="Stable promotion: render smoke admission from this pinned candidate, not RELEASE_NEEDS")
parser.add_argument("--candidate-commit", default=None,
help="Exact admitted commit for --candidate-manifest-sha256")
parser.add_argument("--summary-commit", default=None,
help="Commit-only mode: render the FULL expected-binary matrix for "
"releases/commit/<sha>/ into --summary-out. Never touches a "
"GitHub release; every expected binary gets a row, built or not")
parser.add_argument("--summary-out", default=None,
help="With --summary-commit: file the summary block is written to "
"(the workflow passes $GITHUB_STEP_SUMMARY)")
parser.add_argument("--summary-failed-legs", default="",
help="With --summary-commit: comma-separated failed job names, "
"blamed on the Not built rows")
parser.add_argument("--repo", default=DEFAULT_REPO)
parser.add_argument("--bundle-env-json", default=os.environ.get("HERMES_BUNDLE_ENV_JSON", ""),
help="Explicit non-secret commit desktop bundle overrides, not the CI environment")
parser.add_argument("--run-url", default=None,
help="Actual workflow run URL for incomplete-build diagnostics")
parser.add_argument("--r2-base-url", default=os.environ.get("CLOUDFLARE_R2_PUBLIC_URL"),
help="Public base URL of the R2 bucket (default: $CLOUDFLARE_R2_PUBLIC_URL)")
parser.add_argument("--dry-run", action="store_true",
help="Print the spliced body instead of editing the release "
"(writes nothing, including the channel page)")
parser.add_argument("--pending-run-url", default=None,
help="Render a 'builds in progress' link to this workflow run "
"instead of the tables")
args = parser.parse_args()
try:
smoke_results = json.loads(os.environ.get("RELEASE_NEEDS", "{}"))
if not isinstance(smoke_results, dict):
smoke_results = {}
except ValueError:
smoke_results = {}
if args.summary_commit and (args.tag or args.pending_run_url):
parser.error("--summary-commit cannot be combined with release-body arguments")
if args.channel_build:
from hermes_cli.release_channels import ChannelReader
from scripts.releases.channel_publish import read_request
if args.tag or args.summary_commit or not args.summary_out or not args.r2_base_url:
parser.error("Channel summary needs --summary-out and --r2-base-url; no tag/commit mode")
request = read_request(args.channel_build, args.channel_request_sha256, args.r2_base_url, args.repo)
resolved = ChannelReader(args.r2_base_url, repository=args.repo).resolve(request["channel"])
if resolved.manifest is None or resolved.manifest["request"] != request:
raise ValueError("Channel head no longer names this build; refusing stale download summary")
with open(args.summary_out, "a", encoding="utf-8") as stream:
stream.write(render_channel_summary(resolved.manifest))
return 0
candidate = None
if args.candidate_manifest_sha256 is not None or args.candidate_commit is not None:
if (args.candidate_manifest_sha256 is None or args.candidate_commit is None
or not args.tag or not args.r2_base_url or args.summary_commit or args.pending_run_url):
parser.error("Candidate rendering requires tag, base URL, manifest SHA256 and commit; no summary or pending mode")
candidate = stable.read_admitted_candidate(args.tag, args.candidate_commit, args.r2_base_url,
args.candidate_manifest_sha256)
smoke_results = candidate["smoke_results"]
if args.summary_commit:
if not args.summary_out:
print("::error::--summary-out is required with --summary-commit")
return 1
if not args.r2_base_url:
print("::error::--r2-base-url (or CLOUDFLARE_R2_PUBLIC_URL) is required to render the summary")
return 1
commit = args.summary_commit
try:
from scripts.releases.bundle_env import decode
bundle_env = decode(args.bundle_env_json)
prefix = r2.commit_prefix_for(commit)
except ValueError as err:
print(f"::error::{err}")
return 1
names = r2_object_names_under(prefix)
receipts = read_commit_receipts(commit)
failed_legs = (failed_legs_from_release_needs(os.environ.get("RELEASE_NEEDS"))
or [leg.strip() for leg in args.summary_failed_legs.split(",") if leg.strip()])
block = render_commit_summary(names, args.r2_base_url, commit, receipts, failed_legs, args.run_url,
smoke_results=smoke_results)
with open(args.summary_out, "a", encoding="utf-8") as out:
out.write(block)
write_page(r2.commit_page_key_for(commit),
render_commit_page(commit, names, args.r2_base_url, receipts, failed_legs, args.run_url,
repo=args.repo, bundle_env=bundle_env, smoke_results=smoke_results),
args.r2_base_url)
built = sum(1 for row in commit_expected_rows(names, receipts) if row["state"] == "built")
print(f"✓ Commit summary appended to {args.summary_out} ({built}/{len(_COMMIT_EXPECTED)} binaries built)")
return 0
if not args.tag:
parser.error("--tag is required (or use --summary-commit for a commit build summary)")
incomplete: list[str] = []
assets: dict = {}
if args.pending_run_url:
block = render_pending(args.pending_run_url)
names: list[str] = []
else:
if not args.r2_base_url:
print("::error::--r2-base-url (or CLOUDFLARE_R2_PUBLIC_URL) is required to render the tables")
return 1
names = r2_object_names(args.tag)
if candidate is not None:
admitted = {r2.staging_key_for(args.tag, item["path"]) for item in candidate["files"]}
names = [name for name in names if name in admitted]
assets = parse_assets(names)
incomplete = [] if candidate is not None else incomplete_release_jobs(os.environ.get("RELEASE_NEEDS"))
block = render_tables(assets, args.r2_base_url, incomplete, args.run_url, smoke_results=smoke_results)
# A failed run still owns its tag page, never the channel pointer
# consumed by source updates. Missing artifacts never become downloads.
if not args.dry_run:
write_page(r2.staging_key_for(args.tag, "index.html"),
render_page(args.tag, assets, args.r2_base_url, incomplete, args.run_url,
repo=args.repo, smoke_results=smoke_results), args.r2_base_url)
# Keep the per-tag diagnostic page even when GitHub cannot supply a draft.
view = subprocess.run(
["gh", "release", "view", args.tag, "--repo", args.repo,
"--json", "body"],
capture_output=True, text=True, encoding="utf-8", errors="replace",
)
if view.returncode != 0:
print(f"::error::gh release view failed: {view.stderr.strip()}")
return 1
release = json.loads(view.stdout)
body = release.get("body") or ""
if not args.pending_run_url and not args.dry_run and not incomplete and names:
write_channel_page(args.tag, assets, args.r2_base_url, repo=args.repo, smoke_results=smoke_results)
if MARKER not in body:
print("::warning::release body has no HERMES_BUILDS_TABLE marker; leaving it unchanged")
return 0
new_body = splice(body, block)
if args.dry_run:
print(new_body)
return 0
edit = subprocess.run(
["gh", "release", "edit", args.tag, "--repo", args.repo,
"--notes-file", "-"],
input=new_body, capture_output=True, text=True, encoding="utf-8",
errors="replace",
)
if edit.returncode != 0:
print(f"::error::gh release edit failed: {edit.stderr.strip()}")
return 1
what = "Builds-in-progress link" if args.pending_run_url else "Builds table"
print(f"✓ {what} rendered into {args.tag} ({len(names)} assets scanned)")
return 0
if __name__ == "__main__":
sys.exit(main())