A preview channel always minted its own side-by-side identity (Hermes NAME, ai.hermes.channel.h<token>), and a plain --build-commit carries Hermes Agent <sha7> with a red icon, so there was no way to ship an exact commit under the official name, icon and package ID. --branding stable (with --channel) makes channel creation copy the identity from the published stable channel record instead of reserving a new token. Branding is fixed at creation like the rest of the identity: re-dispatching with the other branding is refused, as is stable branding before a stable is published or in a disposable namespace. --branding without --channel is refused rather than silently falling back to a sha-branded one-off build. The workflow gains a `branding` input, forwarded to the allocation step and the dispatch log so the printed release.py replay matches what was sent.
170 lines
8.8 KiB
Python
170 lines
8.8 KiB
Python
"""Privileged allocation: probe CAS/CDN and allocate the channel this run builds.
|
|
|
|
The workflow's allocation step runs here for BOTH the disposable test path
|
|
(``disposable_channel`` input, scoped to a ``ci-disposable/`` namespace) and the
|
|
production preview path (``channel`` input, unscoped production namespace). The
|
|
local ``release.py --channel`` command no longer touches R2: it only dispatches
|
|
this workflow, and this privileged step creates the channel and mints the
|
|
immutable build request that the build legs consume via job outputs.
|
|
""" # noqa: E501
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from hermes_cli.release_channels import canonical_json, channel_key, validate_name
|
|
from scripts.releases import commit_build, r2
|
|
from scripts.releases.channels import ChannelConflict, ChannelPublisher, R2ChannelStore
|
|
from scripts.releases.job_groups import selects_all
|
|
from scripts.releases.r2_scope import R2Scope, channel_public_base, require_run
|
|
|
|
|
|
def probe(publisher: ChannelPublisher, commit: str, version: str, controller: str) -> None:
|
|
"""Exercise real create, CAS, stale-writer refusal, list and CDN readback."""
|
|
scope = publisher.store.scope
|
|
if not scope.prefix or publisher.public_base != channel_public_base():
|
|
raise ValueError("CAS/CDN probes require the configured disposable namespace")
|
|
name = "ci-cas-probe"
|
|
publisher.create(name)
|
|
current = publisher._read(name)
|
|
if current is None:
|
|
raise ValueError("Probe channel disappeared")
|
|
old, etag = current
|
|
first = publisher.allocate(name, commit, version, controller_commit=controller)
|
|
second = publisher.allocate(name, commit, version, controller_commit=controller)
|
|
try:
|
|
publisher.store.put(channel_key(name), canonical_json(old), etag)
|
|
except ChannelConflict:
|
|
pass
|
|
else:
|
|
raise ValueError("R2 accepted a stale conditional channel write")
|
|
if (second["sequence"] <= first["sequence"]
|
|
or publisher.request(second["buildId"]) != second
|
|
or name not in {record["name"] for record in publisher.list()}):
|
|
raise ValueError("Disposable channel CAS/list probe failed")
|
|
visible = publisher.reader.resolve(name).terminal
|
|
if visible["nextSequence"] != second["sequence"] + 1 or visible["head"] is not None:
|
|
raise ValueError("Disposable channel public readback differs from allocation")
|
|
|
|
|
|
def require_receiver_scope(publisher) -> None:
|
|
scope = getattr(publisher.store, "scope", None)
|
|
if not isinstance(scope, R2Scope) or not scope.prefix or not publisher.public_base.endswith("/" + scope.prefix.rstrip("/")):
|
|
raise ValueError("Receiver candidates require physically disposable storage")
|
|
|
|
|
|
def allocate_receivers(publisher, commit: str, source_version: str, controller: str) -> dict:
|
|
"""Reserve official-identity test packages, not accepted or published releases."""
|
|
from hermes_cli.release_channels import build_prefix, validate_request
|
|
from scripts.releases.channel_releases import product_identity
|
|
|
|
require_receiver_scope(publisher)
|
|
if publisher._read("stable") is not None:
|
|
raise ValueError("Receiver allocation requires a fresh disposable stable record")
|
|
identity = product_identity("v0.0.1")
|
|
requests = {}
|
|
for sequence, slot in enumerate((("S", "T")), 1):
|
|
request = {"schema": 1, "channel": "stable", "repository": publisher.repository,
|
|
"sequence": sequence, "commit": commit, "sourceVersion": source_version,
|
|
"controllerCommit": controller, "version": f"0.0.{sequence}",
|
|
"windowsVersion": f"0.0.{sequence}.0", "releaseTag": f"v0.0.{sequence}",
|
|
"receiverCandidate": True, "identity": identity, "bundleEnv": {},
|
|
"publicBase": publisher.public_base}
|
|
request["buildId"] = hashlib.sha256(canonical_json(request)).hexdigest()[:32]
|
|
validate_request(request, policy="stable-release")
|
|
requests[slot] = request
|
|
record = {"schema": 1, "name": "stable", "repository": publisher.repository, "policy": "stable-release",
|
|
"testOnly": True, "state": "active", "revision": 1, "nextSequence": 3, "head": None,
|
|
"identity": identity}
|
|
publisher._write(channel_key("stable"), record)
|
|
for request in requests.values():
|
|
publisher._write(build_prefix(request["buildId"]) + "request.json", request)
|
|
return requests
|
|
|
|
|
|
def allocate(env: dict[str, str]) -> dict:
|
|
disposable_name = env.get("DISPOSABLE_CHANNEL", "")
|
|
channel_name = env.get("CHANNEL", "")
|
|
if disposable_name and channel_name:
|
|
raise ValueError("Choose disposable_channel or channel, not both")
|
|
disposable = bool(disposable_name)
|
|
branding = env.get("BRANDING") or "preview"
|
|
if disposable and branding != "preview":
|
|
raise ValueError("Disposable namespaces have no published stable identity to copy")
|
|
name = validate_name(disposable_name or channel_name)
|
|
if disposable:
|
|
if name in {"ci-cas-probe", "stable", "canary", "main"}:
|
|
raise ValueError("Use a non-protected disposable preview name")
|
|
if env.get("CHANNEL_BUILD") or env.get("CHANNEL_REQUEST_SHA256"):
|
|
raise ValueError("Allocation cannot reuse a build request or select a storage scope")
|
|
if not selects_all(env.get("JOBS")):
|
|
raise ValueError("Disposable allocation cannot select partial job groups")
|
|
# One dispatch now allocates AND builds, so the namespace must survive a
|
|
# "re-run failed jobs": lease by the run id alone (no attempt suffix), and
|
|
# never accept a caller-supplied path, public URL, or allocation namespace.
|
|
run = require_run(env.get("GITHUB_RUN_ID", ""))
|
|
admitted = commit_build.admit(env)
|
|
controller = commit_build.require_commit(env.get("GITHUB_SHA", ""))
|
|
if disposable:
|
|
os.environ["R2_DISPOSABLE_RUN"] = run
|
|
scope = R2Scope.configured()
|
|
base = channel_public_base()
|
|
repository = env["GITHUB_REPOSITORY"]
|
|
|
|
def authorize(action: str, record: dict) -> None:
|
|
if action not in {"create", "allocate"} or record["policy"] != "preview":
|
|
raise ValueError("Controller only creates and allocates previews")
|
|
commit_build.admit(env)
|
|
|
|
publisher = ChannelPublisher(R2ChannelStore(*r2.credentials(), scope=scope), repository,
|
|
base, authorize=authorize)
|
|
if disposable:
|
|
probe(publisher, admitted["sha"], admitted["payload-version"], controller)
|
|
publisher.create(name, branding)
|
|
from scripts.releases.bundle_env import decode
|
|
request = publisher.allocate(name, admitted["sha"], admitted["payload-version"],
|
|
decode(env.get("BUNDLE_ENV_JSON", "")), controller)
|
|
requests = {"A": request}
|
|
if env.get("DISPOSABLE_RECEIVERS") == "true":
|
|
requests["B"] = publisher.allocate(name, admitted["sha"], admitted["payload-version"],
|
|
decode(env.get("BUNDLE_ENV_JSON", "")), controller)
|
|
requests.update(allocate_receivers(publisher, admitted["sha"], admitted["payload-version"], controller))
|
|
digest = hashlib.sha256(canonical_json(request)).hexdigest()
|
|
return {"request": request, "requestSha256": digest, "disposableRun": run if disposable else "",
|
|
"storagePrefix": scope.prefix, "requests": requests}
|
|
|
|
|
|
def main() -> None:
|
|
result = allocate(dict(os.environ))
|
|
outputs = {
|
|
"channel_build": result["request"]["buildId"],
|
|
"channel_request_sha256": result["requestSha256"],
|
|
"public_base": channel_public_base(),
|
|
}
|
|
if result["disposableRun"]:
|
|
outputs["disposable_run"] = result["disposableRun"]
|
|
outputs["storage_prefix"] = result["storagePrefix"]
|
|
# Job outputs feed the build legs of THIS run (one dispatch). The summary is
|
|
# informational; the request is consumed through job outputs.
|
|
if os.environ.get("GITHUB_OUTPUT"):
|
|
with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as stream:
|
|
stream.write("".join(f"{key}={value}\n" for key, value in outputs.items()))
|
|
if result["disposableRun"]:
|
|
title = "## Disposable channel allocation\n\n"
|
|
note = ("CAS, stale-writer rejection, listing and public readback passed. "
|
|
"No native build or production record was published.")
|
|
prefix = f"Storage prefix: `{result['storagePrefix']}`"
|
|
else:
|
|
title = "## Channel allocation\n\n"
|
|
note = "Created the channel and allocated this build's immutable request in the production namespace."
|
|
prefix = f"Channel: `{result['request']['channel']}` · build `{result['request']['buildId']}`"
|
|
summary = (title + note + "\n\n" + prefix + "\n")
|
|
with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a", encoding="utf-8") as stream:
|
|
stream.write(summary)
|
|
print(json.dumps(result, sort_keys=True))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |