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.
265 lines
14 KiB
Python
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 == []
|