330 lines
16 KiB
Python
330 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; other enabled legs are Not built, and Linux is Disabled.
|
|
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 bundle (MSIXBUNDLE)" in text
|
|
assert "Store" not 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)
|