Files
hermes-agent/scripts/releases/channels.py
Siddharth Balyan 09681e7959 fix(release): --branding stable works before stable has a channel record (#129541)
`--branding stable` copies the identity from the published stable channel
record on R2, and refused when there was none. Production has no
releases/channels/stable.json yet: the channel pipeline merged after the
last stable release (v2026.9.24), and no stable release has published
through it since. So every stable-branded channel build failed at
allocation ("none is published"), including the first real dispatch.

When the stable record does not exist, use product_identity(), the
identity channel_publish already holds every stable release to. A stable
record without a head still refuses, so once the first stable release
publishes, its record is the only authority again and this path stops
running by itself.

The allocation step needs node for product_identity(), so the workflow's
identity-reader setup also runs for branding == 'stable'.
2026-10-01 01:30:24 +05:30

466 lines
24 KiB
Python

"""Authenticated channel administration; mutable records use ETag CAS, never CDN state."""
from __future__ import annotations
from copy import deepcopy
import hashlib
import http.client
import secrets
import uuid
from hermes_cli.release_channels import (
ChannelError, ChannelNotFound, ChannelReader, artifact_key, build_prefix,
canonical_json, channel_key, decode_json, package_versions, public_base as validate_public_base,
validate_name, validate_record, validate_repository, validate_request, validate_manifest, require_sha256,
)
from scripts.releases import r2
BRANDINGS = ("preview", "stable")
class ChannelConflict(ChannelError):
pass
class PublicVisibilityError(ChannelError):
"""The authenticated write committed; do not roll it back on a stale CDN read."""
class R2ChannelStore:
def __init__(self, creds: dict, base: str, bucket: str, *, scope=None):
from scripts.releases.r2_scope import R2Scope
self.creds, self.base, self.bucket = creds, base.rstrip("/"), bucket
self.scope = R2Scope.configured() if scope is None else scope
def get(self, key: str) -> tuple[bytes, str] | None:
url = self.scope.object_url(self.base, self.bucket, artifact_key(key))
try:
response = r2.signed_request("GET", url, creds=self.creds, now=r2.amz_timestamp())
except r2.R2RequestError as exc:
if exc.status == 404:
return None
raise
etag = response.header("etag")
if not etag:
raise ChannelError("Authenticated channel read missing ETag")
body = response._body
if len(body) > 4 * 1024 * 1024:
raise ChannelError("Channel metadata exceeds size limit")
return body, etag
def put(self, key: str, body: bytes, etag: str | None = None) -> None:
url = self.scope.object_url(self.base, self.bucket, artifact_key(key))
condition = {"If-Match": etag} if etag is not None else {"If-None-Match": "*"}
condition["Cache-Control"] = r2.cache_control_for(key) or "no-store"
try:
r2.signed_request("PUT", url, body=body, body_hash=hashlib.sha256(body).hexdigest(),
content_length=len(body), content_type="application/json",
creds=self.creds, now=r2.amz_timestamp(), extra_headers=condition, tries=1)
except (r2.R2RequestError, OSError, http.client.HTTPException) as exc:
# Lost success responses and conditional retries are settled by exact bytes.
current = self.get(key)
if current is not None and current[0] == body:
from scripts.releases.upload_summary import note
note(self.scope.key(key))
return
if isinstance(exc, r2.R2RequestError) and exc.status == 412:
raise ChannelConflict(f"Channel write conflict: {key}") from exc
raise ChannelError(f"Channel write outcome uncertain: {key}; inspect before retry") from exc
current = self.get(key)
if current is None or current[0] != body:
raise ChannelConflict(f"Channel changed before authenticated readback: {key}")
from scripts.releases.upload_summary import note
note(self.scope.key(key))
def keys(self, prefix: str) -> list[str]:
keys, seen = [], set()
prefix = self.scope.listing_prefix(prefix)
token = None
while True:
params = {"list-type": "2", "max-keys": "1000", "prefix": prefix}
if token is not None:
params["continuation-token"] = token
url = f"{self.scope.bucket_url(self.base, self.bucket)}?{r2.canonical_query(params)}"
response = r2.signed_request("GET", url, creds=self.creds, now=r2.amz_timestamp())
page = r2.parse_list_xml(response.text())
keys.extend(self.scope.logical_key(key) for key in page["keys"])
if not page["truncated"]:
return keys
token = page["nextToken"]
if not token or token in seen:
raise ChannelError("Incomplete or repeated channel pagination token")
seen.add(token)
def preview_identity(name: str, token: str) -> dict:
validate_name(name)
pascal = f"HermesChannel{token}"
return {"token": token, "displayName": f"Hermes {name}",
"appId": f"ai.hermes.channel.h{token}", "appNamePascal": pascal,
"artifactNamePascal": pascal, "cliName": f"hermes-{name}",
"windowsExecutableName": pascal, "msixAppIdWithOrg": f"NousResearch.{pascal}"}
class ChannelPublisher:
def __init__(self, store: R2ChannelStore, repository: str, public_base: str,
authorize, verify_build=None):
self.store = store
self.repository = validate_repository(repository)
self.public_base = validate_public_base(public_base)
self.reader = ChannelReader(self.public_base, self.repository)
self.authorize = authorize
self.verify_build = verify_build
def _read(self, name: str) -> tuple[dict, str] | None:
value = self.store.get(channel_key(name))
if value is None:
return None
body, etag = value
return validate_record(decode_json(body), name=name, repository=self.repository), etag
def _write(self, key: str, value: dict, etag: str | None = None) -> None:
body = canonical_json(value)
self.store.put(key, body, etag)
try:
visible = self.reader.read_bytes(key)
except ChannelError as exc:
raise PublicVisibilityError(f"Committed {key}; public visibility verification failed") from exc
if visible != body:
current = self.store.get(key)
if current is not None and current[0] != body:
# A real subsequent CAS is not a stale CDN response. Allocation may
# retry (leaving a gap); retirement must re-qualify the changed head.
raise ChannelConflict(f"Committed {key}; superseded before public readback")
raise PublicVisibilityError(f"Committed {key}; public visibility verification failed")
def _preview(self, action: str, record: dict) -> None:
if record["policy"] != "preview":
raise ChannelError("Protected release policy requires its existing release gate")
if record["state"] != "active":
raise ChannelError("Retired channel is permanently closed to publication")
self.authorize(action, record)
def _stable_identity(self) -> dict:
"""The identity installed stable clients already carry, read from its channel record.
Before any stable release has published through channels there is no
record to read, so use the identity channel_publish holds the first
stable release to. A record without a head still refuses: once stable
exists on channels, its record is the only authority.
"""
current = self._read("stable")
if current is None:
from scripts.releases.channel_releases import product_identity
return product_identity("v0.0.0")
if current[0]["head"] is None:
raise ChannelError("Stable branding copies the published stable channel identity; none is published")
return deepcopy(current[0]["identity"])
def _require_branding(self, record: dict, branding: str) -> None:
# Identity is fixed when the channel is created, so a mismatch means a
# different channel name, never a silent rebrand of installed clients.
identity = record["identity"]
if branding == "stable":
matches = identity == self._stable_identity()
else:
matches = identity == preview_identity(record["name"], identity["token"])
if not matches:
raise ChannelError(f"Channel {record['name']} was created with different branding; "
"pick a new channel name or pass its original --branding")
def create(self, name: str, branding: str = "preview") -> dict:
validate_name(name)
if branding not in BRANDINGS:
raise ChannelError(f"Unknown channel branding: {branding}")
existing = self._read(name)
if existing:
self._preview("create", existing[0])
self._require_branding(existing[0], branding)
return existing[0]
record = {"schema": 1, "name": name, "repository": self.repository,
"policy": "preview", "state": "active", "revision": 1,
"nextSequence": 1, "head": None}
self.authorize("create", record)
if branding == "stable":
# Shares the stable app's name, icon and package ID, so no token is reserved.
record["identity"] = self._stable_identity()
else:
for _ in range(16):
token = secrets.token_hex(8)
try:
self.store.put(f"releases/channel-identities/{token}.json",
canonical_json({"schema": 1, "repository": self.repository, "channel": name}))
record["identity"] = preview_identity(name, token)
break
except ChannelConflict:
continue
else:
raise ChannelConflict("Could not reserve a channel identity")
validate_record(record)
try:
self._write(channel_key(name), record)
except ChannelConflict:
winner = self._read(name)
if winner is None:
raise
self._preview("create", winner[0])
self._require_branding(winner[0], branding)
return winner[0]
return record
def allocate(self, name: str, commit: str, source_version: str,
bundle_env: dict | None = None, controller_commit: str | None = None) -> dict:
from scripts.releases.bundle_env import validate
bundle_env = validate({} if bundle_env is None else bundle_env)
for _ in range(16):
current = self._read(name)
if current is None:
raise ChannelNotFound(f"Channel not found: {name}")
record, etag = current
self._preview("allocate", record)
sequence = record["nextSequence"]
version, windows_version = package_versions(sequence)
request = {"schema": 1, "buildId": uuid.uuid4().hex, "channel": name,
"sequence": sequence, "repository": self.repository, "commit": commit,
"sourceVersion": source_version, "version": version, "windowsVersion": windows_version,
"identity": deepcopy(record["identity"]), "bundleEnv": bundle_env,
"publicBase": self.public_base}
if controller_commit is not None:
request["controllerCommit"] = controller_commit
validate_request(request, repository=self.repository, base_url=self.public_base)
# Distinguish competing allocations: the build ID makes otherwise
# identical CAS bodies unique, so a loser's readback recovery can
# never mistake another allocation's write for its own.
updated = {**record, "revision": record["revision"] + 1, "nextSequence": sequence + 1,
"lastAllocation": {"buildId": request["buildId"], "sequence": sequence}}
try:
self._write(channel_key(name), updated, etag)
except ChannelConflict:
continue
self._write(build_prefix(request["buildId"]) + "request.json", request)
return request
raise ChannelConflict("Channel allocation remained contended")
def _protected(self, action: str, record: dict, request: dict, policy: str, release_gate) -> None:
if policy not in {"stable-release", "canary-release"} or record["policy"] != policy:
raise ChannelError("Protected release policy mismatch")
if record["state"] != "active":
raise ChannelError("Retired channel is permanently closed to publication")
from scripts.releases.semver import is_canary_version, is_release_version
version = request["version"]
if not is_release_version(version) or is_canary_version(version) != (policy == "canary-release"):
raise ChannelError("Release version does not match protected policy")
self.authorize(action, record)
if release_gate(request) is not True:
raise ChannelError("Protected publication requires the successful release gate")
def allocate_protected(self, name: str, commit: str, source_version: str, *,
release_tag: str, version: str, windows_version: str,
identity: dict, policy: str, release_gate,
archive_ref: str | None = None) -> dict:
"""Reserve accepted legacy bytes, never authorize a custom build as stable."""
facts = {"schema": 1, "channel": name, "repository": self.repository, "commit": commit,
"sourceVersion": source_version, "version": version, "windowsVersion": windows_version,
"releaseTag": release_tag, "identity": deepcopy(identity), "bundleEnv": {},
"publicBase": self.public_base}
if archive_ref is not None:
facts["archiveRef"] = archive_ref
build_id = hashlib.sha256(canonical_json(facts)).hexdigest()[:32]
key = build_prefix(build_id) + "request.json"
for _ in range(16):
current = self._read(name)
record, etag = current if current else ({
"schema": 1, "name": name, "repository": self.repository, "policy": policy,
"state": "active", "revision": 1, "nextSequence": 1, "head": None,
"identity": deepcopy(identity)}, None)
validate_record(record, repository=self.repository)
request = {**facts, "buildId": build_id, "sequence": record["nextSequence"]}
validate_request(request, repository=self.repository, base_url=self.public_base, policy=policy)
self._protected("allocate-protected", record, request, policy, release_gate)
if record["identity"] != identity:
raise ChannelError("Protected release identity mismatch")
existing = self.store.get(key)
if existing is not None:
pinned = self.request(build_id)
if {k: v for k, v in pinned.items() if k != "sequence"} != {k: v for k, v in request.items() if k != "sequence"}:
raise ChannelError("Protected immutable request differs from accepted release")
return pinned
updated = {**record, "revision": record["revision"] + 1,
"nextSequence": request["sequence"] + 1}
try:
self._write(channel_key(name), updated, etag)
except ChannelConflict:
continue
try:
self._write(key, request)
except ChannelConflict:
# Identical concurrent releases adopt the first immutable request;
# their other reservation is only an unused sequence gap.
continue
return request
raise ChannelConflict("Protected release allocation remained contended")
def request(self, build_id: str, sha256: str | None = None) -> dict:
if sha256 is not None:
require_sha256(sha256)
key = build_prefix(build_id) + "request.json"
found = self.store.get(key)
if found is None:
raise ChannelNotFound(f"Request not found: {build_id}")
body = found[0]
if sha256 is not None and hashlib.sha256(body).hexdigest() != sha256:
raise ChannelError("Request SHA256 mismatch")
decoded = decode_json(body)
current = self._read(validate_name(decoded.get("channel")))
if current is None:
raise ChannelNotFound("Request channel not found")
request = validate_request(decoded, repository=self.repository, base_url=self.public_base,
policy=current[0]["policy"])
if request["buildId"] != build_id:
raise ChannelError("Request build ID mismatch")
return request
def list(self) -> list[dict]:
result = []
for key in self.store.keys("releases/channels/"):
name = key.removeprefix("releases/channels/").removesuffix(".json")
if channel_key(name) != key:
raise ChannelError("Invalid object in channel namespace")
current = self._read(name)
if current is None:
raise ChannelError("Listed channel disappeared")
result.append(current[0])
return result
def bootstrap(self, record: dict, manifest: dict | None = None, *, publish: bool = False) -> dict:
record = deepcopy(validate_record(record, repository=self.repository))
if record["policy"] not in {"stable-release", "canary-release", "source-branch"} or record["state"] != "active":
raise ChannelError("Bootstrap requires an active protected release policy")
self.authorize("bootstrap", record)
if record["policy"] == "source-branch":
if manifest is not None:
raise ChannelError("Source branch bootstrap has no artifact manifest")
else:
if record["head"] is None or manifest is None:
raise ChannelError("Bootstrap requires actual accepted release metadata")
validate_manifest(manifest, record, self.public_base)
if hashlib.sha256(canonical_json(manifest)).hexdigest() != record["head"]["sha256"]:
raise ChannelError("Bootstrap manifest SHA256 mismatch")
if self.verify_build is None or self.verify_build(manifest["request"], manifest) is not True:
raise ChannelError("Bootstrap requires release qualification")
if publish:
if manifest is not None:
self._write(build_prefix(manifest["request"]["buildId"]) + "request.json", manifest["request"])
self._write(record["head"]["manifestKey"], manifest)
self._write(channel_key(record["name"]), record)
return record
def promote(self, build_id: str) -> dict:
return self._promote(build_id)
def promote_protected(self, build_id: str, *, policy: str, release_gate) -> dict:
return self._promote(build_id, policy=policy, release_gate=release_gate)
def _promote(self, build_id: str, *, policy: str | None = None, release_gate=None) -> dict:
request = self.request(build_id)
key = build_prefix(build_id) + "build.json"
found = self.store.get(key)
if found is None:
raise ChannelNotFound("Complete build manifest not found")
raw = found[0]
manifest = decode_json(raw)
if manifest.get("request") != request:
raise ChannelError("Build manifest differs from immutable admitted request")
head = {"buildId": build_id, "sequence": request["sequence"], "manifestKey": key,
"sha256": hashlib.sha256(raw).hexdigest()}
for _ in range(16):
current = self._read(request["channel"])
if current is None:
raise ChannelNotFound("Publication channel not found")
record, etag = current
if policy is None:
self._preview("promote", record)
else:
self._protected("promote-protected", record, request, policy, release_gate)
if record["identity"] != request["identity"] or request["sequence"] >= record["nextSequence"]:
raise ChannelError("Request channel identity or allocation mismatch")
candidate = {**record, "head": head, "revision": record["revision"] + 1}
validate_manifest(manifest, candidate, self.public_base)
if record["head"] is not None and record["head"]["sequence"] >= request["sequence"]:
if record["head"] == head:
self.reader.read_bytes(key, head["sha256"])
return record
raise ChannelError("A newer build is published; stale completion refused")
if policy is not None and record["head"] is not None:
from scripts.releases.semver import compare
from scripts.releases.stable import windows_version
from hermes_cli.update_channel import canary_timestamp
previous_head = record["head"]
previous = self.store.get(previous_head["manifestKey"])
if previous is None or hashlib.sha256(previous[0]).hexdigest() != previous_head["sha256"]:
raise ChannelError("Previous protected manifest is unavailable or changed")
previous_request = validate_manifest(decode_json(previous[0]), record, self.public_base)["request"]
if policy == "canary-release":
current_stamp = canary_timestamp(request["releaseTag"])
previous_stamp = canary_timestamp(previous_request["releaseTag"])
source_increases = (
current_stamp is not None
and previous_stamp is not None
and current_stamp > previous_stamp
)
else:
source_increases = compare(request["version"], previous_request["version"]) > 0
if (not source_increases
or windows_version(request["windowsVersion"]) <= windows_version(previous_request["windowsVersion"])):
raise ChannelError("Protected native versions must increase; stale completion refused")
if self.verify_build is None or self.verify_build(request, manifest) is not True:
raise ChannelError("Publication requires complete native build qualification")
self.reader.read_bytes(key, head["sha256"])
try:
self._write(channel_key(record["name"]), candidate, etag)
except ChannelConflict:
continue
return candidate
raise ChannelConflict("Channel promotion remained contended")
def retire(self, name: str, destination: str, minimum_version: str,
*, publish: bool = True) -> dict:
validate_name(destination)
if name == destination:
raise ChannelError("Channel retirement cycle")
current = self._read(name)
if current is None:
raise ChannelNotFound(f"Channel not found: {name}")
record, etag = current
self._preview("retire", record)
target_read = self._read(destination)
if target_read is None:
raise ChannelNotFound("Retirement destination not found")
target, target_etag = target_read
# Do not discard incomparable intermediate identity floors.
if target["state"] != "active" or target["policy"] != "stable-release" or target["head"] is None:
raise ChannelError("Retirement requires a directly active stable-release destination")
target_raw = self.reader.read_bytes(target["head"]["manifestKey"], target["head"]["sha256"])
manifest = validate_manifest(decode_json(target_raw), target, self.public_base)
# Two-tier retirement: the kind is derived, never asserted by the caller.
# A suffixed (canary/commit) identity can never match stable's, so
# "in-place" cannot be mis-assigned; the kind is a client routing hint.
identity = record.get("identity")
kind = "in-place" if identity is not None and identity == target.get("identity") else "discontinued"
retired = {**record, "state": "retired", "revision": record["revision"] + 1,
"destination": destination, "minimumVersion": minimum_version,
"destinationHead": target["head"], "receiverProtocol": 1,
"receiver": {"kind": kind},
"lastHead": record["head"]}
validate_record(retired)
if tuple(map(int, manifest["request"]["sourceVersion"].split("."))) < tuple(map(int, minimum_version.split("."))):
raise ChannelError("Destination does not meet minimum version")
if manifest.get("receiverProtocol") != 1:
raise ChannelError("Stable release does not declare retirement receiver support")
if self._read(destination) != (target, target_etag):
raise ChannelConflict("Retirement destination changed; review the new target before retrying")
if publish:
self._write(channel_key(name), retired, etag)
return retired