Files
hermes-agent/scripts/releases/r2.py
ethernet 0384a24edc Merge branch 'ethie/release-attempt-refs' into ethie/pm-clean
Conflicts:
- scripts/releases/stamping.py, tests/scripts/test_version_stamping.py:
  took ethie/pm-clean. The release branch's side was only its base's copy
  of "stamping a payload snapshot skips the bootstrap-installer check"
  (8411fdb333, same patch-id as 8d34601f47 here); the install-stamp
  refactor c13ea774e6 supersedes the rest.
- tests/ci/test_stable_release_graph.py: kept pm-clean's release-epoch
  contract (no HERMES_RELEASE_EPOCH on termux-deb, version on docker and
  nix only) and the release branch's per-group receipt wiring.

Semantic conflict: the dispatch log step (6e64e961d8) read
inputs.termux_only, which the jobs input replaced. It reads JOBS now, and a
dispatch that selects only some groups has no release.py replay, as a
termux-only one had none before.
2026-09-23 19:00:44 -04:00

1088 lines
40 KiB
Python

"""Stream and verify release objects through the signed R2 transport."""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
import hmac
import http.client
import os
import re
import sys
import time
from datetime import datetime, timezone
from typing import Callable, Iterable, cast
from urllib.parse import quote, urlparse
from scripts.releases.r2_scope import R2Scope
REGION = "auto"
SERVICE = "s3"
EMPTY_SHA = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
_CONTENT_TYPES_PATH = Path(__file__).resolve().parents[1] / "release-content-types.json"
with _CONTENT_TYPES_PATH.open(encoding="utf-8") as _file:
_CONTENT_TYPES = tuple(json.load(_file).items())
def content_type_for(filename: str) -> str | None:
lower = filename.lower()
base = lower[lower.rfind("/") + 1:]
for key, mime in _CONTENT_TYPES:
if "." in key:
if lower.endswith(key):
return mime
elif base == key:
return mime
return None
# ---------------------------------------------------------------------------
# SigV4 (pure; tests pin these against botocore-generated vectors)
# ---------------------------------------------------------------------------
_UNRESERVED = set(
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~"
)
def rfc3986_encode(value: str) -> str:
"""RFC3986 encode: everything except unreserved [A-Za-z0-9-_.~]."""
out: list[str] = []
for char in value:
if char in _UNRESERVED:
out.append(char)
else:
out.extend(f"%{b:02X}" for b in char.encode("utf-8"))
return "".join(out)
def canonical_query(params: dict[str, str]) -> str:
"""Canonical query string: params sorted by encoded key (then value)."""
pairs = sorted(
(rfc3986_encode(k), rfc3986_encode(str(v))) for k, v in params.items()
)
return "&".join(f"{k}={v}" for k, v in pairs)
def _header_value(headers: dict[str, str], name: str) -> str:
for key, value in headers.items():
if key.lower() == name:
return str(value)
raise KeyError(name)
def canonical_request(
method: str,
path: str,
query: str,
headers: dict[str, str],
payload_hash: str,
) -> str:
"""Canonical request for one S3-style request. Header names are
canonicalized lowercase + sorted; values are read case-insensitively
(the mixed-case 'Content-Type' regression is pinned by a test)."""
names = sorted(k.lower() for k in headers)
whitespace = re.compile(r"\s+")
def _collapsed(name: str) -> str:
return whitespace.sub(" ", _header_value(headers, name).strip())
canonical_headers = "\n".join(f"{n}:{_collapsed(n)}" for n in names)
return "\n".join(
[method, path, query, canonical_headers, "", ";".join(names), payload_hash]
)
def string_to_sign(canonical: str, date: str, scope: str) -> str:
sts_hash = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
return "\n".join(["AWS4-HMAC-SHA256", date, scope, sts_hash])
def _hmac(key: bytes, msg: str) -> bytes:
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
def signature(sts_text: str, secret_key: str, date: str, region: str, service: str) -> str:
k_date = _hmac(f"AWS4{secret_key}".encode("utf-8"), date)
k_region = _hmac(k_date, region)
k_service = _hmac(k_region, service)
k_signing = _hmac(k_service, "aws4_request")
return hmac.new(k_signing, sts_text.encode("utf-8"), hashlib.sha256).hexdigest()
def auth_header(
*,
method: str,
host: str,
path: str,
query: str,
headers: dict[str, str],
payload_hash: str,
access_key_id: str,
secret_key: str,
now: str,
region: str = REGION,
service: str = SERVICE,
) -> str:
"""Full AWS4-HMAC-SHA256 Authorization header value for one request."""
date = now[:8]
canonical = canonical_request(method, path, query, headers, payload_hash)
sts = string_to_sign(canonical, now, f"{date}/{region}/{service}/aws4_request")
sig = signature(sts, secret_key, date, region, service)
signed_headers = ";".join(sorted(k.lower() for k in headers))
return (
f"AWS4-HMAC-SHA256 Credential={access_key_id}/{date}/{region}/{service}/aws4_request, "
f"SignedHeaders={signed_headers}, Signature={sig}"
)
# ---------------------------------------------------------------------------
# R2 request plumbing
# ---------------------------------------------------------------------------
def s3_endpoint(account_id: str) -> str:
return f"https://{account_id}.r2.cloudflarestorage.com"
def encode_key_path(key: str) -> str:
"""Encode an object key into the URI path, segment by segment."""
return "/".join(rfc3986_encode(seg) for seg in key.split("/"))
def amz_timestamp() -> str:
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
class R2RequestError(Exception):
def __init__(self, method: str, path: str, status: int, body: str = ""):
self.method, self.path, self.status, self.body = method, path, status, body
super().__init__(f"R2 {method} {path} -> {status}" + (f": {body[:300]}" if body else ""))
class Response:
def __init__(self, status: int, headers: list[tuple[str, str]], body: bytes):
self.status = status
self.headers = {k.lower(): v for k, v in headers}
self._body = body
def text(self) -> str:
return self._body.decode("utf-8", errors="replace")
def header(self, name: str) -> str | None:
return self.headers.get(name.lower())
def r2_headers(
method: str,
host: str,
path: str,
query: str,
body_hash: str,
now: str,
creds: dict[str, str],
content_type: str | None = None,
content_length: int | None = None,
extra_headers: dict[str, str] | None = None,
) -> dict[str, str]:
headers = {
"host": host,
"x-amz-date": now,
"x-amz-content-sha256": body_hash,
}
# Content-Type / Content-Length / extras (Range, If-None-Match, ...)
# join BEFORE signing so they land in SignedHeaders like everything else.
if content_type:
headers["Content-Type"] = content_type
if content_length is not None:
headers["Content-Length"] = str(content_length)
if extra_headers:
headers.update(extra_headers)
headers["authorization"] = auth_header(
method=method, host=host, path=path, query=query, headers=headers,
payload_hash=body_hash, access_key_id=creds["access_key_id"],
secret_key=creds["secret_key"], now=now,
)
return headers
def _connection(url: str, timeout: float) -> http.client.HTTPConnection:
parsed = urlparse(url)
port = parsed.port or (443 if parsed.scheme == "https" else 80)
if parsed.scheme == "https":
import ssl
return http.client.HTTPSConnection(
parsed.hostname, port, context=ssl.create_default_context(), timeout=timeout
)
return http.client.HTTPConnection(parsed.hostname, port, timeout=timeout)
_RETRYABLE_STATUSES = {500, 502, 503, 504}
def signed_request(
method: str,
url: str,
*,
body: bytes | None = None,
body_iter_factory: Callable[[], Iterable[bytes]] | None = None,
body_hash: str = EMPTY_SHA,
content_length: int | None = None,
creds: dict[str, str],
now: str,
content_type: str | None = None,
extra_headers: dict[str, str] | None = None,
tries: int = 3,
) -> Response:
"""Signed request with retries. Path payloads get a FRESH stream per
attempt (a consumed iterator cannot be replayed). Raises R2RequestError
on a final non-2xx response."""
parsed = urlparse(url)
query = parsed.query
last: Response | None = None
for attempt in range(1, tries + 1):
body_iter = body_iter_factory() if body_iter_factory is not None else None
headers = r2_headers(
method, parsed.netloc, parsed.path, query, body_hash, now, creds,
content_type=content_type, content_length=content_length,
extra_headers=extra_headers,
)
conn = _connection(url, timeout=600.0)
try:
conn.request(
method,
parsed.path + ("?" + query if query else ""),
body=body_iter if body_iter is not None else body,
headers=headers,
)
res = conn.getresponse()
data = res.read()
except (OSError, http.client.HTTPException):
if attempt == tries:
raise
time.sleep(float(attempt))
continue
finally:
conn.close()
if body_iter is not None and hasattr(body_iter, "close"):
body_iter.close()
response = Response(res.status, res.getheaders(), data)
if 200 <= response.status < 300:
return response
last = response
if response.status in _RETRYABLE_STATUSES and attempt < tries:
time.sleep(1.0 * attempt)
continue
raise R2RequestError(method, parsed.path, response.status, response.text())
raise R2RequestError(method, parsed.path, last.status if last else 0)
def stream_file(path: str, chunk_size: int = 1024 * 1024) -> Iterable[bytes]:
with open(path, "rb") as handle:
while chunk := handle.read(chunk_size):
yield chunk
def file_sha256(path: str) -> str:
digest = hashlib.sha256()
for chunk in stream_file(path):
digest.update(chunk)
return digest.hexdigest()
def verify_remote_artifact(
url: str,
creds: dict[str, str],
now: str,
expected_size: int,
digest: str,
algorithm: str = "sha512",
encoding: str = "base64",
fetcher: Callable[[str], Response] | None = None,
) -> None:
"""STREAMED GET (release artifacts are ~2GiB — never buffered whole);
size + digest must both match or the artifact is corrupt."""
if fetcher is not None:
response = fetcher(url)
if response.status >= 400:
raise R2RequestError("GET", urlparse(url).path, response.status)
data = response._body # noqa: SLF001 — loopback-test responses are small
hash_obj = hashlib.new(algorithm)
hash_obj.update(data)
size = len(data)
else:
hash_obj, size = _stream_and_hash(url, creds, now, algorithm)
actual = (
__import__("base64").b64encode(hash_obj.digest()).decode("ascii")
if encoding == "base64"
else hash_obj.hexdigest()
)
if size != expected_size or actual != digest:
raise ValueError(f"Artifact checksum mismatch: {urlparse(url).path}")
def download_object(
creds: dict[str, str], base: str, bucket: str, key: str, file: Path, now: str,
*, expected_size: int, expected_sha256: str,
) -> None:
url = R2Scope.configured().object_url(base, bucket, key)
parsed = urlparse(url)
def headers(attempt: int) -> dict[str, str]:
return r2_headers("GET", parsed.netloc, parsed.path, "", EMPTY_SHA,
now if attempt == 1 else amz_timestamp(), creds)
_download_url(url, file, headers, expected_size=expected_size, expected_sha256=expected_sha256)
def public_artifact_url(base: str, key: str) -> str:
"""Public reads never carry credentials or follow redirects out of the archive."""
parsed = urlparse(base)
if (not parsed.hostname or parsed.username is not None or parsed.password is not None
or parsed.query or parsed.fragment or parsed.params
or any(c in base for c in ('%', '\\')) or any(ord(c) <= 32 for c in base)
or any(part in ('.', '..') for part in parsed.path.split('/'))
or not (parsed.scheme == 'https' or (parsed.scheme == 'http'
and parsed.hostname in ('127.0.0.1', 'localhost', '::1')))):
raise ValueError('Invalid public artifact base URL')
return public_url_for(base, relative_artifact_path(key))
def read_public_receipt(base: str, key: str) -> dict:
url = public_artifact_url(base, key)
parsed = urlparse(url)
conn = _connection(url, timeout=60.0)
try:
conn.request('GET', parsed.path)
response = conn.getresponse()
if response.status != 200:
raise R2RequestError('GET', parsed.path, response.status)
# Receipts are metadata, never an unbounded binary download.
body = response.read(4 * 1024 * 1024 + 1)
if len(body) > 4 * 1024 * 1024:
raise ValueError('Public handoff receipt exceeds metadata limit')
return json.loads(body)
finally:
conn.close()
def download_public_object(base: str, key: str, file: Path, *, expected_size: int, expected_sha256: str) -> None:
_download_url(public_artifact_url(base, key), file, lambda _attempt: {},
expected_size=expected_size, expected_sha256=expected_sha256)
def _download_url(url: str, file: Path, request_headers: Callable[[int], dict[str, str]],
*, expected_size: int, expected_sha256: str) -> None:
"""Publish a download locally only after its exact receipt matches."""
import tempfile
if type(expected_size) is not int or expected_size < 0 or not re.fullmatch(r"[a-f0-9]{64}", expected_sha256):
raise ValueError("Invalid artifact size or SHA256")
file = Path(file)
file.parent.mkdir(parents=True, exist_ok=True)
parsed = urlparse(url)
for attempt in range(1, 4):
conn = _connection(url, timeout=600.0)
temporary = None
try:
conn.request("GET", parsed.path, headers=request_headers(attempt))
response = conn.getresponse()
if response.status != 200:
raise R2RequestError("GET", parsed.path, response.status)
digest, size = hashlib.sha256(), 0
with tempfile.NamedTemporaryFile(dir=file.parent, prefix=f".{file.name}.", delete=False) as output:
temporary = Path(output.name)
while chunk := response.read(1024 * 1024):
digest.update(chunk)
size += len(chunk)
if size > expected_size:
raise ValueError(f"Artifact checksum mismatch: {parsed.path}")
output.write(chunk)
if size != expected_size or digest.hexdigest() != expected_sha256:
raise ValueError(f"Artifact checksum mismatch: {parsed.path}")
temporary.replace(file)
return
except R2RequestError as error:
if error.status not in _RETRYABLE_STATUSES or attempt == 3:
raise
except (OSError, http.client.HTTPException):
if attempt == 3:
raise
finally:
conn.close()
if temporary is not None:
temporary.unlink(missing_ok=True)
time.sleep(float(attempt))
def _stream_and_hash(url: str, creds: dict[str, str], now: str, algorithm: str):
"""True streaming GET: hash + count bytes as chunks arrive off the
socket, never materializing the body in memory."""
parsed = urlparse(url)
headers = r2_headers(
"GET", parsed.netloc, parsed.path, parsed.query, EMPTY_SHA, now, creds
)
conn = _connection(url, timeout=600.0)
hash_obj = hashlib.new(algorithm)
size = 0
try:
conn.request("GET", parsed.path + ("?" + parsed.query if parsed.query else ""), headers=headers)
res = conn.getresponse()
if res.status >= 400:
raise R2RequestError("GET", parsed.path, res.status)
while chunk := res.read(1024 * 1024):
hash_obj.update(chunk)
size += len(chunk)
finally:
conn.close()
return hash_obj, size
# ---------------------------------------------------------------------------
# Layout helpers (pure)
# ---------------------------------------------------------------------------
def channel_for_tag(tag: str) -> str:
"""Return the release channel encoded by a canonical tag."""
from hermes_cli.update_channel import is_canary_tag
return "canary" if is_canary_tag(tag) else "stable"
def staging_key_for(tag: str, filename: str) -> str:
return f"releases/tag/{tag}/{filename}"
_FULL_SHA_RE = re.compile(r"[a-f0-9]{40}")
def is_full_sha(value: str) -> bool:
return bool(isinstance(value, str) and _FULL_SHA_RE.fullmatch(value))
# Windows reserved names (CPython ntpath parity). COM/LPT superscript forms
# (COM² etc.) are reserved under NTFS namespace rules, same as the digit forms.
_WIN_RESERVED_NAMES = frozenset(
["CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"]
+ [f"COM{i}" for i in range(1, 10)] + ["COM¹", "COM²", "COM³"]
+ [f"LPT{i}" for i in range(1, 10)] + ["LPT¹", "LPT²", "LPT³"]
)
def _is_windows_reserved(value: str) -> bool:
"""True if any path component is reserved on Windows.
Port of ntpath.isreserved() (added in Python 3.13; release CI may run
older system Pythons, so inline the semantics rather than depend on it).
"""
for part in reversed(value.split("/")):
# Trailing dots and spaces are reserved.
if part[-1:] in (".", " ") and part not in (".", ".."):
return True
stem = part.partition(".")[0].rstrip(" ").upper()
if stem in _WIN_RESERVED_NAMES:
return True
return False
def relative_artifact_path(value: str) -> str:
"""Validate the original path before any filesystem normalization."""
if (not isinstance(value, str) or not value or value.startswith("/")
or any(part in ("", ".", "..") for part in value.split("/"))
or any(c in value for c in "\\:%?#") or any(ord(c) < 32 for c in value)
or _is_windows_reserved(value)):
raise ValueError("Invalid release artifact path")
return value
def commit_key_for(commit: str, filename: str) -> str:
"""Keep nested artifacts inside the exact commit namespace."""
return commit_prefix_for(commit) + relative_artifact_path(filename)
def commit_prefix_for(commit: str) -> str:
if not is_full_sha(commit):
raise ValueError("Commit builds require an exact full 40-character SHA")
return f"releases/commit/{commit}/"
# Public download origin for object keys. CI supplies the authoritative
# value as CLOUDFLARE_R2_PUBLIC_URL; the documented production origin is the
# fallback so a local command can still name a page it is about to publish.
DEFAULT_PUBLIC_URL = "https://hermes-assets.nousresearch.com"
def public_base_url(explicit: str | None = None) -> str:
return R2Scope.configured().public_base(
explicit or os.environ.get("CLOUDFLARE_R2_PUBLIC_URL") or DEFAULT_PUBLIC_URL
)
def public_url_for(base_url: str, key: str) -> str:
"""Public download URL of an object key (segment-wise encoded)."""
return f"{base_url.rstrip('/')}/{quote(key, safe='/')}"
def channel_page_key_for(channel: str) -> str:
"""The mutable per-channel downloads page, replaced by each release."""
return f"releases/{channel}/index.html"
def commit_page_key_for(commit: str) -> str:
"""The per-commit-build downloads page (every expected binary, built or not)."""
return commit_prefix_for(commit) + "index.html"
def feed_dir_for(platform: str, channel: str) -> str:
return f"releases/{platform}/{channel}"
def cache_control_for(key: str) -> str | None:
"""APT indexes are mutable; by-hash indexes and versioned packages are not."""
if key.startswith("releases/channels/"):
return "no-store"
if key.startswith(("releases/channel-builds/", "releases/channel-identities/")):
return "public, max-age=31536000, immutable"
if key.endswith((".appinstaller", ".html")) or key.startswith("releases/stable/") or (key.startswith("releases/darwin/") and key.endswith("-mac.yml")):
return "no-store"
if not key.startswith("releases/termux/"):
return None
return (
"public, max-age=31536000, immutable"
if "/by-hash/" in key or "/pool/" in key
else "no-store"
)
# ---------------------------------------------------------------------------
# Credentials / env
# ---------------------------------------------------------------------------
def required_env(name: str) -> str:
value = os.environ.get(name)
if not value:
print(
f"::error::missing env {name} — see the header comment in scripts/releases/r2.py",
file=sys.stderr,
)
raise SystemExit(2)
return value
def credentials() -> tuple[dict[str, str], str, str]:
"""(creds, base, bucket) from the R2 env vars. No secrets are printed."""
R2Scope.configured() # Fail closed on a malformed disposable lease.
creds = {
"access_key_id": required_env("CLOUDFLARE_R2_ACCESS_KEY_ID"),
"secret_key": required_env("CLOUDFLARE_R2_SECRET_ACCESS_KEY"),
}
base = s3_endpoint(required_env("CLOUDFLARE_R2_ACCOUNT_ID"))
bucket = required_env("CLOUDFLARE_R2_BUCKET")
return creds, base, bucket
# ---------------------------------------------------------------------------
# put
# ---------------------------------------------------------------------------
def put_object(
creds: dict[str, str],
base: str,
bucket: str,
key: str,
payload: str | bytes,
now: str,
content_type: str | None,
conditions: dict[str, str] | None = None,
fetcher: Callable[..., Response] | None = None,
*,
multipart_part_size: int = 64 * 1024 * 1024,
) -> None:
"""Stream file paths, using multipart for large artifacts, or send bytes.
Immutable conflicts must match the local digest before reuse.
"""
conditions = conditions or {}
is_path = isinstance(payload, (str, os.PathLike))
if is_path:
payload = os.fspath(payload)
size = os.path.getsize(payload)
body_hash = file_sha256(payload)
def body_iter_factory() -> Iterable[bytes]:
return stream_file(payload)
body: bytes | None = None
else:
size = len(payload)
body_hash = hashlib.sha256(payload).hexdigest()
body_iter_factory = None
body = payload
cache_control = cache_control_for(key)
extra = dict(conditions)
if cache_control:
extra["Cache-Control"] = cache_control
url = R2Scope.configured().object_url(base, bucket, key)
last_error: Exception | None = None
response: Response | None = None
for _ in range(3):
try:
if is_path and size > multipart_part_size:
from .r2_multipart import upload_file
upload_file(url, cast(str, payload), size, creds, content_type, extra, multipart_part_size)
break
response = (
fetcher(method="PUT", url=url, body_hash=body_hash, body=body,
content_length=size, extra_headers=extra)
if fetcher is not None
else signed_request(
"PUT", url, body=body, body_iter_factory=body_iter_factory,
body_hash=body_hash, content_length=size, creds=creds, now=now,
content_type=content_type, extra_headers=extra,
)
)
break
except R2RequestError as err:
last_error = err
if err.status == 412 and is_path and conditions.get("If-None-Match") == "*":
# Immutable conflict: verify the remote bytes ARE ours.
verify_remote_artifact(
url, creds, now, size, body_hash, algorithm="sha256", encoding="hex",
fetcher=fetcher,
)
response = None
break
if err.status in _RETRYABLE_STATUSES:
time.sleep(1.0)
continue
raise
else:
raise last_error if last_error else R2RequestError("PUT", key, 0)
if response is not None and response.status >= 400:
raise R2RequestError("PUT", key, response.status, response.text())
# HEAD verifies the upload landed at the right size; fall back to a
# 1-byte ranged GET (Content-Range carries the authoritative total).
head = (
fetcher(method="HEAD", url=url, body_hash=EMPTY_SHA)
if fetcher is not None
else signed_request("HEAD", url, creds=creds, now=now)
)
remote_size = head.header("content-length")
if remote_size is None:
ranged = (
fetcher(method="GET", url=url, body_hash=EMPTY_SHA,
extra_headers={"Range": "bytes=0-0"})
if fetcher is not None
else signed_request("GET", url, creds=creds, now=now,
extra_headers={"Range": "bytes=0-0"})
)
content_range = ranged.header("content-range") or ""
match = re.search(r"bytes 0-0/(\d+)", content_range)
if not match:
raise R2RequestError(
"GET", key, ranged.status,
f"could not determine remote size (content-range: {content_range!r})",
)
remote_size = match.group(1)
if str(remote_size) != str(size):
raise R2RequestError("HEAD", key, head.status, f"size mismatch (remote {remote_size}, local {size})")
from scripts.releases.upload_summary import note
note(key)
print(f"OK r2: {key} ({size} bytes)")
def finalize(tag: str, dir: str, variant: str | None = None, archive: str | None = None) -> None:
"""Lazy re-export of the Darwin finalize so callers can treat
scripts.releases.r2 as the single transport surface."""
from . import darwin as darwin_module
darwin_module.finalize(tag=tag, dir=dir, variant=variant, archive=archive)
def put(
tag: str,
key: str,
file: str,
key_is_full: bool = False,
immutable: bool = False,
) -> None:
"""Upload one artifact. `key` is a filename archived under
releases/tag/<tag>/ unless `key_is_full`. `immutable=True` sends
If-None-Match: * to reject replacement of existing bytes."""
creds, base, bucket = credentials()
now = amz_timestamp()
key_path = key if key_is_full else staging_key_for(tag, key)
conditions = {"If-None-Match": "*"} if immutable else {}
put_object(creds, base, bucket, key_path, file, now, content_type_for(key), conditions)
# ---------------------------------------------------------------------------
# list + prune helpers (pure)
# ---------------------------------------------------------------------------
def parse_list_xml(xml: str) -> dict:
"""Parse a ListObjectsV2 XML body into keys/lastModified/truncated/nextToken."""
import html
keys = re.findall(r"<Key>([^<]+)</Key>", xml)
keys = [html.unescape(k) for k in keys]
last_modified: dict[str, int] = {}
truncated = "<IsTruncated>true</IsTruncated>" in xml
token_match = re.search(r"<NextContinuationToken>([^<]+)</NextContinuationToken>", xml)
def _parse_epoch(text: str) -> int:
# R2 emits both '...Z' and fractional '...mmmZ' forms; accept either.
text = text.strip()
if "." in text:
return int(
datetime.strptime(text, "%Y-%m-%dT%H:%M:%S.%fZ")
.replace(tzinfo=timezone.utc)
.timestamp()
)
return int(
datetime.strptime(text, "%Y-%m-%dT%H:%M:%SZ")
.replace(tzinfo=timezone.utc)
.timestamp()
)
for block in re.findall(r"<Contents>([\s\S]*?)</Contents>", xml):
key_match = re.search(r"<Key>([^<]+)</Key>", block)
lm_match = re.search(r"<LastModified>([^<]+)</LastModified>", block)
if key_match and lm_match:
last_modified[html.unescape(key_match.group(1))] = _parse_epoch(lm_match.group(1))
return {
"keys": keys,
"lastModified": last_modified,
"truncated": truncated,
"nextToken": html.unescape(token_match.group(1)) if token_match else None,
}
def list_objects(
prefix: str = "",
creds: dict[str, str] | None = None,
base: str | None = None,
bucket: str | None = None,
fetcher: Callable[..., Response] | None = None,
) -> dict:
if creds is None:
creds, base, bucket = credentials()
assert creds is not None and base is not None and bucket is not None
scope = R2Scope.configured()
prefix = scope.listing_prefix(prefix)
keys: list[str] = []
last_modified: dict[str, int] = {}
token: str | None = None
while True:
params: dict[str, str] = {"list-type": "2", "max-keys": "1000"}
if prefix:
params["prefix"] = prefix
if token:
params["continuation-token"] = token
query = canonical_query(params)
url = f"{scope.bucket_url(base, bucket)}?{query}"
if fetcher is not None:
response = fetcher(method="GET", url=url, body_hash=EMPTY_SHA)
if response.status >= 400:
raise R2RequestError("GET", f"/{bucket}", response.status)
else:
response = signed_request("GET", url, creds=creds, now=amz_timestamp())
parsed = parse_list_xml(response.text())
keys.extend(parsed["keys"])
last_modified.update(parsed["lastModified"])
if not parsed["truncated"] or not parsed["nextToken"]:
break
token = parsed["nextToken"]
return {"keys": [scope.logical_key(key) for key in keys],
"lastModified": {scope.logical_key(key): value for key, value in last_modified.items()}}
def get_object(
creds: dict[str, str], base: str, bucket: str, key: str, now: str,
fetcher: Callable[..., Response] | None = None,
) -> str | None:
"""GET one object's body, or None when it does not exist / cannot be read."""
url = R2Scope.configured().object_url(base, bucket, key)
response = (
fetcher(method="GET", url=url, body_hash=EMPTY_SHA)
if fetcher is not None
else signed_request("GET", url, creds=creds, now=now)
)
return response.text() if response.status < 400 else None
def canary_doomed_keys(keys: list[str], cutoff: str) -> list[str]:
"""Keys whose own canary date (YYYYMMDD in the name) is before `cutoff`."""
from hermes_cli.update_channel import _CANARY_TAG_RE
tag_re = re.compile(_CANARY_TAG_RE.pattern.strip("^$"))
doomed = []
for key in keys:
match = tag_re.search(key)
if match and match.group(0).split("+canary.", 1)[1][:8] < cutoff:
doomed.append(key)
return doomed
def publish_feed_uploads(plan: dict, upload: Callable[[str, str], None]) -> None:
"""Feed publish order: the immutable .msixbundle FIRST, the pointer LAST."""
upload(f"{plan['channelDir']}/{plan['bundleFilename']}", plan["bundleFile"])
upload(f"{plan['channelDir']}/{plan['appinstallerName']}", plan["appinstallerFile"])
def referenced_feed_bundle_filenames(appinstaller_xml: str | None) -> list[str]:
return [uri.rsplit("/", 1)[-1] for uri in _feed_bundle_uris(appinstaller_xml)]
def feed_referenced_keys(dir_key: str, appinstaller_xml: str | None) -> list[str]:
"""Full bucket keys a manifest references: each referenced bundle inside
its feed dir, plus any absolute /releases/... path Uri (tag-archive
targets), protected by exact key."""
keys: list[str] = []
for uri in _feed_bundle_uris(appinstaller_xml):
keys.append(f"{dir_key}/{uri.rsplit('/', 1)[-1]}")
if "/" in uri and uri.startswith(("http://", "https://")):
from urllib.parse import urlsplit, unquote
path = urlsplit(uri).path
if path.startswith("/releases/"):
keys.append(unquote(path[1:]))
return keys
def _feed_bundle_uris(appinstaller_xml: str | None) -> list[str]:
"""Bundle basenames a .appinstaller manifest still references, parsed
from the KNOWN generated shape (MainPackage/MainBundle Uri attributes
only — never any Uri=" in the document). Unrecognized/empty -> [] and
the pruner treats [] as "block this directory" (fail closed)."""
xml = str(appinstaller_xml or "").strip()
if not re.fullmatch(
r"(?:<\?xml[^?]*\?>\s*)?<AppInstaller\b[^>]*>[\s\S]*</AppInstaller>", xml
):
return []
elements = re.findall(r"<(?:MainPackage|MainBundle)\b[^>]*/>", xml)
if len(elements) != 1:
return []
uri_match = re.search(r"\bUri=\"([^\"]+)\"", elements[0])
if uri_match and re.search(r"\.(?:msixbundle|msix)$", uri_match.group(1), re.I):
return [uri_match.group(1)]
return []
def stale_feed_bundle_keys(
keys: list[str],
feed_xml_by_dir: dict[str, list[str | None]],
last_modified_ms: dict[str, float] | None = None,
cutoff_ms: float = float("-inf"),
) -> list[str]:
"""Canary feed-dir retention: fail-closed on every unknown. A bundle is
doomed when its feed dir's manifests were ALL readable AND it is
referenced by none of them AND its list LastModified predates cutoff.
Unreadable/unrecognized manifest (zero references) blocks the whole dir;
stable dirs and unknown dirs are never pruned; a missing LastModified
keeps the object."""
doomed: list[str] = []
for dir_key, manifests in (feed_xml_by_dir or {}).items():
if not re.search(r"/canary$", dir_key.rstrip("/")):
continue # canaries only
referenced: set[str] = set()
blocked = False
for xml in manifests or []:
names = referenced_feed_bundle_filenames(xml)
if not names:
blocked = True
print(f"::warning::feed manifest unreadable/unrecognized, skipping feed retention for {dir_key}/")
break
referenced.update(names)
if blocked or not referenced:
continue
prefix = f"{dir_key.rstrip('/')}/"
for key in keys:
if not key.startswith(prefix):
continue
if not re.search(r"\.(?:msixbundle|msix)$", key, re.I):
continue # pointers + metadata stay
if key[len(prefix):] in referenced:
continue
lm = (last_modified_ms or {}).get(key)
if lm is None or lm != lm or lm in (float("inf"),) or lm >= cutoff_ms:
continue # keep-days grace (fail-closed)
doomed.append(key)
return doomed
# ---------------------------------------------------------------------------
# prune-canaries
# ---------------------------------------------------------------------------
def prune(
keep_days: int,
dry_run: bool = False,
fetcher: Callable[..., Response] | None = None,
now_epoch: float | None = None,
) -> None:
"""Cutoff dated by the canary suffix in the KEY (a re-uploaded old tag
never resets its clock). Live referenced objects are never deleted."""
creds, base, bucket = credentials()
current = now_epoch if now_epoch is not None else time.time()
cutoff_date = time.strftime(
"%Y%m%d", time.gmtime(current - keep_days * 86400)
)
cutoff_ms = current - keep_days * 86400
listing = list_objects(creds=creds, base=base, bucket=bucket, fetcher=fetcher)
keys = listing["keys"]
last_modified = listing["lastModified"]
now = amz_timestamp()
feed_xml_by_dir: dict[str, list[str]] = {}
protected: set[str] = set()
for key in [k for k in keys if k.endswith(".appinstaller")]:
dir_key = key[: key.rfind("/")]
xml = get_object(creds, base, bucket, key, now, fetcher=fetcher)
if not _feed_bundle_uris(xml):
raise RuntimeError(f"Cannot establish live references from {key}; refusing to prune")
feed_xml_by_dir.setdefault(dir_key, []).append(xml or "")
for protected_key in feed_referenced_keys(dir_key, xml):
protected.add(protected_key)
# Darwin feeds protect their artifacts (key + .blockmap) too.
from . import darwin as darwin_module
for key in [k for k in keys if k.startswith("releases/darwin/") and k.endswith("-mac.yml")]:
text = get_object(creds, base, bucket, key, now, fetcher=fetcher)
if text is None:
# Fail closed: an unreadable Darwin feed might reference the very
# objects the pruner is about to delete.
raise RuntimeError(f"Cannot read Darwin feed {key}; refusing to prune")
for reference in darwin_module.mac_feed_references(text):
protected.add(reference)
doomed = [
key
for key in [
*canary_doomed_keys(keys, cutoff_date),
*stale_feed_bundle_keys(keys, feed_xml_by_dir, last_modified, cutoff_ms),
]
if key not in protected
]
if not doomed:
print(f"OK r2: no canary objects older than {keep_days} days")
return
for key in sorted(doomed):
if dry_run:
print(f"(dry-run) would delete r2:{key}")
continue
url = R2Scope.configured().object_url(base, bucket, key)
response = (
fetcher(method="DELETE", url=url, body_hash=EMPTY_SHA)
if fetcher is not None
else signed_request("DELETE", url, creds=creds, now=now)
)
if response.status >= 400:
raise R2RequestError("DELETE", key, response.status)
print(f"deleted r2:{key}")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
USAGE = """usage:
python -m scripts.releases.r2 put --tag vX.Y.Z --key <filename> --file <path> [--key-is-full] [--immutable]
python -m scripts.releases.r2 finalize --tag vX.Y.Z --dir <staging-dir> [--variant light]
python -m scripts.releases.r2 list [--prefix <p>]
python -m scripts.releases.r2 prune-canaries --keep-days <n> [--dry-run]
--key-is-full: the --key is a FULL object key (e.g. releases/win32/stable/...),
not a filename to archive under releases/tag/<tag>/.
--immutable: send If-None-Match: * (refuse to overwrite an existing object).
"""
_VALUE_FLAGS = {"--tag", "--key", "--file", "--prefix", "--keep-days", "--dir", "--variant"}
_BOOL_FLAGS = {"--dry-run", "--key-is-full", "--immutable"}
def main(argv: list[str] | None = None) -> None:
argv = list(sys.argv[1:] if argv is None else argv)
if not argv:
print(USAGE, file=sys.stderr)
raise SystemExit(2)
cmd, rest = argv[0], argv[1:]
args: dict[str, str | bool] = {}
i = 0
while i < len(rest):
flag = rest[i]
if flag in _VALUE_FLAGS:
i += 1
if i >= len(rest):
print(USAGE, file=sys.stderr)
raise SystemExit(2)
args[flag[2:]] = rest[i]
elif flag in _BOOL_FLAGS:
args[flag[2:]] = True
else:
print(USAGE, file=sys.stderr)
raise SystemExit(2)
i += 1
if cmd == "put":
tag = args.get("tag") or os.environ.get("HERMES_PAYLOAD_TAG")
key, file = args.get("key"), args.get("file")
if not tag or not key or not file:
print(USAGE, file=sys.stderr)
raise SystemExit(2)
immutable = bool(args.get("immutable"))
if not immutable:
# Preserve the JS CLI's derivation: darwin artifact staging keys
# are immutable per-release (rerun-safe via If-None-Match: *).
key_path = key if args.get("key-is-full") else staging_key_for(str(tag), str(key))
immutable = key_path.startswith("releases/tag/") and bool(
re.search(r"-mac-(?:arm64|x64)\.(?:zip|dmg)(?:\.blockmap)?$", key_path)
)
put(
tag=tag,
key=key,
file=file,
key_is_full=bool(args.get("key-is-full")),
immutable=bool(args.get("immutable")) or immutable,
)
elif cmd == "finalize":
tag, dir_path = args.get("tag"), args.get("dir")
if not tag or not dir_path:
print(USAGE, file=sys.stderr)
raise SystemExit(2)
from . import darwin as darwin_module
darwin_module.finalize(tag=tag, dir=dir_path, variant=args.get("variant"))
elif cmd == "list":
listing = list_objects(prefix=str(args.get("prefix") or ""))
for key in listing["keys"]:
print(key)
elif cmd == "prune-canaries":
try:
keep_days = int(args.get("keep-days", ""))
except ValueError:
print(USAGE, file=sys.stderr)
raise SystemExit(2)
if keep_days <= 0:
print(USAGE, file=sys.stderr)
raise SystemExit(2)
prune(keep_days, dry_run=bool(args.get("dry-run")))
else:
print(USAGE, file=sys.stderr)
raise SystemExit(2)
if __name__ == "__main__":
# Siblings import the canonical module; run the CLI on that same identity.
from scripts.releases.r2 import main as cli_main
try:
cli_main()
except SystemExit:
raise
except Exception as err: # pragma: no cover — CLI error surface
print(f"::error::{err}", file=sys.stderr)
raise SystemExit(1)