Canary and commit builds must not replace stable or share its desktop state. Package names alone are insufficient because Electron reads the product name before main initializes its paths. Pin nonstable userData before the first lookup, and keep the packaged identity independent of runtime build variables. Keep release artifact filenames unchanged. Qualify payload CLI names, route each nonstable MSIX alias to its own entrypoint, and copy the immutable desktop provenance into the embedded Python checkout. Only stable releases can use the official Store identity. Targeted validation: 75 JavaScript tests passed, 2 platform skips; 15 Python tests passed with file retries disabled. Native Windows SDK manifest proof is tracked separately. Full app install, signing and macOS launch validation are not claimed.
271 lines
12 KiB
Python
271 lines
12 KiB
Python
"""Commit-mode summary renderer: the receipt-bound expected-binary matrix.
|
|
|
|
The commit summary is a SEPARATE sink from the release-body tables: it
|
|
never reads or edits a GitHub release, and every expected binary gets a
|
|
row — Built (receipt + object both present, with a link) or Not built —
|
|
so a skipped or interrupted leg can never be a silent omission or a
|
|
forged success.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_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)
|
|
|
|
COMMIT = "a" * 40
|
|
BASE = "https://cdn.example.com"
|
|
|
|
|
|
def _leg_for(basename: str) -> str:
|
|
"""The receipt name whose regex matches this artifact basename."""
|
|
import re
|
|
for _label, leg, pattern in rbt._COMMIT_EXPECTED:
|
|
if re.match(pattern, basename):
|
|
return leg
|
|
raise AssertionError(f"no expected row matches {basename!r}")
|
|
|
|
|
|
# Every produced binary of a fully green commit run (Termux's .deb is a
|
|
# nested deb/ path — the workflow stages it under deb/pool/).
|
|
_ALL_BASENAMES = [
|
|
"HermesBundled-0.28.0-win-x64.msix",
|
|
"HermesBundled-0.28.0-win-arm64.msix",
|
|
"HermesBundled-0.28.0-win.msixbundle",
|
|
"HermesBundled-0.28.0-mac-arm64.dmg",
|
|
"HermesBundled-0.28.0-mac-x64.dmg",
|
|
"HermesBundled-0.28.0-mac-arm64.zip",
|
|
"HermesBundled-0.28.0-mac-x64.zip",
|
|
"deb/pool/hermes-agent_0.28.0_aarch64.deb",
|
|
]
|
|
|
|
|
|
def _receipts_all_built() -> dict[str, dict | None]:
|
|
"""Validated receipts: each leg lists exactly the artifact basenames
|
|
the matrix expects from it (the real staged shape)."""
|
|
receipts: dict[str, dict | None] = {name: None for name in rbt.COMMIT_RECEIPT_NAMES}
|
|
for basename in _ALL_BASENAMES:
|
|
leg = _leg_for(basename.rsplit("/", 1)[-1])
|
|
receipt = receipts[leg] or {"schema": 2, "commit": COMMIT, "name": leg, "files": []}
|
|
receipt["files"].append({"path": basename, "size": 1, "sha256": "0" * 64})
|
|
receipts[leg] = receipt
|
|
return receipts
|
|
|
|
|
|
def _names(*basenames: str) -> list[str]:
|
|
return [f"releases/commit/{COMMIT}/{name}" for name in basenames]
|
|
|
|
|
|
def _all_built_names() -> list[str]:
|
|
return _names(*_ALL_BASENAMES)
|
|
|
|
|
|
def test_every_expected_binary_gets_a_row_built_or_not():
|
|
receipts = _receipts_all_built()
|
|
rows = rbt.commit_expected_rows(_all_built_names(), receipts)
|
|
assert len(rows) == len(rbt._COMMIT_EXPECTED)
|
|
for row in rows:
|
|
assert row["state"] == "built", row
|
|
summary = rbt.render_commit_summary(_all_built_names(), BASE, COMMIT, receipts)
|
|
assert summary.count("✅ Built") == len(rbt._COMMIT_EXPECTED)
|
|
# zip/Termux rows exist in the COMMIT summary (the release-body
|
|
# table hides zips on purpose; the commit summary shows every binary).
|
|
assert any("ZIP" in row["label"] for row in rows)
|
|
assert any("Termux" in row["label"] for row in rows)
|
|
assert not any("Store" in row["label"] for row in rows)
|
|
assert any("MSIXBUNDLE" in row["label"] for row in rows)
|
|
# The universal sideload bundle links correctly.
|
|
bundle = next(row for row in rows if "MSIXBUNDLE" in row["label"])
|
|
assert bundle["key"].endswith(".msixbundle")
|
|
|
|
|
|
def test_missing_binaries_are_explicit_rows_never_links():
|
|
receipts = _receipts_all_built()
|
|
names = _names("HermesBundled-0.28.0-win-x64.msix")
|
|
summary = rbt.render_commit_summary(names, BASE, COMMIT, receipts)
|
|
built = sum(1 for line in summary.splitlines() if "✅ Built" in line)
|
|
missing = sum(1 for line in summary.splitlines()
|
|
if "❌ Not built" in line and "release leg disabled" not in line)
|
|
assert built == 1
|
|
assert missing == len(rbt._COMMIT_EXPECTED) - built
|
|
# No link for an object that does not exist.
|
|
assert "win-arm64.msix)" not in summary
|
|
# Failed legs are blamed by name on the RECEIPT-missing rows; without
|
|
# blame the row says incomplete.
|
|
darwin_down = dict(receipts)
|
|
darwin_down["darwin-arm64"] = None
|
|
blamed = rbt.render_commit_summary(names, BASE, COMMIT, darwin_down, failed_legs=["build-darwin"])
|
|
assert "failed: build-darwin" in blamed
|
|
assert "Not built (leg incomplete or upload interrupted)" in rbt.render_commit_summary(names, BASE, COMMIT, darwin_down)
|
|
|
|
|
|
def test_object_without_completion_receipt_never_renders_built():
|
|
"""The old draft trusted raw object names: an interrupted upload that
|
|
left artifacts but no receipt looked ✅ Built. Now the receipt is the
|
|
completion marker."""
|
|
receipts = _receipts_all_built()
|
|
names = _all_built_names() # every object present...
|
|
for leg in ("win32-x64", "termux"):
|
|
receipts[leg] = None # ...two legs' receipts absent
|
|
summary = rbt.render_commit_summary(names, BASE, COMMIT, receipts)
|
|
for row in rbt.commit_expected_rows(names, receipts):
|
|
if row["leg"] in ("win32-x64", "termux"):
|
|
assert row["state"] != "built"
|
|
assert f"| {row['label']} | ✅ Built |" not in summary
|
|
else:
|
|
assert row["state"] == "built"
|
|
|
|
|
|
def test_orphan_object_with_unrelated_valid_receipt_never_renders_built():
|
|
"""An object matching a row's shape, staged under a leg whose VALID
|
|
receipt covers only unrelated files, is NOT evidence that the row's
|
|
binary was built. The old draft accepted ANY receipt object for the
|
|
leg; rows now bind to the receipt's own file paths."""
|
|
receipts = _receipts_all_built()
|
|
# win32-x64's receipt is valid but lists a DIFFERENT file.
|
|
receipts["win32-x64"] = {"schema": 2, "commit": COMMIT, "name": "win32-x64",
|
|
"files": [{"path": "metadata-windows-x64.json",
|
|
"size": 1, "sha256": "0" * 64}]}
|
|
names = _all_built_names() # the orphan win-x64 msix object exists
|
|
rows = rbt.commit_expected_rows(names, receipts)
|
|
x64 = next(row for row in rows if row["label"] == "Windows x64 (MSIX)")
|
|
assert x64["state"] != "built"
|
|
assert x64["key"] is None
|
|
summary = rbt.render_commit_summary(names, BASE, COMMIT, receipts)
|
|
assert "| Windows x64 (MSIX) | ✅ Built |" not in summary
|
|
|
|
|
|
def test_receipt_without_object_is_reported_not_built():
|
|
receipts = _receipts_all_built()
|
|
summary = rbt.render_commit_summary([], BASE, COMMIT, receipts)
|
|
assert "Not built (receipt present but object missing)" in summary
|
|
assert "✅ Built" not in summary
|
|
|
|
|
|
def test_ambiguous_or_extra_objects_never_render_a_link():
|
|
"""Two objects that BOTH match a row's shape AND are listed by the
|
|
leg's receipt are genuinely ambiguous — no link is rendered. An extra
|
|
object the receipt does NOT list cannot poison a row whose own
|
|
receipt-listed object is unique (it is foreign to the binding)."""
|
|
receipts = _receipts_all_built()
|
|
# List both versions in win32-x64's receipt (a torn staging would
|
|
# leave exactly this shape behind two shape-matching objects).
|
|
receipts["win32-x64"]["files"].append(
|
|
{"path": "HermesBundled-0.28.1-win-x64.msix", "size": 1, "sha256": "0" * 64})
|
|
names = _names(
|
|
"HermesBundled-0.28.0-win-x64.msix",
|
|
"HermesBundled-0.28.1-win-x64.msix",
|
|
)
|
|
rows = rbt.commit_expected_rows(names, receipts)
|
|
x64 = next(row for row in rows if row["label"] == "Windows x64 (MSIX)")
|
|
assert x64["state"] == "ambiguous" and x64["key"] is None
|
|
|
|
# The extra 0.28.1 object without a receipt listing is NOT ambiguous:
|
|
# the receipt's own 0.28.0 binding stays built.
|
|
plain = _receipts_all_built()
|
|
rows = rbt.commit_expected_rows(
|
|
_names("HermesBundled-0.28.0-win-x64.msix",
|
|
"HermesBundled-0.28.1-win-x64.msix"), plain)
|
|
x64 = next(row for row in rows if row["label"] == "Windows x64 (MSIX)")
|
|
assert x64["state"] == "built"
|
|
assert x64["key"].endswith("HermesBundled-0.28.0-win-x64.msix")
|
|
|
|
|
|
def test_failed_legs_from_release_needs_seam():
|
|
needs = json.dumps({
|
|
"build-win32": {"result": "success"},
|
|
"build-darwin": {"result": "failure"},
|
|
"termux-deb": {"result": "skipped"},
|
|
"build-linux": {"result": "failure"},
|
|
})
|
|
assert rbt.failed_legs_from_release_needs(needs) == ["build-darwin", "build-linux"]
|
|
assert rbt.failed_legs_from_release_needs(None) == []
|
|
assert rbt.failed_legs_from_release_needs("") == []
|
|
assert rbt.failed_legs_from_release_needs("not json") == []
|
|
|
|
|
|
def test_summary_uses_exact_nested_keys_and_lists_the_sideload_bundle():
|
|
from scripts.releases import handoff
|
|
|
|
paths = {
|
|
"windows-universal": [
|
|
"HermesBundled-0.28.0.0-win.msixbundle",
|
|
],
|
|
"termux": ["deb/pool build/hermes-agent_0.28.0_aarch64.deb"],
|
|
}
|
|
receipts = {
|
|
leg: {"schema": 2, "commit": COMMIT, "name": leg,
|
|
"files": [{"path": path, "size": 1, "sha256": "0" * 64} for path in files]}
|
|
for leg, files in paths.items()
|
|
}
|
|
for leg, receipt in receipts.items():
|
|
handoff.validate_commit_receipt(receipt, COMMIT, leg)
|
|
names = _names(*(path for files in paths.values() for path in files))
|
|
summary = rbt.render_commit_summary(names, BASE + "/downloads", COMMIT, receipts)
|
|
from urllib.parse import quote
|
|
|
|
for key in names:
|
|
assert f"]({BASE}/downloads/{quote(key, safe='/')})" in summary
|
|
built = [line for line in summary.splitlines() if "✅ Built" in line]
|
|
assert len(built) == len(names)
|
|
assert not any("Store" in line for line in built)
|
|
assert "Linux x64" in summary and "Linux ARM64" in summary
|
|
|
|
|
|
def test_commit_page_matches_the_summary_rows():
|
|
"""Two sinks, ONE row set: every link and every status in the step
|
|
summary appears on the page, and neither sink drops a missing binary."""
|
|
receipts = _receipts_all_built()
|
|
names = _all_built_names()
|
|
summary = rbt.render_commit_summary(names, BASE, COMMIT, receipts)
|
|
page = rbt.render_commit_page(COMMIT, names, BASE, receipts)
|
|
links = re.findall(r"\]\((https?://[^)]+)\)", summary)
|
|
assert len(links) == len(rbt._COMMIT_EXPECTED)
|
|
for url in links:
|
|
assert f'href="{url}"' in page
|
|
assert page.count("✅ Built") == summary.count("✅ Built") == len(rbt._COMMIT_EXPECTED)
|
|
assert page.count("❌ Not built") == summary.count("❌ Not built") == len(rbt._COMMIT_DISABLED)
|
|
assert rbt.recorded_build(page) == COMMIT
|
|
|
|
|
|
def test_commit_page_lists_a_missing_binary_without_a_link():
|
|
receipts = _receipts_all_built()
|
|
receipts["darwin-arm64"] = None
|
|
names = _names("HermesBundled-0.28.0-win-x64.msix")
|
|
page = rbt.render_commit_page(COMMIT, names, BASE, receipts, failed_legs=["build-darwin"])
|
|
assert page.count("<tr>") == len(rbt._COMMIT_EXPECTED) + len(rbt._COMMIT_DISABLED) + 1
|
|
assert "failed: build-darwin" in page
|
|
# Only the one staged object was built, so it is the only anchor.
|
|
assert page.count("<a href=") == 1
|
|
|
|
|
|
def test_commit_run_publishes_the_commit_page(tmp_path, monkeypatch, capsys):
|
|
"""The real CLI path: commit mode writes the summary AND the page."""
|
|
summary = tmp_path / "summary.md"
|
|
uploads: list[tuple[str, str, bool]] = []
|
|
receipts = _receipts_all_built()
|
|
monkeypatch.setattr(rbt.r2, "list_objects", lambda prefix="": {"keys": _all_built_names()})
|
|
monkeypatch.setattr(rbt.handoff, "read_commit_receipt", lambda commit, name: receipts[name])
|
|
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(sys, "argv", ["render-builds-table.py", "--summary-commit", COMMIT,
|
|
"--summary-out", str(summary), "--r2-base-url", BASE])
|
|
assert rbt.main() == 0
|
|
assert len(uploads) == 1
|
|
key, page, key_is_full = uploads[0]
|
|
assert key == f"releases/commit/{COMMIT}/index.html" and key_is_full
|
|
assert f"✓ Page {BASE}/releases/commit/{COMMIT}/index.html" in capsys.readouterr().out
|
|
written = summary.read_text(encoding="utf-8")
|
|
for url in re.findall(r"\]\((https?://[^)]+)\)", written):
|
|
assert f'href="{url}"' in page
|
|
assert written.count("✅ Built") == page.count("✅ Built") == len(rbt._COMMIT_EXPECTED)
|