"""Validate and conditionally publish macOS update feeds.""" from __future__ import annotations import re from typing import Any, Callable from . import r2 as r2_module from .semver import compare, is_release_version from hermes_cli.update_channel import canary_timestamp, is_canary_tag ARCHES = ("arm64", "x64") _HASH_PATTERN = re.compile(r"^[A-Za-z0-9+/]{86}==$") def _darwin_feed(channel: str, light: bool = False) -> dict[str, Any]: """Legacy URL shape only, not a channel existence registry.""" if not isinstance(channel, str) or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", channel): raise ValueError("Invalid legacy feed path component") return {"directory": f"releases/darwin/{'light/' if light else ''}{channel}", "channel": channel, "fileName": f"{channel}-mac.yml"} # --------------------------------------------------------------------------- # Feed parsing / merging (pure) # --------------------------------------------------------------------------- def parse_mac_feed(text: str) -> dict[str, Any]: import hermes_yaml as yaml # lazy: YAML support loads only on the feed path feed = yaml.safe_load(text) if ( not isinstance(feed, dict) or not is_release_version(str(feed.get("version", ""))) or not isinstance(feed.get("files"), list) or not feed["files"] ): raise ValueError("Invalid macOS update feed") for file_entry in feed["files"]: if ( not isinstance(file_entry, dict) or not isinstance(file_entry.get("url"), str) or not isinstance(file_entry.get("sha512"), str) or not _HASH_PATTERN.match(file_entry["sha512"]) or not isinstance(file_entry.get("size"), int) or isinstance(file_entry.get("size"), bool) or file_entry["size"] <= 0 ): raise ValueError("Invalid macOS artifact metadata") if feed.get("path") and not any( isinstance(f, dict) and f.get("url") == feed.get("path") and f.get("sha512") == feed.get("sha512") for f in feed["files"] ): raise ValueError("Legacy feed path/hash disagrees with files") return feed _MAC_URL_PATTERN = re.compile( # Attempt refs (rc.N-vX.Y.Z) key the stable archive; plain and canary # vX.Y.Z[+canary...] tags key the rest. r"^/releases/tag/(?:v|rc\.[1-9]\d*-v)[0-9A-Za-z.+-]+/[0-9A-Za-z._+-]+\.(zip|dmg)$" ) def mac_feed_references(text: str) -> list[str]: """Every bucket key a published feed references (artifact + .blockmap).""" feed = parse_mac_feed(text) references: list[str] = [] for file_entry in feed["files"]: # Only our immutable artifact namespace is eligible for publication # or pruning; anything else fails loudly instead of half-publishing. if not _MAC_URL_PATTERN.match(file_entry["url"]): raise ValueError(f"Invalid macOS artifact path: {file_entry['url']}") key = file_entry["url"][1:] references.extend([key, f"{key}.blockmap"]) return references def merge_mac_feeds(legs: dict[str, str], tag: str, light: bool = False, archive: str | None = None) -> dict[str, Any]: """Validate both native legs, merge them, and rewrite artifact URLs into the immutable per-release tag namespace. `archive` keys that namespace (the attempt ref for stable attempts); `tag` stays the version identity.""" import hermes_yaml as yaml # lazy version = tag[1:] if isinstance(tag, str) and tag.startswith("v") else "" if not is_release_version(version): raise ValueError("Invalid macOS release tag") selection = _darwin_feed("canary" if is_canary_tag(tag) else "stable", light) expected = [f"{arch}-{selection['fileName']}" for arch in ARCHES] if sorted(legs.keys()) != sorted(expected): raise ValueError("Expected exactly one ARM64 and one x64 macOS feed") files: dict[str, dict[str, Any]] = {} first: dict[str, Any] | None = None for index, name in enumerate(expected): leg = parse_mac_feed(legs[name]) if str(leg.get("version")) != version: raise ValueError(f"Feed version does not match {tag}") prefix = f"{'HermesLight' if light else 'HermesBundled'}-{version}-mac-{ARCHES[index]}" if not any(f.get("url") == f"{prefix}.zip" for f in leg["files"]): raise ValueError(f"Missing native ZIP for {ARCHES[index]}") for file_entry in leg["files"]: if file_entry.get("url") not in (f"{prefix}.zip", f"{prefix}.dmg"): raise ValueError(f"Wrong variant or architecture: {file_entry.get('url')}") rewritten = {**file_entry, "url": f"/releases/tag/{archive or tag}/{file_entry['url']}"} prior = files.get(file_entry["url"]) if prior is not None and prior != rewritten: raise ValueError(f"Conflicting artifact: {file_entry['url']}") files[file_entry["url"]] = rewritten if first is None: first = leg assert first is not None merged = {**first, "files": list(files.values())} if merged.get("path"): merged["path"] = f"/releases/tag/{archive or tag}/{merged['path']}" text = yaml.safe_dump(merged, width=100000, default_flow_style=False, sort_keys=False) mac_feed_references(text) # publish only a feed that parses back clean return { "key": f"{selection['directory']}/{selection['fileName']}", "text": text, "files": merged["files"], "tag": tag, "version": version, } # --------------------------------------------------------------------------- # Publication (transport-verified) # --------------------------------------------------------------------------- def publish_mac_feed( plan: dict[str, Any], transport: dict[str, Callable[..., Any]], ) -> None: """The transport verifies immutable bytes and conditionally replaces one pointer. Downgrade rejection: semver.compare against the live feed.""" read = transport["read"] verify = transport["verify"] write = transport["write"] live = read(plan["key"]) if live: old_feed = parse_mac_feed(live["text"]) next_feed = parse_mac_feed(plan["text"]) old_url = str(old_feed["files"][0]["url"]) old_tag = old_url.split("/releases/tag/", 1)[1].split("/", 1)[0] old_canary = canary_timestamp(old_tag) next_canary = canary_timestamp(plan.get("tag")) order = ( (next_canary > old_canary) - (next_canary < old_canary) if next_canary is not None and old_canary is not None else compare(plan["version"], str(old_feed["version"])) ) if order < 0: raise ValueError("Refusing to move the macOS feed backward") if order == 0: if old_feed != next_feed: raise ValueError("Refusing to replace published version with different artifacts") return for file_entry in plan["files"]: verify(file_entry["url"][1:], file_entry) write(plan["key"], plan["text"], live["etag"] if live else None) published = read(plan["key"]) if not published or published["text"] != plan["text"]: raise ValueError("macOS feed readback differs from publication") # --------------------------------------------------------------------------- # finalize (real signed transport) # --------------------------------------------------------------------------- def finalize(tag: str, dir: str, variant: str | None = None, archive: str | None = None) -> None: """Validate both native legs, verify their streamed bytes, then replace the feed pointer (conditional write, then readback).""" if variant and variant != "light": raise ValueError("Unknown macOS variant") creds, base, bucket = r2_module.credentials() now = r2_module.amz_timestamp() import os legs = { name: open(os.path.join(dir, name), encoding="utf-8-sig").read() for name in sorted(os.listdir(dir)) if name.endswith("-mac.yml") } plan = merge_mac_feeds(legs, tag, variant == "light", archive=archive) def read(key: str) -> dict[str, str] | None: try: response = r2_module.signed_request( "GET", f"{base}/{bucket}/{r2_module.encode_key_path(key)}", creds=creds, now=now ) except r2_module.R2RequestError as err: if err.status == 404: return None # first publish: no live feed yet raise if response.status >= 400: raise r2_module.R2RequestError("GET", key, response.status) etag = response.header("etag") if not etag: raise ValueError(f"No ETag for {key}") return {"text": response.text(), "etag": etag} def verify(key: str, file_entry: dict[str, Any]) -> None: r2_module.verify_remote_artifact( f"{base}/{bucket}/{r2_module.encode_key_path(key)}", creds, now, file_entry["size"], file_entry["sha512"], ) def write(key: str, text: str, etag: str | None) -> None: r2_module.put_object( creds, base, bucket, key, text.encode("utf-8"), now, "application/yaml", {"If-Match": etag} if etag else {"If-None-Match": "*"}, ) publish_mac_feed(plan, {"read": read, "verify": verify, "write": write}) print(f"OK r2: finalized {tag} -> {plan['key']}")