Files
hermes-agent/tests/scripts/test_render_builds_table.py
ethernet d233b6d7a9 feat(release): publish downloads pages to the release bucket
The builds table only ever existed inside a GitHub release body. Emit the
same rows as a tiny standalone page in R2, so a build is readable straight
from the download origin:

  releases/<channel>/index.html     latest stable / canary builds, every
                                    variant, replaced by each tag run
  releases/commit/<sha>/index.html  every expected binary of one commit
                                    build, built or not

scripts/render-builds-table.py keeps ONE row set per mode and renders it
into two sinks (release body markdown, page HTML), so the page can never
list different artifacts than the release. A channel page is a mutable
pointer written from a per-tag job, so it records its release tag and the
writer compares that against scripts/releases/semver.py before replacing:
re-running an older tag cannot regress a newer channel page.

Pages need two registrations to be usable: `.html` maps to
text/html; charset=utf-8 in release-content-types.json (unregistered, R2
serves the object as an octet-stream download) and to no-store in
r2.cache_control_for (the page is a pointer, not an artifact). Page keys
and public URLs come from new r2 layout helpers, shared with
`release.py --build-commit`, which now prints the commit page URL before
dispatching. No workflow change: the existing renderer jobs already carry
the R2 credentials.

Verified: 76 tests over the renderer/release/transport files, including a
loopback R2 PUT proving the page object lands as text/html with no-store.
2026-09-10 11:15:36 -04:00

265 lines
14 KiB
Python

"""render-builds-table.py: tables from REAL bucket object names, spliced idempotently.
The contract: table rows exist only for objects that are actually in the
R2 bucket for the tag's exact version (missing artifact = missing row,
never a dead link), links point at the R2 public URL, msixbundle / zip /
feed manifests stay out, and re-rendering replaces the previous block
instead of stacking a second one.
Adapted from the restack suite for this branch's artifact shapes: the
Windows per-arch artifact here is .msix (the msixbundle folds both arches
and stays out of the tables), not the NSIS .exe.
"""
import importlib.util
import json
import re
import subprocess
import sys
from pathlib import Path
_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "render-builds-table.py"
_SPEC = importlib.util.spec_from_file_location("render_builds_table", _SCRIPT)
assert _SPEC and _SPEC.loader
rbt = importlib.util.module_from_spec(_SPEC)
_SPEC.loader.exec_module(rbt)
ASSETS = [
"releases/tag/v0.28.0/HermesBundled-0.28.0-mac-arm64.dmg",
"releases/tag/v0.28.0/HermesBundled-0.28.0-mac-arm64.zip", # updater delta target — no row
"releases/tag/v0.28.0/HermesBundled-0.28.0-win-x64.msix",
"releases/tag/v0.28.0/HermesBundled-0.28.0-win-arm64.msix",
"releases/tag/v0.28.0/HermesBundled-0.28.0-win.msixbundle", # store/sideload channel — no row
"releases/tag/v0.28.0/HermesBundled-0.28.0-linux-x64.AppImage",
"releases/tag/v0.28.0/HermesLight-0.28.0-win-x64.msix",
"latest.yml", # feed manifest — no row
"light.yml",
"releases/tag/v0.28.0/HermesBundled-0.28.0-win-x64.msix.blockmap", # no row
]
BASE_URL = "https://releases.example.com"
class TestParseAssets:
def test_only_table_shaped_assets_parse(self):
parsed = rbt.parse_assets(ASSETS)
assert ("mac", "arm64") in parsed["HermesBundled"]
assert ("win", "x64") in parsed["HermesBundled"]
assert ("win", "arm64") in parsed["HermesBundled"]
assert ("linux", "x64") in parsed["HermesBundled"]
assert len(parsed["HermesBundled"]) == 4 # zip/msixbundle/blockmap/yml excluded
assert parsed["HermesLight"] == {("win", "x64"): ("releases/tag/v0.28.0/HermesLight-0.28.0-win-x64.msix", "msix")}
def test_canary_versions_parse(self):
parsed = rbt.parse_assets(["releases/tag/v0.28.0-canary.20260818/HermesBundled-0.28.0-canary.20260818-win-x64.msix"])
assert ("win", "x64") in parsed["HermesBundled"]
def test_flat_names_still_parse(self):
# Names without the releases/tag/ prefix (e.g. from a plain list) are
# handled too — the shape match runs on the basename either way.
parsed = rbt.parse_assets(["HermesBundled-0.28.0-win-x64.msix"])
assert parsed["HermesBundled"][("win", "x64")] == ("HermesBundled-0.28.0-win-x64.msix", "msix")
class TestFilterNamesForVersion:
def test_stable_tag_does_not_match_canary_objects(self):
# '0.28.0' is a prefix of '0.28.0-canary...' — the filter must be
# exact, or a stable table would list canary binaries.
names = [
"releases/tag/v0.28.0/HermesBundled-0.28.0-mac-arm64.dmg",
"releases/tag/v0.28.0-canary.20260818/HermesBundled-0.28.0-canary.20260818-win-x64.msix",
"releases/tag/v0.29.0/HermesBundled-0.29.0-win-x64.msix",
"latest.yml",
]
assert rbt.filter_names_for_version(names, "0.28.0") == [
"releases/tag/v0.28.0/HermesBundled-0.28.0-mac-arm64.dmg",
]
def test_canary_tag_matches_its_objects(self):
names = [
"releases/tag/v0.28.0-canary.20260818/HermesBundled-0.28.0-canary.20260818-win-x64.msix",
"releases/tag/v0.28.0-canary.20260818/HermesBundled-0.28.0-canary.20260818-win-x64.msix.blockmap",
"releases/tag/v0.28.0-canary.20260817/HermesBundled-0.28.0-canary.20260817-win-arm64.msix",
"releases/tag/v0.28.0/HermesBundled-0.28.0-mac-arm64.dmg",
]
assert rbt.filter_names_for_version(names, "0.28.0-canary.20260818") == [
"releases/tag/v0.28.0-canary.20260818/HermesBundled-0.28.0-canary.20260818-win-x64.msix",
]
class TestRenderAndSplice:
def test_rows_only_for_present_assets(self):
block = rbt.render_tables(rbt.parse_assets(ASSETS), BASE_URL)
assert f"{BASE_URL}/releases/tag/v0.28.0/HermesBundled-0.28.0-mac-arm64.dmg" in block
assert "HermesBundled-0.28.0-win.msixbundle" not in block
assert ".zip" not in block
assert ".blockmap" not in block
# A leg that never uploaded leaves no row at all.
assert "linux-arm64" not in block
def test_links_point_at_the_r2_base_url(self):
block = rbt.render_tables(rbt.parse_assets(ASSETS), BASE_URL)
assert "github.com" not in block
assert f"{BASE_URL}/releases/tag/v0.28.0/HermesLight-0.28.0-win-x64.msix" in block
def test_base_url_trailing_slash_is_stripped(self):
block = rbt.render_tables(rbt.parse_assets(ASSETS), f"{BASE_URL}/")
assert f"{BASE_URL}/releases/tag/v0.28.0/HermesBundled-0.28.0-mac-arm64.dmg" in block
assert f"{BASE_URL}//releases" not in block
def test_splice_replaces_marker_and_is_idempotent(self):
body = f"# Notes\n\n{rbt.MARKER}\n\n## Changes"
block = rbt.render_tables(rbt.parse_assets(ASSETS), BASE_URL)
once = rbt.splice(body, block)
assert "## Downloads" in once
assert once.count(rbt.MARKER) == 1
# Second render (e.g. a re-run with more assets) replaces, not stacks.
twice = rbt.splice(once, block)
assert twice.count("## Downloads") == 1
assert twice.count(rbt.END_MARKER) == 1
def test_no_marker_leaves_body_alone(self):
block = rbt.render_tables(rbt.parse_assets(ASSETS), BASE_URL)
assert rbt.splice("no marker here", block) == "no marker here"
class TestPendingPlaceholder:
def test_final_render_replaces_the_pending_link(self):
# The lifecycle: marker → pending link (builds-pending job) →
# tables (builds-table job). The link must not survive step 3.
body = f"# Notes\n\n{rbt.MARKER}\n\n## Changes"
pending = rbt.render_pending("https://github.com/o/r/actions/runs/123")
with_pending = rbt.splice(body, pending)
assert "actions/runs/123" in with_pending
assert with_pending.count(rbt.MARKER) == 1 # wrapper survives for step 3
tables = rbt.render_tables(rbt.parse_assets(ASSETS), BASE_URL)
final = rbt.splice(with_pending, tables)
assert "actions/runs/123" not in final
assert "## Downloads" in final
assert final.count(rbt.END_MARKER) == 1
def test_pending_rerender_replaces_not_stacks(self):
once = rbt.splice(rbt.MARKER, rbt.render_pending("https://x/runs/1"))
twice = rbt.splice(once, rbt.render_pending("https://x/runs/2"))
assert "https://x/runs/1" not in twice
assert twice.count("https://x/runs/2") == 1
assert twice.count(rbt.END_MARKER) == 1
class TestBucketPage:
"""The page is the same row set as the release-body table.
Every download link in the markdown table exists in the page, and the
artifacts the table deliberately hides (zip/msixbundle/blockmap) stay
out of the page too — a page that outlives the release body must not
advertise a delta target as a download.
"""
def test_page_carries_exactly_the_table_rows(self):
block = rbt.render_tables(rbt.parse_assets(ASSETS), BASE_URL)
page = rbt.render_page("v0.28.0", rbt.parse_assets(ASSETS), BASE_URL)
links = re.findall(r"\]\((https?://[^)]+)\)", block)
assert links # the table is non-empty, so the comparison means something
for url in links:
assert f'href="{url}"' in page
# One row per table row, plus one header row per section.
assert page.count("<tr>") == len(links) + page.count("<table>")
assert ".zip" not in page and ".blockmap" not in page and ".msixbundle" not in page
# A leg that never uploaded has no row in either sink, while a leg
# that did (linux-x64) is listed.
assert BASE_URL + "/releases/tag/v0.28.0/HermesBundled-0.28.0-linux-x64.AppImage" in page
assert "linux-arm64" not in page
def test_page_names_the_build_it_describes(self):
page = rbt.render_page("v0.28.0", rbt.parse_assets(ASSETS), BASE_URL)
assert rbt.recorded_build(page) == "v0.28.0"
canary = rbt.render_page("v0.28.0-canary.20260818101010", rbt.parse_assets(ASSETS), BASE_URL)
assert rbt.recorded_build(canary) == "v0.28.0-canary.20260818101010"
assert "canary" in canary
def test_older_tag_never_regresses_the_channel_page(self):
"""A re-run of an old tag must not overwrite the page a newer release
published; the recorded tag is compared with the feed's own authority."""
current = rbt.render_page("v0.29.0", rbt.parse_assets(ASSETS), BASE_URL)
assert not rbt.supersedes(current, "v0.28.0")
assert not rbt.supersedes(current, "v0.28.0-canary.20260818101010")
assert rbt.supersedes(current, "v0.29.0") # same tag re-run rewrites
assert rbt.supersedes(current, "v0.30.0")
newer_canary = rbt.render_page("v0.29.0-canary.20260901090000", rbt.parse_assets(ASSETS), BASE_URL)
assert not rbt.supersedes(newer_canary, "v0.29.0-canary.20260801090000")
assert rbt.supersedes(newer_canary, "v0.29.0-canary.20260901090001")
# Nothing published yet, or an unreadable record: the new page wins.
assert rbt.supersedes(None, "v0.28.0")
assert rbt.supersedes("<html>garbage</html>", "v0.28.0")
def test_channel_page_key_follows_the_tag(self):
assert rbt.r2.channel_page_key_for(rbt.r2.channel_for_tag("v0.28.0")) == "releases/stable/index.html"
assert rbt.r2.channel_page_key_for(
rbt.r2.channel_for_tag("v0.28.0-canary.20260818101010")) == "releases/canary/index.html"
class TestTagRunPublishesThePage:
"""The real CLI path: the page is uploaded for the tag's own channel."""
TAG = "v0.28.0-canary.20260818101010"
KEYS = [
f"releases/tag/{TAG}/HermesBundled-0.28.0-canary.20260818101010-win-x64.msix",
f"releases/tag/{TAG}/HermesBundled-0.28.0-canary.20260818101010-mac-arm64.dmg",
f"releases/tag/{TAG}/HermesBundled-0.28.0-canary.20260818101010-win.msixbundle",
f"releases/tag/{TAG}/HermesLight-0.28.0-canary.20260818101010-win-x64.msix",
"releases/tag/v0.27.0/HermesBundled-0.27.0-win-x64.msix", # neighbor release
]
@staticmethod
def _gh(argv, **kwargs):
if argv[:3] == ["gh", "release", "view"]:
body = f"# Notes\n\n{rbt.MARKER}\n\n## Changes\n- x\n"
return subprocess.CompletedProcess(argv, 0, stdout=json.dumps({"body": body}), stderr="")
if argv[:3] == ["gh", "release", "edit"]:
return subprocess.CompletedProcess(argv, 0, stdout="", stderr="")
raise AssertionError(argv)
def test_page_upload_key_and_bytes(self, monkeypatch, capsys, tmp_path):
uploads: list[tuple[str, str, bool]] = []
monkeypatch.setattr(rbt.r2, "list_objects", lambda prefix="": {"keys": self.KEYS})
monkeypatch.setattr(rbt, "existing_page", lambda key: None)
monkeypatch.setattr(rbt.r2, "put", lambda tag, key, file, key_is_full=False, immutable=False:
uploads.append((key, Path(file).read_text(encoding="utf-8"), key_is_full)))
monkeypatch.setattr(rbt.subprocess, "run", self._gh)
monkeypatch.setattr(sys, "argv", [
"render-builds-table.py", "--tag", self.TAG, "--repo", "o/r", "--r2-base-url", BASE_URL,
])
assert rbt.main() == 0
assert len(uploads) == 1
key, page, key_is_full = uploads[0]
# Canary tag → the canary channel page, as a full key (not a tag name).
assert key == "releases/canary/index.html" and key_is_full
assert rbt.recorded_build(page) == self.TAG
for name in ("HermesBundled-0.28.0-canary.20260818101010-win-x64.msix",
"HermesBundled-0.28.0-canary.20260818101010-mac-arm64.dmg",
"HermesLight-0.28.0-canary.20260818101010-win-x64.msix"):
assert f"{BASE_URL}/releases/tag/{self.TAG}/{name}" in page
# The neighbor release and the hidden artifact shapes stay out.
assert "v0.27.0" not in page and ".msixbundle" not in page
assert f"✓ Page {BASE_URL}/releases/canary/index.html" in capsys.readouterr().out
def test_dry_run_and_stale_tags_write_nothing(self, monkeypatch, tmp_path):
uploads: list[str] = []
monkeypatch.setattr(rbt.r2, "list_objects", lambda prefix="": {"keys": self.KEYS})
monkeypatch.setattr(rbt.r2, "put", lambda **kwargs: uploads.append(kwargs["key"]))
monkeypatch.setattr(rbt.subprocess, "run", self._gh)
stale = rbt.render_page("v0.29.0", rbt.parse_assets(ASSETS), BASE_URL)
monkeypatch.setattr(rbt, "existing_page", lambda key: stale)
monkeypatch.setattr(sys, "argv", [
"render-builds-table.py", "--tag", self.TAG, "--repo", "o/r", "--r2-base-url", BASE_URL,
])
assert rbt.main() == 0 # stale tag: page untouched
monkeypatch.setattr(rbt, "existing_page", lambda key: None)
monkeypatch.setattr(sys, "argv", [
"render-builds-table.py", "--tag", self.TAG, "--repo", "o/r",
"--r2-base-url", BASE_URL, "--dry-run",
])
assert rbt.main() == 0 # dry run: nothing published
assert uploads == []