Files
hermes-agent/tests/scripts/test_release_darwin.py

231 lines
10 KiB
Python

# tests/scripts/test_release_darwin.py — contract tests for the macOS
# electron-updater feed publication (port of tests-js/darwin-feed.test.mjs).
# Feed merge/validation is pure; publication runs against a REAL loopback
# HTTP server (http.server on 127.0.0.1), not a fabricated backend.
from __future__ import annotations
import hashlib
import os
import tempfile
import pytest
import hermes_yaml as yaml
from scripts.releases import darwin, r2
from tests.scripts.test_release_r2 import r2_server # noqa: F401 — loopback fixture
from scripts.releases.darwin import (
_darwin_feed,
mac_feed_references,
merge_mac_feeds,
parse_mac_feed,
)
def _inputs(version="0.28.0", light=False):
channel = "canary" if "+canary." in version else "stable"
bytes_by_key = {}
legs = {}
for i, arch in enumerate(("arm64", "x64")):
name = f"{'HermesLight' if light else 'HermesBundled'}-{version}-mac-{arch}.zip"
data = f"test artifact {arch}".encode()
bytes_by_key[f"releases/tag/v{version}/{name}"] = data
file_entry = {
"url": name,
"size": len(data),
"sha512": base64_sha512(data),
}
legs[f"{arch}-{channel}-mac.yml"] = yaml.safe_dump(
{
"version": version,
"files": [file_entry],
"path": name,
"sha512": file_entry["sha512"],
"releaseDate": f"2026-09-0{i + 1}T00:00:00Z",
"releaseNotes": "two lines\nof release notes",
},
sort_keys=False,
)
return legs, bytes_by_key
def base64_sha512(data: bytes) -> str:
return __import__("base64").b64encode(hashlib.sha512(data).digest()).decode("ascii")
def test_real_prune_protects_live_macos_artifacts_and_fails_closed(r2_server, monkeypatch):
legs, _bytes = _inputs("0.28.0+canary.20200101T000000Z")
plan = merge_mac_feeds(legs, "v0.28.0+canary.20200101T000000Z")
references = mac_feed_references(plan["text"])
stale = "releases/tag/v0.27.0+canary.20200101T000000Z/unreferenced.zip"
for key in [plan["key"], *references, stale]:
r2_server.store[key] = (b"x", '"e"')
r2_server.store[plan["key"]] = (plan["text"].encode(), '"e"')
monkeypatch.setattr(r2.time, "time", lambda: 1788547200.0) # 2026-09-04
r2.prune(keep_days=14, dry_run=False)
assert stale not in r2_server.store
for reference in references:
assert reference in r2_server.store, reference
# Fail closed: an unreadable manifest aborts the prune, nothing deleted.
r2_server.store[stale] = (b"still protected until all feeds are readable", '"e"')
before = set(r2_server.store)
r2_server.store["releases/darwin/canary/canary-mac.yml"] = (b"<html>503</html>", '"e"')
with pytest.raises(ValueError):
r2.prune(keep_days=14, dry_run=False)
assert set(r2_server.store) == before
def test_merges_native_legs_and_preserves_release_metadata():
legs, _bytes = _inputs()
plan = merge_mac_feeds(legs, "v0.28.0")
feed = parse_mac_feed(plan["text"])
assert len(feed["files"]) == 2
assert feed["releaseNotes"] == "two lines\nof release notes"
assert plan["key"] == "releases/darwin/stable/stable-mac.yml"
assert mac_feed_references(plan["text"]) == [
ref
for f in feed["files"]
for ref in (f["url"][1:], f"{f['url'][1:]}.blockmap")
]
assert r2.cache_control_for(plan["key"]) == "no-store"
selection = _darwin_feed("canary", True)
light_legs, _ = _inputs("0.29.0+canary.20260906T000000Z", light=True)
light_plan = merge_mac_feeds(light_legs, "v0.29.0+canary.20260906T000000Z", light=True)
assert light_plan["key"] == f"{selection['directory']}/{selection['fileName']}"
@pytest.mark.parametrize(
"kind", ["missing", "version", "variant", "hash", "legacy", "traversal"]
)
def test_rejects_broken_legs_instead_of_publishing(kind):
legs, _bytes = _inputs()
key = "arm64-stable-mac.yml"
feed = yaml.safe_load(legs[key])
if kind == "missing":
del legs[key]
else:
if kind == "version":
feed["version"] = "0.27.0"
if kind == "variant":
feed["files"][0]["url"] = feed["files"][0]["url"].replace("HermesBundled", "HermesLight")
if kind == "hash":
feed["files"][0]["sha512"] = "invalid"
if kind == "legacy":
feed["sha512"] = "wrong"
if kind == "traversal":
feed["files"][0]["url"] = "../other.zip"
legs[key] = yaml.safe_dump(feed, sort_keys=False)
with pytest.raises(Exception):
merge_mac_feeds(legs, "v0.28.0")
def test_semver_grammar_rejects_non_release_versions():
# The Python port never imports npm semver; it implements exactly the
# release grammar (vMAJOR.MINOR.PATCH[+canary.<full UTC timestamp>]) and fails
# loudly on anything else instead of guessing an order.
from scripts.releases.semver import compare, is_valid_version
assert is_valid_version("0.28.0") and is_valid_version("0.28.0+canary.20260904T101010Z")
# Legacy CalVer (four-digit major) is not a release version anywhere.
assert not is_valid_version("0.28") and not is_valid_version("2026.7.20")
assert not is_valid_version("0.28.0-beta.1")
assert compare("0.28.0", "0.27.9") == 1
assert compare("0.28.0+canary.20260904T101010Z", "0.28.0") == 0
assert compare("0.28.0+canary.20260904T101010Z", "0.28.0+canary.20260904T101011Z") == 0
with pytest.raises(ValueError):
compare("nonsense", "0.28.0")
def test_finalize_uses_the_real_signed_transport_and_publishes_last(r2_server):
with tempfile.TemporaryDirectory() as dir_path:
legs, bytes_by_key = _inputs()
for name, text in legs.items():
with open(os.path.join(dir_path, name), "w", encoding="utf-8") as handle:
handle.write(text)
for key, value in bytes_by_key.items():
r2_server.store[key] = (value, '"e"')
darwin.finalize(tag="v0.28.0", dir=dir_path)
feed_key = "releases/darwin/stable/stable-mac.yml"
assert feed_key in r2_server.store
published = r2_server.store[feed_key][0].decode("utf-8")
assert len(parse_mac_feed(published)["files"]) == 2
# Order: reads (legs verified) FIRST, then exactly one conditional PUT,
# then the readback — verify bytes before the pointer write.
methods = [m for m, _p, _h in r2_server.requests]
assert methods.count("PUT") == 1
assert "PUT" not in methods[:3]
put_headers = [h for m, _p, h in r2_server.requests if m == "PUT"][0]
assert put_headers["If-None-Match"] == "*"
assert put_headers["Cache-Control"] == "no-store"
assert put_headers["Content-Type"] == "application/yaml"
# Every request was signed.
for _m, _p, headers in r2_server.requests:
assert headers["authorization"].startswith("AWS4-HMAC-SHA256 ")
def test_finalize_rejects_a_downgrade_over_the_live_feed(r2_server):
with tempfile.TemporaryDirectory() as dir_path:
legs, bytes_by_key = _inputs("0.27.0")
for name, text in legs.items():
with open(os.path.join(dir_path, name), "w", encoding="utf-8") as handle:
handle.write(text)
for key, value in bytes_by_key.items():
r2_server.store[key] = (value, '"e"')
# The live feed is already at 0.28.0.
newer = merge_mac_feeds(_inputs()[0], "v0.28.0")
r2_server.store["releases/darwin/stable/stable-mac.yml"] = (
newer["text"].encode(), '"live"')
with pytest.raises(ValueError, match="backward"):
darwin.finalize(tag="v0.27.0", dir=dir_path)
# The live pointer was not replaced.
assert parse_mac_feed(
r2_server.store["releases/darwin/stable/stable-mac.yml"][0].decode()
)["version"] == "0.28.0"
def test_finalize_rejects_unknown_variant():
with pytest.raises(ValueError, match="variant"):
darwin.finalize(tag="v0.28.0", dir=".", variant="dark")
@pytest.mark.parametrize('fault', ['identical', 'different', 'corrupt-artifact', 'stale-etag', 'readback', 'upgrade'])
def test_finalize_live_feed_faults(tmp_path, r2_server, fault):
legs, content = _inputs()
for name, text in legs.items():
(tmp_path / name).write_text(text, encoding='utf-8')
r2_server.store.update({key: (value, '"e"') for key, value in content.items()})
key = 'releases/darwin/stable/stable-mac.yml'
live = merge_mac_feeds(_inputs('0.28.0' if fault in {'identical', 'different'} else '0.27.0')[0],
'v0.28.0' if fault in {'identical', 'different'} else 'v0.27.0')['text']
if fault == 'different':
live = live.replace('two lines', 'other notes')
r2_server.store[key] = (live.encode(), '"live"')
if fault == 'corrupt-artifact':
r2_server.store[next(iter(content))] = (b'corrupt', '"e"')
if fault == 'stale-etag':
r2_server.race_key = key
if fault == 'readback':
r2_server.corrupt_put = key
errors = {'different': (ValueError, 'different artifacts'),
'corrupt-artifact': (ValueError, 'checksum mismatch'),
'stale-etag': (r2.R2RequestError, '412'), 'readback': (ValueError, 'readback differs')}
if fault in errors:
error, message = errors[fault]
with pytest.raises(error, match=message):
darwin.finalize(tag='v0.28.0', dir=str(tmp_path))
else:
darwin.finalize(tag='v0.28.0', dir=str(tmp_path))
puts = [headers for method, _, headers in r2_server.requests if method == 'PUT']
if fault in {'identical', 'different', 'corrupt-artifact'}:
assert puts == [] and r2_server.store[key][0] == live.encode()
else:
assert len(puts) == 1 and puts[0]['If-Match'] == '"live"'
if fault == 'stale-etag':
assert r2_server.store[key] == (live.encode(), '"raced"')
elif fault == 'upgrade':
assert parse_mac_feed(r2_server.store[key][0].decode())['version'] == '0.28.0'
elif fault == 'readback':
assert [method for method, path, _ in r2_server.requests if path.endswith(key)] == ['GET', 'PUT', 'HEAD', 'GET']