Commit builds use their own immutable namespace and the shared signed transport. Publish each receipt after its files, and fetch shared files once only when their receipt records agree. Summary links retain the full object key. A completed row requires its own validated receipt and listed object. Missing, corrupt and ambiguous results remain distinct. Include both universal Windows bundles. Verified: 85 tests passed across transfer, rendering, candidate and promotion paths. The subprocess test fetches the rendered download URL from loopback HTTP. Ruff and added-comment checks passed. No live R2 writes, workflow dispatch or native package build ran. The commit-build CLI and workflow changes remain separate drafts.
329 lines
16 KiB
Python
329 lines
16 KiB
Python
"""Commit-only handoff receipts (schema 2) bind files to a bare commit."""
|
|
import copy
|
|
import json
|
|
import os
|
|
import unittest.mock as mock
|
|
|
|
import pytest
|
|
|
|
from scripts.releases import r2
|
|
from tests.scripts.test_release_r2 import r2_server # noqa: F401
|
|
|
|
|
|
COMMIT = "b" * 40
|
|
|
|
|
|
def test_commit_key_namespace_never_overlaps_tag_or_channel_dirs():
|
|
assert r2.commit_key_for(COMMIT, "app.msix") == f"releases/commit/{COMMIT}/app.msix"
|
|
assert r2.commit_prefix_for(COMMIT) == f"releases/commit/{COMMIT}/"
|
|
for bad in ("abc", COMMIT[:39], "g" * 40, "", None, " " * 40):
|
|
with pytest.raises(ValueError):
|
|
r2.commit_key_for(bad, "app.msix")
|
|
# Traversal / absolute / backslash paths stay forbidden...
|
|
for bad_name in ("../outside", "a/../../b", "/abs.msix", "C:\\x.msix", "",
|
|
"a//b", "./a", "C:/b", "a%2fb", "a?b", "a#b", "NUL", "a/../b"):
|
|
with pytest.raises(ValueError):
|
|
r2.commit_key_for(COMMIT, bad_name)
|
|
# ...but REAL nested artifact paths (the Termux receipt shape) fetch.
|
|
assert (r2.commit_key_for(COMMIT, "deb/pool/hermes_0.28.0_aarch64.deb")
|
|
== f"releases/commit/{COMMIT}/deb/pool/hermes_0.28.0_aarch64.deb")
|
|
key = r2.commit_key_for(COMMIT, "app.msix")
|
|
assert not key.startswith("releases/tag/")
|
|
assert not key.startswith(("releases/win32/", "releases/darwin/", "releases/termux/"))
|
|
|
|
|
|
def test_commit_stage_fetch_roundtrip_is_receipt_last_and_tag_free(tmp_path, r2_server):
|
|
from scripts.releases import handoff
|
|
|
|
root = tmp_path / "built"
|
|
root.mkdir()
|
|
data = b"commit build transport fixture\n" * 50000
|
|
(root / "HermesBundled-0.28.0-win-x64.msix").write_bytes(data)
|
|
# No --commit flag: the commit-build identity comes ONLY from
|
|
# --commit-build. --commit never defaults from ambient $GITHUB_SHA.
|
|
handoff.main(["stage", "--commit-build", COMMIT, "--name", "win32-x64",
|
|
"--root", str(root), "--include", "*.msix"])
|
|
receipt_key = f"releases/commit/{COMMIT}/handoff-win32-x64.json"
|
|
receipt = json.loads(r2_server.store[receipt_key][0])
|
|
assert receipt["schema"] == 2 and receipt["commit"] == COMMIT
|
|
assert "tag" not in receipt
|
|
assert receipt["files"][0]["sha256"] == r2.file_sha256(root / "HermesBundled-0.28.0-win-x64.msix")
|
|
assert set(r2_server.store) <= {f"releases/commit/{COMMIT}/{name}" for name in
|
|
("HermesBundled-0.28.0-win-x64.msix", "handoff-win32-x64.json")}
|
|
puts = [path for method, path, _ in r2_server.requests if method == "PUT"]
|
|
assert puts[-1].endswith(receipt_key), "receipt must be the completion marker"
|
|
assert all(headers.get("If-None-Match") == "*"
|
|
for method, _, headers in r2_server.requests if method == "PUT")
|
|
|
|
downloaded = tmp_path / "downloaded"
|
|
handoff.main(["fetch", "--commit-build", COMMIT,
|
|
"--name", "win32-x64", "--root", str(downloaded)])
|
|
assert (downloaded / "HermesBundled-0.28.0-win-x64.msix").read_bytes() == data
|
|
|
|
# Same-SHA retry with identical bytes succeeds (verified-conflict path).
|
|
handoff.stage_commit_build(COMMIT, "win32-x64", root, ["*.msix"])
|
|
# Conflicting different bytes must fail, never delete the existing object.
|
|
(root / "HermesBundled-0.28.0-win-x64.msix").write_bytes(b"different")
|
|
with pytest.raises(Exception):
|
|
handoff.stage_commit_build(COMMIT, "win32-x64", root, ["*.msix"])
|
|
assert r2_server.store[f"releases/commit/{COMMIT}/HermesBundled-0.28.0-win-x64.msix"][0] == data
|
|
|
|
|
|
def test_commit_stage_fetch_nested_paths_roundtrip(tmp_path, r2_server):
|
|
"""The Termux leg stages deb/*.deb (nested receipt paths). The fetch
|
|
must rebuild the same nested layout — the old commit_key_for forbade
|
|
any slash, so the workflow's own stage shape was unfetchable."""
|
|
from scripts.releases import handoff
|
|
|
|
root = tmp_path / "termux-build"
|
|
(root / "deb" / "pool").mkdir(parents=True)
|
|
deb = root / "deb" / "pool" / "hermes_0.28.0_aarch64.deb"
|
|
deb.write_bytes(b"!<arch>\ndeb-bytes")
|
|
handoff.main(["stage", "--commit-build", COMMIT, "--name", "termux",
|
|
"--root", str(root), "--include", "deb/**/*.deb"])
|
|
receipt = json.loads(r2_server.store[f"releases/commit/{COMMIT}/handoff-termux.json"][0])
|
|
assert receipt["files"][0]["path"] == "deb/pool/hermes_0.28.0_aarch64.deb"
|
|
|
|
downloaded = tmp_path / "out"
|
|
handoff.main(["fetch", "--commit-build", COMMIT, "--name", "termux", "--root", str(downloaded)])
|
|
assert (downloaded / "deb" / "pool" / "hermes_0.28.0_aarch64.deb").read_bytes() == b"!<arch>\ndeb-bytes"
|
|
|
|
|
|
def test_commit_cli_rejects_conflicting_commit_value(tmp_path, r2_server):
|
|
"""--commit is a SEPARATE explicit flag: it never defaults from ambient
|
|
$GITHUB_SHA (a feature-branch run must not inherit a conflicting SHA
|
|
that would parser.error against --commit-build), and when passed it
|
|
must EQUAL --commit-build."""
|
|
from scripts.releases import handoff
|
|
|
|
root = tmp_path / "built"
|
|
root.mkdir()
|
|
(root / "a.msix").write_bytes(b"x")
|
|
# Disagreeing explicit flags are a hard CLI error, never silently
|
|
# resolved (the old draft accepted and dropped the override).
|
|
with pytest.raises(SystemExit):
|
|
handoff.main(["stage", "--commit-build", COMMIT, "--commit", "c" * 40,
|
|
"--name", "win32-x64", "--root", str(root), "--include", "*.msix"])
|
|
# Without --commit, --commit-build alone stages fine even though the
|
|
# inherited environment carries an unrelated GITHUB_SHA (the old draft
|
|
# would parser.error here — every commit builder exports GITHUB_SHA).
|
|
with mock.patch.dict(os.environ, {"GITHUB_SHA": "c" * 40}):
|
|
handoff.main(["stage", "--commit-build", COMMIT,
|
|
"--name", "win32-x64", "--root", str(root), "--include", "*.msix"])
|
|
# Equal values are accepted (they name the same commit).
|
|
handoff.main(["stage", "--commit-build", COMMIT, "--commit", COMMIT,
|
|
"--name", "win32-x64", "--root", str(root), "--include", "*.msix"])
|
|
|
|
|
|
def test_commit_and_tag_namespaces_stay_isolated(tmp_path, r2_server):
|
|
from scripts.releases import handoff
|
|
|
|
# A tag staging and a commit staging of the same artifact name must
|
|
# land in disjoint prefixes — neither can read or overwrite the other.
|
|
root = tmp_path / "built"
|
|
root.mkdir()
|
|
(root / "a.msix").write_bytes(b"commit bytes")
|
|
handoff.stage_commit_build(COMMIT, "win32-x64", root, ["*.msix"])
|
|
tag_root = tmp_path / "tagged"
|
|
tag_root.mkdir()
|
|
(tag_root / "a.msix").write_bytes(b"tag bytes")
|
|
handoff.stage("v0.28.0", COMMIT, "win32-x64", tag_root, ["*.msix"])
|
|
assert r2_server.store[f"releases/commit/{COMMIT}/a.msix"][0] == b"commit bytes"
|
|
assert r2_server.store["releases/tag/v0.28.0/a.msix"][0] == b"tag bytes"
|
|
# The schema-1 tag receipt still carries tag+commit; the schema-2
|
|
# commit receipt has no tag anywhere.
|
|
tag_receipt = json.loads(r2_server.store["releases/tag/v0.28.0/handoff-win32-x64.json"][0])
|
|
assert tag_receipt["schema"] == 1 and tag_receipt["tag"] == "v0.28.0" and tag_receipt["commit"] == COMMIT
|
|
commit_receipt = json.loads(r2_server.store[f"releases/commit/{COMMIT}/handoff-win32-x64.json"][0])
|
|
assert commit_receipt["schema"] == 2 and "tag" not in commit_receipt
|
|
|
|
|
|
def test_interrupted_upload_leaves_no_commit_receipt(tmp_path, r2_server):
|
|
"""Receipt-last means an interrupted build publishes NO receipt, so the
|
|
summary can never mistake a half-staged leg for a completed one."""
|
|
from scripts.releases import handoff
|
|
|
|
root = tmp_path / "built"
|
|
root.mkdir()
|
|
(root / "a.msix").write_bytes(b"x")
|
|
(root / "b.bin").write_bytes(b"y")
|
|
# Second include pattern's upload dies mid-leg: the receipt PUT (last)
|
|
# never happens, only artifacts exist.
|
|
real_put = r2.put
|
|
calls = {"n": 0}
|
|
|
|
def flaky_put(**kwargs):
|
|
calls["n"] += 1
|
|
if "b.bin" in kwargs["key"]:
|
|
raise OSError("connection reset mid-upload")
|
|
return real_put(**kwargs)
|
|
|
|
with mock.patch.object(r2, "put", side_effect=lambda **kw: flaky_put(**kw)):
|
|
with pytest.raises(OSError):
|
|
handoff.stage_commit_build(COMMIT, "win32-x64", root, ["*.msix", "*.bin"])
|
|
assert f"releases/commit/{COMMIT}/a.msix" in r2_server.store
|
|
assert f"releases/commit/{COMMIT}/b.bin" not in r2_server.store
|
|
assert f"releases/commit/{COMMIT}/handoff-win32-x64.json" not in r2_server.store
|
|
|
|
|
|
def test_commit_receipt_identity_rejects_bad_sha_tag_sneak_and_traversal(tmp_path, r2_server):
|
|
from scripts.releases import handoff
|
|
|
|
for bad in ("abc", COMMIT[:39], "G" * 40, ""):
|
|
with pytest.raises(ValueError):
|
|
handoff.validate_commit_identity(bad, "win32-x64")
|
|
with pytest.raises(ValueError):
|
|
handoff.validate_commit_identity(COMMIT, "Bad Name")
|
|
receipt = {"schema": 2, "commit": COMMIT, "name": "win32-x64",
|
|
"files": [{"path": "a.msix", "size": 1, "sha256": "0" * 64}]}
|
|
with pytest.raises(ValueError, match="identity mismatch"):
|
|
handoff.validate_commit_receipt({**receipt, "tag": "v1.2.3"}, COMMIT, "win32-x64")
|
|
with pytest.raises(ValueError, match="identity mismatch"):
|
|
handoff.validate_commit_receipt({**receipt, "schema": 1}, COMMIT, "win32-x64")
|
|
bad_path = copy.deepcopy(receipt)
|
|
bad_path["files"][0]["path"] = "../outside"
|
|
with pytest.raises(ValueError, match="path"):
|
|
handoff.validate_commit_receipt(bad_path, COMMIT, "win32-x64")
|
|
|
|
# A fetch against a mismatched commit is refused.
|
|
root = tmp_path / "built"
|
|
root.mkdir()
|
|
(root / "a.msix").write_bytes(b"x")
|
|
handoff.stage_commit_build(COMMIT, "win32-x64", root, ["*.msix"])
|
|
with pytest.raises(handoff.MissingReceipt):
|
|
handoff.read_commit_receipt("c" * 40, "win32-x64")
|
|
|
|
|
|
def test_corrupt_commit_receipt_is_not_reported_as_missing(tmp_path, r2_server):
|
|
"""A present-but-invalid receipt is a distinct failure from a MISSING
|
|
one: the summary renderer must crash loudly on a corrupt receipt, not
|
|
render the leg as an ordinary "leg incomplete" row."""
|
|
from scripts.releases import handoff
|
|
|
|
root = tmp_path / "built"
|
|
root.mkdir()
|
|
(root / "a.msix").write_bytes(b"x")
|
|
handoff.stage_commit_build(COMMIT, "win32-x64", root, ["*.msix"])
|
|
|
|
# Corrupt the stored receipt (wrong schema: a validation failure).
|
|
receipt_key = f"releases/commit/{COMMIT}/handoff-win32-x64.json"
|
|
original = json.loads(r2_server.store[receipt_key][0])
|
|
corrupt = copy.deepcopy(original)
|
|
corrupt["schema"] = 1
|
|
r2_server.store[receipt_key] = (json.dumps(corrupt).encode(), '"e"')
|
|
with pytest.raises(ValueError) as excinfo:
|
|
handoff.read_commit_receipt(COMMIT, "win32-x64")
|
|
assert not isinstance(excinfo.value, handoff.MissingReceipt)
|
|
|
|
# A mismatched-commit receipt is likewise NOT a MissingReceipt.
|
|
corrupt2 = copy.deepcopy(original)
|
|
corrupt2["commit"] = "c" * 40
|
|
r2_server.store[receipt_key] = (json.dumps(corrupt2).encode(), '"e"')
|
|
with pytest.raises(ValueError) as excinfo2:
|
|
handoff.read_commit_receipt(COMMIT, "win32-x64")
|
|
assert not isinstance(excinfo2.value, handoff.MissingReceipt)
|
|
|
|
# Restore; then a genuinely absent receipt raises MissingReceipt.
|
|
r2_server.store[receipt_key] = (json.dumps(original).encode(), '"e"')
|
|
with pytest.raises(handoff.MissingReceipt):
|
|
handoff.read_commit_receipt("d" * 40, "win32-x64")
|
|
|
|
|
|
def test_real_cli_stage_and_summary_run_as_subprocesses(tmp_path):
|
|
"""Run the actual CLI parsers and signed transport against loopback HTTP."""
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
from http.server import ThreadingHTTPServer
|
|
from pathlib import Path
|
|
from urllib.request import urlopen
|
|
|
|
repo = Path(__file__).resolve().parents[2]
|
|
from tests.scripts.test_release_r2 import _R2StubHandler
|
|
|
|
server = ThreadingHTTPServer(("127.0.0.1", 0), _R2StubHandler)
|
|
server.store = {}
|
|
server.requests = []
|
|
|
|
def listing_xml():
|
|
parts = ["<?xml version='1.0'?><ListBucketResult><IsTruncated>false</IsTruncated>"]
|
|
for key, (body, _etag) in server.store.items():
|
|
parts.append(f"<Contents><Key>{key}</Key><LastModified>2026-08-01T00:00:00Z</LastModified></Contents>")
|
|
parts.append("</ListBucketResult>")
|
|
return "".join(parts)
|
|
|
|
server.listing_xml = listing_xml
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
env = dict(os.environ)
|
|
env.pop("PYTHONPATH", None)
|
|
env.pop("PYTHONHOME", None)
|
|
env.update({
|
|
"CLOUDFLARE_R2_ACCOUNT_ID": "loopback",
|
|
"CLOUDFLARE_R2_ACCESS_KEY_ID": "test-inert",
|
|
"CLOUDFLARE_R2_SECRET_ACCESS_KEY": "test-inert",
|
|
"CLOUDFLARE_R2_BUCKET": "hermes-releases",
|
|
"GITHUB_SHA": "c" * 40, # ambient, unrelated — must be ignored
|
|
})
|
|
base = f"http://127.0.0.1:{server.server_port}"
|
|
driver = tmp_path / "cli_driver.py"
|
|
driver.write_text(
|
|
"import runpy, sys\n"
|
|
f"sys.path.insert(0, {str(repo)!r})\n"
|
|
"from scripts.releases import r2\n"
|
|
f"r2.s3_endpoint = lambda account: {base!r}\n"
|
|
"mode = sys.argv.pop(1)\n"
|
|
"if mode == 'handoff':\n"
|
|
" runpy.run_module('scripts.releases.handoff', run_name='__main__')\n"
|
|
"else:\n"
|
|
f" runpy.run_path({str(repo / 'scripts/render-builds-table.py')!r}, run_name='__main__')\n",
|
|
encoding="utf-8",
|
|
)
|
|
build_root = tmp_path / "built"
|
|
build_root.mkdir()
|
|
(build_root / "HermesBundled-0.28.0-win-x64.msix").write_bytes(b"cli-bytes")
|
|
|
|
stage = subprocess.run(
|
|
[sys.executable, str(driver), "handoff", "stage",
|
|
"--commit-build", COMMIT, "--name", "win32-x64",
|
|
"--root", str(build_root), "--include", "*.msix"],
|
|
cwd=tmp_path, env=env, capture_output=True, text=True, encoding="utf-8", timeout=60)
|
|
assert stage.returncode == 0, stage.stdout + stage.stderr
|
|
|
|
summary = tmp_path / "summary.md"
|
|
render = subprocess.run(
|
|
[sys.executable, str(driver), "summary",
|
|
"--summary-commit", COMMIT, "--summary-out", str(summary),
|
|
"--r2-base-url", base + "/hermes-releases"],
|
|
cwd=tmp_path, env=env, capture_output=True, text=True, encoding="utf-8", timeout=60)
|
|
assert render.returncode == 0, render.stdout + render.stderr
|
|
text = summary.read_text(encoding="utf-8")
|
|
# The staged leg is receipt+object bound -> exactly one ✅ row with
|
|
# a link; every other leg renders an explicit ❌.
|
|
built = [line for line in text.splitlines() if "✅ Built" in line]
|
|
assert len(built) == 1, text
|
|
assert "HermesBundled-0.28.0-win-x64.msix" in built[0]
|
|
url = re.search(r"\]\((http[^)]+)\)", built[0]).group(1)
|
|
assert f"/releases/commit/{COMMIT}/" in url
|
|
with urlopen(url, timeout=5) as response:
|
|
assert response.read() == b"cli-bytes"
|
|
assert "Windows universal Store bundle (MSIXBUNDLE)" in text
|
|
assert "Linux x64" in text and "Linux ARM64" in text
|
|
assert all(key.startswith(f"releases/commit/{COMMIT}/") for key in server.store)
|
|
|
|
receipt_key = f"releases/commit/{COMMIT}/handoff-win32-x64.json"
|
|
server.store[receipt_key] = (b"not-json", '"invalid"')
|
|
failed = subprocess.run(
|
|
[sys.executable, str(driver), "summary", "--summary-commit", COMMIT,
|
|
"--summary-out", str(summary), "--r2-base-url", base + "/hermes-releases"],
|
|
cwd=tmp_path, env=env, capture_output=True, text=True, encoding="utf-8", timeout=60,
|
|
)
|
|
assert failed.returncode != 0
|
|
assert summary.read_text(encoding="utf-8") == text
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
thread.join(timeout=5)
|