#!/usr/bin/env -S bash -c 'exec "$BASH" "$(dirname "$0")/_hermes-python" "$0" "$@"' """Render the release download tables into , 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//), 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//, 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//index.html every admitted tag, including failed builds releases//index.html latest successful stable or canary build; failed prerequisites leave it unchanged releases/commit//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, versioning # noqa: E402 MARKER = "" END_MARKER = "" 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"^(?PHermesBundled|HermesLight)-(?P[^-]+)" r"-(?Pmac|win|linux)-(?Px64|arm64)\.(?Pdmg|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//; 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// 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-x64", "win32-arm64": "build-win32-arm64", "darwin-x64": "build-darwin-x64", "darwin-arm64": "build-darwin-arm64", "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 ["

Native install/chat smoke

", f"

{html.escape(_SMOKE_SCOPE)}

", *_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' str: return ( '\n\n\n\n' '\n' f'\n' f"{html.escape(title)}\n\n\n\n" + "\n".join(body) + "\n\n\n" ) def _table(headers: tuple[str, ...], rows: list[list[str]]) -> list[str]: head = "".join(f"{html.escape(name)}" for name in headers) body = ["" + "".join(f"{cell}" for cell in row) + "" for row in rows] return [f"\n{head}\n", *body, "\n
"] def _link(url: str) -> str: return f'' 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"

Hermes Desktop {channel} builds

", f"

Release {_link(tag_url)}{html.escape(tag)}. Only objects this release " "actually staged in the bucket are listed.

", ] attempt = versioning.parse_attempt_ref(tag) if attempt is not None: version = attempt[0] body.append("

" + html.escape(f"Attempt builds are not upgrade-safe: every attempt of {version} has the same " f"package version, so an installed attempt is not replaced by the published {version}.") + "

") if incomplete_jobs: body.append("

Build incomplete. Jobs not successful: " + html.escape(", ".join(incomplete_jobs)) + ". The channel page was not advanced.

") sections = table_rows(assets_by_app) if not sections: body.append("

No downloadable artifacts were staged for this build.

") for title, rows in sections: body.append(f"

{html.escape(title)}

") 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)}"] for os_name, arch, kind, name in rows], )) if incomplete_jobs and run_url: body.append("

Build diagnostics

") body.extend(_table( ("Job", "Diagnostics"), [[html.escape(job), f"{_link(run_url)}View build run"] 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)}" if url and link_text else "—") rows.append([html.escape(label), html.escape(status), cell]) body = [ f"

Hermes commit build {html.escape(commit[:12])}

", f"

Commit {_link(commit_url)}{html.escape(commit)}. Every expected binary is listed; " "built rows link to downloads; incomplete rows link to the build run when available.

", *_table(("Binary", "Status", "Download / diagnostics"), rows), ] if bundle_env: from scripts.releases.bundle_env import validate body.extend([ "

Bundle environment

", "

Explicit non-secret desktop bundle overrides only. Runtime values override defaults; " "Unset always removes the variable. String values use JSON notation.

", *_table(("Variable", "Value / action"), [ [f"{html.escape(key)}", "Unset" if value is None else f"{html.escape(json.dumps(value, ensure_ascii=False))}"] 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//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//. A tag prefix and exact version match exclude neighboring releases. An attempt ref filters by its plain version, which is what file names carry. """ from scripts.releases.versioning import parse_attempt_ref keys = r2_object_names_under(f"releases/tag/{tag}/") parsed = parse_attempt_ref(tag) return filter_names_for_version(keys, parsed[0] if parsed else 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("--archive", default=None, help="Attempt ref of the release archive when --tag is the plain payload tag") 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// 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") # The archive ref (attempt ref for stable attempts) keys every object the # page lists and writes; the payload tag names the GitHub release body. archive = args.archive or args.tag 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, archive=archive) 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(archive) if candidate is not None: admitted = {r2.staging_key_for(archive, 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(archive, "index.html"), render_page(archive, 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())