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.
332 lines
12 KiB
Python
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
|