# 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"503", '"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.]) 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']