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.
219 lines
9.3 KiB
Python
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']}")
|