Files
hermes-agent/scripts/releases/darwin.py
ethernet 7547d72763 feat(release): write the stable archive under the attempt ref
The archive readers already keyed every object on releases/tag/<attempt>;
the writers still keyed them on the plain payload tag, so a stable run
wrote releases/tag/vX.Y.Z/ while publish read releases/tag/rc.N-vX.Y.Z/
and found nothing. Derive one archive ref at admission (validate emits
archive-tag, jobs export HERMES_ARCHIVE_TAG) and use it at every writer
and reader of a releases/tag/<x>/ key in the stable path. The plain
vX.Y.Z keeps naming the payload identity: build stamps, package
versions, feed versions, and GitHub release bodies. Canary and channel
builds keep archive == payload tag, so their keys are unchanged.
2026-09-23 10:51:01 -04:00

219 lines
9.3 KiB
Python

"""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']}")