Files
hermes-agent/scripts/releases/channel_build.py
ethernet 13dbe260aa feat(release): --branding stable builds a channel commit as the regular app
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.
2026-09-30 09:19:35 -04:00

332 lines
12 KiB
Python

"""Channel dispatch: the local command names intent; CI allocates and builds.
A channel build is a preview of an exact pushed commit. The local command does
not touch R2: it resolves the commit, validates the request and dispatches the
default-branch workflow. The workflow's privileged allocation step creates the
channel and mints the immutable build request (see channel_disposable.py).
""" # noqa: E501
from __future__ import annotations
import json
import os
from pathlib import Path
import subprocess
from hermes_cli.release_channels import (
ChannelError,
validate_name,
validate_repository,
)
from scripts.releases import commit_build, r2
from scripts.releases.bundle_env import parse_assignments, validate
from scripts.releases.channels import BRANDINGS, ChannelPublisher, R2ChannelStore
def dispatch_command(
name: str,
commit: str,
repository: str,
branch: str,
bundle_env: dict | None = None,
branding: str = "preview",
) -> list[str]:
validate_name(name)
if branding not in BRANDINGS:
raise ChannelError(f"Unknown channel branding: {branding}")
validate_repository(repository)
commit_build.require_commit(commit)
if not branch or branch.startswith("-"):
raise ChannelError("Missing repository default branch")
command = [
"gh",
"workflow",
"run",
commit_build.WORKFLOW,
"--repo",
repository,
"--ref",
branch,
"-f",
"channel=" + name,
"-f",
"build_commit=" + commit,
"-f",
"tag=",
"-f",
"upload_release=false",
"-f",
"termux_upgrade_from_tag=",
]
if bundle_env:
command += ["-f", "bundle_env=" + json.dumps(bundle_env, sort_keys=True)]
if branding != "preview":
command += ["-f", "branding=" + branding]
return command
def prepare_build(
*,
name: str,
revision: str,
remote: str,
repo: Path,
repository: str,
default_branch: str,
dispatch,
bundle_env: dict | None = None,
publish: bool = False,
branding: str = "preview",
) -> dict:
validate_name(name)
validate_repository(repository)
bundle_env = validate({} if bundle_env is None else bundle_env)
commit = commit_build.resolve_revision(revision, remote, repo)
version = commit_build.version_at(repo, commit)
if not default_branch or default_branch.startswith("-"):
raise ChannelError("Missing repository default branch")
command = dispatch_command(name, commit, repository, default_branch, bundle_env, branding)
result = {
"channel": name,
"branding": branding,
"repository": repository,
"commit": commit,
"sourceVersion": version,
}
if not publish:
return {**result, "command": command}
dispatch(command)
return {**result, "command": command}
def maintainer_authorizer(repository: str):
validate_repository(repository)
def authorize(action: str, record: dict) -> None:
actor = commit_build.output(["gh", "api", "user", "--jq", ".login"])
permission = commit_build.output([
"gh",
"api",
f"repos/{repository}/collaborators/{actor}/permission",
"--jq",
".permission",
])
if permission not in {"write", "maintain", "admin"}:
raise ChannelError(
"Channel administration requires a repository maintainer"
)
if action == "bootstrap" and permission not in {"maintain", "admin"}:
raise ChannelError(
"Protected bootstrap requires maintain or admin permission"
)
return authorize
def configured_publisher(repository: str) -> ChannelPublisher:
from scripts.releases.r2_scope import R2Scope, channel_public_base
validate_repository(repository)
scope = R2Scope.configured(repository)
if scope.prefix:
actual_id = commit_build.output([
"gh",
"api",
f"repos/{repository}",
"--jq",
".id",
])
if actual_id != os.environ.get("GITHUB_REPOSITORY_ID"):
raise ChannelError("Disposable namespace belongs to another repository")
base = channel_public_base()
store = R2ChannelStore(*r2.credentials(), scope=scope)
def verify_build(request: dict, manifest: dict) -> bool:
from scripts.releases.channel_releases import verify_bootstrap
return verify_bootstrap(request, manifest, base, repository)
return ChannelPublisher(
store,
repository,
base,
authorize=maintainer_authorizer(repository),
verify_build=verify_build,
)
def cmd_channel(args) -> None:
from scripts import release
try:
remote = release.resolve_push_remote(args.remote)
repository = release.remote_github_repo(remote)
if not repository:
raise ChannelError("Channel commands require an explicit GitHub remote")
default_branch = release._default_branch(repository)
if args.channel:
bundle_env = parse_assignments(args.bundle_env, args.bundle_unset)
if args.channel_specific_data_dirs:
bundle_env["HERMES_DATA_DIR_SUFFIX"] = f"-channel-build-{args.channel}"
bundle_env["HERMES_HOME"] = None
bundle_env["HERMES_DESKTOP_USER_DATA_DIR"] = None
bundle_env["HERMES_SHARED_AUTH_DIR"] = None
result = prepare_build(
name=args.channel,
revision=args.build_commit,
remote=remote,
repo=release.REPO_ROOT,
repository=repository,
default_branch=default_branch,
dispatch=lambda command: subprocess.run(
command, cwd=release.REPO_ROOT, check=True, timeout=60
),
bundle_env=bundle_env,
publish=args.publish,
branding=args.branding,
)
print(json.dumps(result, sort_keys=True, indent=2))
if not args.publish:
print("Dry run: no workflow was dispatched. Add --publish to dispatch.")
return
print(f"Channel build for {args.channel} started at commit {result['commit']}.")
print(f"Workflow: https://github.com/{repository}/actions/workflows/desktop-bundled-release.yml")
print("Wait for that workflow to finish. It builds this commit and moves the channel when every native smoke passes.")
return
publisher = configured_publisher(repository)
operations = {
"channels": lambda: publisher.list(),
"retire_channel": lambda: publisher.retire(
args.retire_channel, args.to, args.minimum_version, publish=args.publish
),
"bootstrap_channels": lambda: _bootstrap_file(
publisher, Path(args.bootstrap_channels), args.publish
),
}
selected = next(key for key in operations if getattr(args, key, None))
result = operations[selected]()
print(json.dumps(result, sort_keys=True, indent=2))
if selected == "channels":
print("These are the channel records. This command did not change them.")
elif not args.publish:
print("Dry run: no R2 object was written. Add --publish to execute.")
elif selected == "retire_channel":
print(f"Retired {args.retire_channel}. New installs move to {args.to} at version {args.minimum_version} or newer.")
else:
print("Wrote the channel records. Check the JSON above for each record.")
except (OSError, ValueError, subprocess.SubprocessError, r2.R2RequestError) as exc:
raise SystemExit(f"release: channel operation refused: {exc}") from exc
def _bootstrap_file(
publisher: ChannelPublisher, path: Path, publish: bool
) -> list[dict]:
value = json.loads(path.read_text(encoding="utf-8-sig"))
if not isinstance(value, list):
raise ChannelError(
"Bootstrap input must be a list of {record, manifest} accepted release facts"
)
# Validate every row before the first write; multi-object publication is not atomic.
for row in value:
publisher.bootstrap(row["record"], row.get("manifest"), publish=False)
return [
publisher.bootstrap(row["record"], row.get("manifest"), publish=publish)
for row in value
]
def add_arguments(parser) -> None:
parser.add_argument(
"--channel",
type=validate_name,
metavar="NAME",
help="R2 channel for --build-commit",
)
parser.add_argument(
"--channel-specific-data-dirs",
action="store_true",
help="Set HERMES_HOME et al to a directory specific for this channel",
)
parser.add_argument(
"--branding",
choices=BRANDINGS,
default="preview",
help="App name, icon and package ID for --channel: 'stable' installs as the "
"regular Hermes app (fixed when the channel is created), 'preview' as "
"its own side-by-side app",
)
parser.add_argument(
"--channels", action="store_true", help="List authenticated R2 channel records"
)
parser.add_argument(
"--retire-channel",
type=validate_name,
metavar="NAME",
help="Permanently retire a preview channel",
)
parser.add_argument(
"--to",
type=validate_name,
metavar="NAME",
help="Retirement destination channel",
)
parser.add_argument(
"--minimum-version", metavar="X.Y.Z", help="Destination version floor"
)
parser.add_argument(
"--bootstrap-channels",
metavar="JSON",
help="Preview protected channel bootstrap from accepted release facts",
)
def validate_arguments(parser, args) -> bool:
selected = [
key
for key in ("channel", "channels", "retire_channel", "bootstrap_channels")
if getattr(args, key)
]
retirement = (args.to, args.minimum_version)
if args.branding != "preview" and not args.channel:
parser.error("--branding requires --channel")
if any(retirement) and not args.retire_channel:
parser.error("Retirement options require --retire-channel")
if len(selected) > 1:
parser.error("Channel administration operations are mutually exclusive")
if not selected:
return False
if (args.bundle_env or args.bundle_unset) and not args.channel:
parser.error("--bundle-env and --bundle-unset require --build-commit")
if (args.bundle_env or args.bundle_unset) and args.channel_specific_data_dirs:
from scripts.releases.bundle_env import parse_assignments
env = parse_assignments(args.bundle_env, args.bundle_unset)
banned = (
"HERMES_DATA_DIR_SUFFIX",
"HERMES_HOME",
"HERMES_SHARED_AUTH_DIR",
"HERMES_DESKTOP_USER_DATA_DIR",
)
bad_vars = [e for e in env if e in banned]
if bad_vars:
bad_set = " ".join([f"--bundle-env {v}" for v in bad_vars if v])
bad_unset = " ".join([f"--bundle-unset {v}" for v in bad_vars if not v])
parser.error(
f"--channel-specific-data-dirs conflicts with:\n{bad_set} {bad_unset}\nRemove those args to use it."
)
if args.channel and args.build_commit is None:
parser.error("--channel requires --build-commit")
if args.retire_channel and not all(retirement):
parser.error("--retire-channel requires --to and --minimum-version")
if (not args.channel and args.build_commit) or any((
args.canary,
args.prune_canaries,
args.date,
args.no_changelog,
)):
parser.error(
"Channel operations cannot be combined with release/tag operations"
)
return True