Files
hermes-agent/scripts/render-builds-table.py
ethernet b0ab0162b0 feat(release): gate stable promotion through the full release pipeline
Run the entire CI workflow before Docker build and tests. Require Nix,
native payload smoke tests, install/update E2E and signed-package upgrade
acceptance before publishing. Keep Desktop Playwright E2E deferred.

Archive tested Docker images and signed bundle candidates with provenance
and hashes. Publishers consume those exact artifacts without rebuilding.
Advance stable channels only after all required publications succeed.
Keep canaries on their separate path and reject direct stable-builder
publication that bypasses the gate.

Move shared release transport, manifests and gates to Python. Keep native
Electron adapters in JS and share feed/MIME facts as JSON. Replace the
R2/feed JS implementation and move its protocol tests to Python.

Verified targeted Python and JS tests, real loopback transport and CLI
execution, temporary Git admission, workflow graph lint, and typechecks.
No live stable release was run. Native signing, package upgrades and real
registry/Store promotion still need their release-run receipts. Separate
services cannot promote atomically. A promotion failure keeps the run red.
2026-09-07 14:40:10 -04:00

214 lines
8.5 KiB
Python

#!/usr/bin/env python3
"""Render the release download tables into <!-- HERMES_BUILDS_TABLE -->.
Runs as the LAST job of desktop-bundled-release.yml, after every matrix
leg has uploaded, 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).
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 json
import os
import re
import subprocess
import sys
MARKER = "<!-- HERMES_BUILDS_TABLE -->"
END_MARKER = "<!-- /HERMES_BUILDS_TABLE -->"
# 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>[^-]+(?:-canary\.\d{8}(?:\d{6})?)?)"
r"-(?P<os>mac|win|linux)-(?P<arch>x64|arm64)\.(?P<ext>dmg|msix|AppImage)$"
)
_OS_LABEL = {"mac": "macOS", "win": "Windows", "linux": "Linux"}
_ARCH_LABEL = {"x64": "x64 (Intel/AMD)", "arm64": "arm64 (Apple Silicon/ARM)"}
_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 render_tables(assets_by_app: dict, base_url: str) -> str:
"""The replacement block: marker + tables + end marker."""
base = base_url.rstrip("/")
sections = []
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
os_name, arch = key
rows.append(
f"| {_OS_LABEL[os_name]} | {_ARCH_LABEL[arch]} "
f"| [{_KIND_LABEL[ext]}]({base}/{name}) |"
)
if rows:
sections.append(
f"### {title}\n\n| OS | Architecture | Download |\n|---|---|---|\n"
+ "\n".join(rows)
)
if not sections:
return ""
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
def r2_object_names(tag: str) -> list[str]:
"""Object keys in the R2 staging dir for `tag`, under releases/tag/<tag>/.
Exact version match, never prefix: 'v0.28.0' must not pick up
'0.28.0-canary.20260818...' objects (they live under their own tag
directory, and the basename filter would reject them anyway). The list
call shells out to scripts/releases/r2.py, which reads the R2 env vars
and needs only Python (no application environment).
"""
run = subprocess.run(
[sys.executable, "-m", "scripts.releases.r2", "list", "--prefix", f"releases/tag/{tag}/"],
capture_output=True, text=True, encoding="utf-8", errors="replace",
)
if run.returncode != 0:
print(f"::error::r2 list failed: {run.stderr.strip() or run.stdout.strip()}")
raise SystemExit(1)
keys = [line.strip() for line in run.stdout.splitlines() if line.strip()]
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 main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--tag", required=True)
parser.add_argument("--repo", default="NousResearch/hermes-agent")
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")
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()
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 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)
block = render_tables(parse_assets(names), args.r2_base_url)
if not block:
print("::warning::no table-shaped assets for this tag in the bucket; leaving the body unchanged")
return 0
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())