Files
hermes-agent/scripts/release.py
Teknium 5f9b77cce0 ci(release): stop building macOS Intel and Termux in stable releases by default
Stable releases no longer build the macOS Intel (darwin-x64) desktop bundle
or the Termux APT package unless the release is cut with an opt-in flag.
Intel macOS is not supported on main, and its macos-15-intel bundle leg
takes 76-88 minutes, which is most of the release's wall time.

    python scripts/release.py release --commit SHA --include-macos-intel
    python scripts/release.py release --commit SHA --include-termux

The flags are written into the signed claim as includeMacosIntel and
includeTermux, like skipBundles/skipTests. admit emits them, and the
candidates-darwin-x64, transitions-darwin-x64 and macos-packaged-x64 jobs
(Intel) and candidates-termux and termux-checks (Termux) run only when
their flag is on. The gate requires those jobs to report skipped when the
flag is off; the skip flags keep their meaning.

Claims cut before these keys existed (every rc.* tag so far) have neither
key. validate_claim treats a missing key as false and still refuses a
non-boolean value or any unknown key, so sequencer discover/reconcile keep
working over the historical tags.

Without the opt-in platforms the candidate manifest, receipts, mac feed,
protected R2 head, APT promotion and downloads table all work with those
rows absent: the macOS feed carries the arm64 leg alone, the APT indexes
are left on the previous Termux release, and the unbuilt smoke is recorded
as skipped (a passed smoke for an unbuilt target is refused). A release
that opts Intel back in after one that left it out has no Intel baseline,
so its Intel upgrade arm is skipped like a first release.

Preview channels, canary and the PM bundle artifacts are unchanged.
2026-10-11 11:38:33 -07:00

729 lines
31 KiB
Python
Executable File

#!/usr/bin/env -S bash -c 'exec "$BASH" "$(dirname "$0")/run-in-hermes-env" python3 "$0" "$@"'
"""Hermes Agent release entrypoint.
Stable releases use the ``release``, ``publish``, and ``abandon`` subcommands.
Canary, commit, and dynamic-channel operations retain their top-level flags.
See ``website/docs/developer-guide/stable-releases.md`` for the operator flow.
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
from collections import defaultdict
from datetime import datetime, timedelta, timezone, UTC
from pathlib import Path
# Bootstrap the repo root onto sys.path so this script can import the
# canary-tag authority from hermes_cli.update_channel (hermes_cli/__init__.py
# is import-light: only os/sys + version constants).
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from hermes_cli.update_channel import (
_CANARY_TAG_RE, STABLE_TAG_RE, canary_tag_for_date, canary_timestamp,
is_canary_tag,
)
from scripts.releases.authors import resolve_author
REPO_ROOT = Path(__file__).resolve().parent.parent
def git(*args, cwd=None):
"""Run a git command and return stdout."""
result = subprocess.run(
["git"] + list(args),
capture_output=True, text=True, encoding="utf-8", errors="replace",
cwd=cwd or str(REPO_ROOT),
check=False,
)
if result.returncode != 0:
print(f"git {' '.join(args)} failed: {result.stderr}", file=sys.stderr)
return ""
return result.stdout.strip()
def git_result(*args, cwd=None):
"""Run a git command and return the full CompletedProcess."""
return subprocess.run(
["git"] + list(args),
capture_output=True,
text=True, encoding="utf-8", errors="replace",
cwd=cwd or str(REPO_ROOT),
check=False,
)
def list_remotes() -> list[str]:
"""The configured git remote names, in git's order."""
result = git_result("remote")
if result.returncode != 0:
return []
return [name for name in result.stdout.split() if name]
def dispatch_desktop_build(tag: str, gh_repo: str | None) -> bool:
"""Dispatch the release pipeline after its draft exists.
Stable workflows run on the tag so all reusable checks see the same
commit. Their gate owns artifact publication and channel promotion.
Canary builds keep default-branch workflow/cache scope and tagged inputs.
Explicit dispatch also works for tags created by GITHUB_TOKEN.
"""
canary = _CANARY_TAG_RE.fullmatch(tag) is not None
if not canary and not STABLE_TAG_RE.fullmatch(tag):
raise ValueError("Expected an exact stable or canary release tag")
workflow = "desktop-bundled-release.yml" if canary else "stable-release.yml"
cmd = ["gh", "workflow", "run", workflow, "--ref", "main" if canary else tag,
"-f", f"tag={tag}"]
if canary:
cmd += ["-f", "upload_release=true"]
if gh_repo:
cmd += ["--repo", gh_repo]
if not shutil.which("gh"):
print(" ✗ Cannot start the release pipeline: `gh` CLI not found.")
print(f" Start it manually: {' '.join(cmd)}")
return False
dispatch_ref = (_default_branch(gh_repo) or "main") if canary else tag
cmd[cmd.index("--ref") + 1] = dispatch_ref
result = subprocess.run(
cmd, capture_output=True, text=True, encoding="utf-8",
errors="replace", cwd=str(REPO_ROOT),
check=False,
)
if result.returncode != 0:
print(f" ✗ Could not start the release pipeline: {result.stderr.strip()}")
print(f" Start it manually: {' '.join(cmd)}")
return False
print(f" ✓ {workflow} started for {tag} (workflow from {dispatch_ref})")
return True
def _default_branch(gh_repo: str | None) -> str | None:
"""The repo's default branch, resolved via gh. None on any failure."""
cmd = ["gh", "repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"]
if gh_repo:
cmd += [gh_repo]
result = subprocess.run(
cmd, capture_output=True, text=True, encoding="utf-8",
errors="replace", cwd=str(REPO_ROOT),
check=False,
)
if result.returncode != 0:
return None
return result.stdout.strip() or None
def resolve_push_remote(requested: str | None) -> str:
"""Pick the remote that receives the release push (and the GitHub
release). One configured remote: use it. More than one: the release
lands on whichever repo the tag is pushed to, so an implicit default
is a foot-gun — require an explicit --remote.
"""
remotes = list_remotes()
if not remotes:
raise SystemExit("release: no git remotes configured — nothing to push to")
if requested:
if requested not in remotes:
raise SystemExit(
f"release: remote {requested!r} is not configured "
f"(available: {', '.join(remotes)})"
)
return requested
if len(remotes) == 1:
return remotes[0]
raise SystemExit(
"release: multiple remotes are configured "
f"({', '.join(remotes)}) — pass --remote <name> to say which one "
"receives the release push"
)
def remote_github_repo(remote: str) -> str | None:
"""Read the GitHub repository that receives the remote's pushes."""
result = git_result("remote", "get-url", "--push", remote)
if result.returncode != 0:
return None
url = result.stdout.strip()
match = re.fullmatch(
r"(?:https?://(?:[^/@\s]+@)?github\.com/|"
r"ssh://(?:[^/@\s]+@)?github\.com(?::\d+)?/|"
r"git://github\.com/|[^/@:\s]+@github\.com:)"
r"([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?)(?:\.git)?/?", url,
)
return match.group(1) if match else None
# Stable tags are matched with STABLE_TAG_RE and canary tags
# with _CANARY_TAG_RE, both imported from hermes_cli.update_channel — the
# single authority for both tag shapes (the stable major is capped at three
# digits so historical CalVer tags like v2026.7.20 never match; the canary
# identity is the exact stable core plus full UTC build metadata.
# Second precision allows several canaries per day; the fixed-width UTC stamp
# sorts chronologically even though SemVer ignores it for precedence.
def get_last_canary_tag():
"""The newest canonical canary receipt, by embedded build time."""
raw = git("tag", "--list", "v*+canary.*")
tags = [tag for tag in (raw.split("\n") if raw else []) if is_canary_tag(tag)]
return max(tags, key=lambda tag: canary_timestamp(tag) or "", default=None)
def categorize_commit(subject: str) -> str:
"""Categorize a commit by its conventional commit prefix."""
subject_lower = subject.lower()
# Match conventional commit patterns
patterns = {
"breaking": [r"^breaking[\s:(]", r"^!:", r"BREAKING CHANGE"],
"features": [r"^feat[\s:(]", r"^feature[\s:(]", r"^add[\s:(]"],
"fixes": [r"^fix[\s:(]", r"^bugfix[\s:(]", r"^bug[\s:(]", r"^hotfix[\s:(]"],
"improvements": [r"^improve[\s:(]", r"^perf[\s:(]", r"^enhance[\s:(]",
r"^refactor[\s:(]", r"^cleanup[\s:(]", r"^clean[\s:(]",
r"^update[\s:(]", r"^optimize[\s:(]"],
"docs": [r"^doc[\s:(]", r"^docs[\s:(]"],
"tests": [r"^test[\s:(]", r"^tests[\s:(]"],
"chore": [r"^chore[\s:(]", r"^ci[\s:(]", r"^build[\s:(]",
r"^deps[\s:(]", r"^bump[\s:(]"],
}
for category, regexes in patterns.items():
for regex in regexes:
if re.match(regex, subject_lower):
return category
# Heuristic fallbacks
if any(w in subject_lower for w in ["add ", "new ", "implement", "support "]):
return "features"
if any(w in subject_lower for w in ["fix ", "fixed ", "resolve", "patch "]):
return "fixes"
if any(w in subject_lower for w in ["refactor", "cleanup", "improve", "update "]):
return "improvements"
return "other"
def clean_subject(subject: str) -> str:
"""Clean up a commit subject for display."""
# Remove conventional commit prefix
cleaned = re.sub(r"^(feat|fix|docs|chore|refactor|test|perf|ci|build|improve|add|update|cleanup|hotfix|breaking|enhance|optimize|bugfix|bug|feature|tests|deps|bump)[\s:(!]+\s*", "", subject, flags=re.IGNORECASE)
# Remove trailing issue refs that are redundant with PR links
cleaned = cleaned.strip()
# Capitalize first letter
if cleaned:
cleaned = cleaned[0].upper() + cleaned[1:]
return cleaned
def parse_coauthors(body: str) -> list:
"""Extract Co-authored-by trailers from a commit message body.
Returns a list of {'name': ..., 'email': ...} dicts.
Filters out AI assistants and bots (Claude, Copilot, Cursor, etc.).
"""
if not body:
return []
# AI/bot emails to ignore in co-author trailers
_ignored_emails = {"noreply@anthropic.com", "noreply@github.com",
"cursoragent@cursor.com", "hermes@nousresearch.com"}
_ignored_names = re.compile(r"^(Claude|Copilot|Cursor Agent|GitHub Actions?|dependabot|renovate)", re.IGNORECASE)
pattern = re.compile(r"Co-authored-by:\s*(.+?)\s*<([^>]+)>", re.IGNORECASE)
results = []
for m in pattern.finditer(body):
name, email = m.group(1).strip(), m.group(2).strip()
if email in _ignored_emails or _ignored_names.match(name):
continue
results.append({"name": name, "email": email})
return results
def get_commits(since_tag=None, until="HEAD", cwd=None):
"""Get commits in ``since_tag..until`` (or all of ``until`` if since_tag is None)."""
if since_tag:
range_spec = f"{since_tag}..{until}"
else:
range_spec = until
# Format: hash<US>author_name<US>author_email<US>subject\0body
# Using %x1f (unit separator) to avoid conflict with | in author names
log = git(
"log", range_spec,
"--format=%H%x1f%an%x1f%ae%x1f%s%x00%b%x00",
"--no-merges",
cwd=cwd,
)
if not log:
return []
commits = []
# Split on double-null to get each commit entry, since body ends with \0
# and format ends with \0, each record ends with \0\0 between entries
for entry in log.split("\0\0"):
entry = entry.strip()
if not entry:
continue
# Split on first null to separate "hash<US>name<US>email<US>subject" from "body"
if "\0" in entry:
header, body = entry.split("\0", 1)
body = body.strip()
else:
header = entry
body = ""
parts = header.split("\x1f", 3)
if len(parts) != 4:
continue
sha, name, email, subject = parts
coauthor_info = parse_coauthors(body)
coauthors = [resolve_author(ca["name"], ca["email"]) for ca in coauthor_info]
commits.append({
"sha": sha,
"short_sha": sha[:8],
"author_name": name,
"author_email": email,
"subject": subject,
"category": categorize_commit(subject),
"github_author": resolve_author(name, email),
"coauthors": coauthors,
})
return commits
def get_pr_number(subject: str) -> str | None:
"""Extract PR number from commit subject if present."""
match = re.search(r"#(\d+)", subject)
if match:
return match.group(1)
return None
def generate_changelog(commits, tag_name, semver, repo_url="https://github.com/NousResearch/hermes-agent",
prev_tag=None, first_release=False, no_changelog=False):
"""Generate markdown changelog from categorized commits."""
lines = []
# Header
now = datetime.now()
date_str = now.strftime("%B %d, %Y")
lines.append(f"# Hermes Agent v{semver} ({tag_name})")
lines.append("")
lines.append(f"**Release Date:** {date_str}")
lines.append("")
# The builds-pending job in desktop-bundled-release.yml replaces this
# marker with a "builds in progress" link to the run as soon as the
# workflow starts. The builds-table job then replaces the link with
# the download tables once every matrix leg has uploaded its
# artifacts to the R2 bucket (real object names, never predicted
# ones; the release body links point at the R2 public URL). A release
# whose matrix never finishes keeps the link — visibly unfinished,
# and it points at the run that stopped.
lines.append("<!-- HERMES_BUILDS_TABLE -->")
lines.append("")
if first_release:
lines.append("> 🎉 **First official release!** This marks the beginning of regular weekly releases")
lines.append("> for Hermes Agent. See below for everything included in this initial release.")
lines.append("")
all_authors = set()
teknium_aliases = {"@teknium1"}
if not no_changelog:
# Group commits by category
categories = defaultdict(list)
for commit in commits:
categories[commit["category"]].append(commit)
author = commit["github_author"]
if author not in teknium_aliases:
all_authors.add(author)
for coauthor in commit.get("coauthors", []):
if coauthor not in teknium_aliases:
all_authors.add(coauthor)
# Category display order and emoji
category_order = [
("breaking", "⚠️ Breaking Changes"),
("features", "✨ Features"),
("improvements", "🔧 Improvements"),
("fixes", "🐛 Bug Fixes"),
("docs", "📚 Documentation"),
("tests", "🧪 Tests"),
("chore", "🏗️ Infrastructure"),
("other", "📦 Other Changes"),
]
for cat_key, cat_title in category_order:
cat_commits = categories.get(cat_key, [])
if not cat_commits:
continue
lines.append(f"## {cat_title}")
lines.append("")
for commit in cat_commits:
subject = clean_subject(commit["subject"])
pr_num = get_pr_number(commit["subject"])
author = commit["github_author"]
# Build the line
parts = [f"- {subject}"]
if pr_num:
parts.append(f"([#{pr_num}]({repo_url}/pull/{pr_num}))")
else:
parts.append(f"([`{commit['short_sha']}`]({repo_url}/commit/{commit['sha']}))")
if author not in teknium_aliases:
parts.append(f"— {author}")
lines.append(" ".join(parts))
lines.append("")
# Contributors section
if all_authors:
# Sort contributors by commit count
author_counts = defaultdict(int)
for commit in commits:
author = commit["github_author"]
if author not in teknium_aliases:
author_counts[author] += 1
for coauthor in commit.get("coauthors", []):
if coauthor not in teknium_aliases:
author_counts[coauthor] += 1
sorted_authors = sorted(author_counts.items(), key=lambda x: -x[1])
lines.append("## 👥 Contributors")
lines.append("")
lines.append("Thank you to everyone who contributed to this release!")
lines.append("")
for author, count in sorted_authors:
commit_word = "commit" if count == 1 else "commits"
lines.append(f"- {author} ({count} {commit_word})")
lines.append("")
# Full changelog link
if prev_tag:
lines.append(f"**Full Changelog**: [{prev_tag}...{tag_name}]({repo_url}/compare/{prev_tag}...{tag_name})")
else:
lines.append(f"**Full Changelog**: [{tag_name}]({repo_url}/commits/{tag_name})")
lines.append("")
return "\n".join(lines)
def _resume_canary(tag: str, remote: str, repository: str, *, notes_file: Path | None = None) -> None:
"""Converge a tag-pushed canary through draft, dispatch, and protected head."""
ref = f"refs/tags/{tag}"
local_object = git("rev-parse", ref)
commit = git("rev-parse", f"{ref}^{{commit}}")
if git("cat-file", "-t", local_object) != "tag":
raise ValueError("Canary receipt must be an annotated tag")
remote_refs = dict(line.split()[::-1] for line in git(
"ls-remote", remote, ref, f"{ref}^{{}}",
).splitlines())
if remote_refs.get(ref) != local_object or remote_refs.get(f"{ref}^{{}}") != commit:
raise ValueError("Canary receipt differs from its exact remote tag object")
view = subprocess.run(
["gh", "release", "view", tag, "--repo", repository, "--json", "tagName,isDraft,isPrerelease,url"],
cwd=REPO_ROOT, capture_output=True, text=True, encoding="utf-8",
check=False,
)
# A draft is served at an untagged-* URL, never releases/tag/<tag>; gh's
# answer is the only working link to it.
if view.returncode != 0:
create = [
"gh", "release", "create", tag, "--repo", repository,
"--verify-tag", "--draft", "--prerelease",
"--title", f"Hermes Agent canary {tag}",
]
create.extend(["--notes-file", str(notes_file)] if notes_file else ["--generate-notes"])
created = subprocess.run(
create, cwd=REPO_ROOT, capture_output=True, text=True, encoding="utf-8",
check=False,
)
if created.returncode != 0:
raise ValueError(created.stderr.strip() or "Canary draft could not be recovered")
draft_url = created.stdout.strip()
else:
release = json.loads(view.stdout)
if (release.get("tagName") != tag or not isinstance(release.get("isDraft"), bool)
or release.get("isPrerelease") is not True):
raise ValueError("Canary release state differs from its receipt")
draft_url = release.get("url") or ""
from scripts.releases.versioning import published_channel_identity
published = published_channel_identity(repository, "canary")
if published is not None and published == (tag[1:], commit):
print(f"✓ {tag} is already published.")
return
if not dispatch_desktop_build(tag, repository):
raise SystemExit(1)
print(f"Resumed canary publication for {tag}.")
print(f"Workflow: https://github.com/{repository}/actions/workflows/desktop-bundled-release.yml")
print("Wait for that workflow to finish. It builds this canary and publishes the draft when the build is green.")
print(f"The draft is at {draft_url}.")
def cmd_canary(args) -> None:
"""--canary: tag + draft a canary source identity.
The source identity is the newest stable version plus a full UTC timestamp
in SemVer build metadata. It therefore compares equal to that stable; only
the R2 canary head moves subscribers. No version-file bump or commit is
created — the receipt tag points at HEAD as-is.
Created as a DRAFT prerelease, for the same reason the stable path
drafts: a published release with no installers attached is a release
users can reach and cannot use. The desktop matrix attaches the
installers to this draft by tag, and the canary workflow's publish
job flips it to published only after that matrix is green. A failed
bundle therefore leaves an inspectable draft rather than a broken
canary.
Exits 0 with "nothing to do" when HEAD is already tagged by the last
canary — the skip-if-no-new-commits gate lives HERE, not in workflow
YAML.
"""
date_utc = args.date or datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
push_remote = resolve_push_remote(args.remote)
gh_repo = remote_github_repo(push_remote)
if not gh_repo:
raise ValueError("Canary publication requires a GitHub repository remote")
prev_canary = get_last_canary_tag()
if prev_canary:
head = git("rev-parse", "HEAD")
if head and head == git("rev-parse", f"{prev_canary}^{{commit}}"):
if args.publish:
_resume_canary(prev_canary, push_remote, gh_repo)
else:
print(f"✓ No new commits since {prev_canary} — nothing to do.")
return
from scripts.releases.versioning import published_stable_identity
stable_version, stable_commit = published_stable_identity(gh_repo)
tag_name = canary_tag_for_date(stable_version, date_utc)
since = prev_canary or stable_commit
if git_result("rev-parse", "--verify", "--quiet", f"refs/tags/{tag_name}").returncode == 0:
if git("rev-parse", f"{tag_name}^{{commit}}") != git("rev-parse", "HEAD"):
raise ValueError(f"{tag_name} already receipts a different commit")
if args.publish:
_resume_canary(tag_name, push_remote, gh_repo)
else:
print(f"✓ {tag_name} already exists — nothing to do.")
return
commits = get_commits(since_tag=since)
if not commits:
print(f"✓ No new commits since {since} — nothing to do.")
return
version = tag_name.lstrip("v")
print(f"Canary: {tag_name} ({len(commits)} commits since {since})")
changelog = generate_changelog(
commits, tag_name, version, prev_tag=since, first_release=False, no_changelog=args.no_changelog
)
if not args.publish:
print(changelog)
print("\nDry run complete. To publish, add --publish")
return
tag_result = git_result(
"tag", "-a", tag_name, "-m", f"Hermes Agent canary {date_utc}"
)
if tag_result.returncode != 0:
print(f"✗ Failed to create tag {tag_name}: {tag_result.stderr.strip()}")
sys.exit(1)
push_result = git_result("push", push_remote, f"refs/tags/{tag_name}")
if push_result.returncode != 0:
print(f"✗ Failed to push {tag_name}: {push_result.stderr.strip()}")
sys.exit(1)
print(f"✓ Pushed {tag_name} to {push_remote}")
changelog_file = REPO_ROOT / ".release_notes.md"
changelog_file.write_text(changelog, encoding="utf-8")
_resume_canary(tag_name, push_remote, gh_repo, notes_file=changelog_file)
changelog_file.unlink(missing_ok=True)
# Record the tag for any workflow step that wants it. release.py
# starts the build itself, so nothing consumes this today; it stays
# because a step output is the cheap, conventional handle for "which
# tag did this run cut".
github_output = os.environ.get("GITHUB_OUTPUT")
if github_output:
with open(github_output, "a", encoding="utf-8") as f:
f.write(f"tag={tag_name}\n")
def _canary_date(tag: str) -> str | None:
"""The YYYYMMDD receipt date for a canonical canary tag."""
if not is_canary_tag(tag):
return None
return tag.split("+canary.", 1)[1][:8]
def prune_old_canaries(args) -> None:
"""--prune-canaries: delete canary releases+tags older than 14 days.
Keep-on-doubt: any parse failure keeps the release. The keep window is
dated by the tag's own UTC suffix, not the release timestamp, so a
re-published old tag never resets its clock.
"""
push_remote = resolve_push_remote(args.remote)
gh_repo = remote_github_repo(push_remote)
cutoff = (datetime.now(UTC) - timedelta(days=14)).strftime("%Y%m%d")
tags = git("tag", "--list", "v*+canary.*", "--sort=-creatordate")
doomed = []
for tag in (tags.split("\n") if tags else []):
date = _canary_date(tag)
if date is not None and date < cutoff:
doomed.append(tag)
if not doomed:
print("✓ No canaries older than 14 days.")
return
for tag in doomed:
if not args.publish:
print(f"Would delete {tag}")
continue
gh_cmd = ["gh", "release", "delete", tag, "--yes", "--cleanup-tag"]
if gh_repo:
gh_cmd += ["--repo", gh_repo]
result = subprocess.run(
gh_cmd, capture_output=True, text=True, encoding="utf-8",
errors="replace", cwd=str(REPO_ROOT),
check=False,
)
if result.returncode == 0:
print(f"✓ Deleted {tag}")
else:
print(f"⚠ Could not delete {tag}: {result.stderr.strip()}")
if not args.publish:
print("Dry run complete. No release was deleted.")
print("Run the same command with --publish to delete the tags above.")
def main():
parser = argparse.ArgumentParser(description="Hermes Agent Release Tool")
parser.add_argument("--canary", action="store_true",
help="Tag + publish a stable-core canary "
"(v<stable>+canary.<YYYYMMDDTHHMMSSZ>); no-op when "
"HEAD has no new commits since the last canary")
parser.add_argument("--build-commit", type=str, metavar="REV",
help="Preview an exact-commit build into releases/commit/<sha>/ on R2. "
"Add --publish to dispatch without a tag or release. The same "
"direct dispatch runs from any GitHub remote; add --channel NAME "
"to publish into an updatable R2 channel instead of a one-off.")
parser.add_argument("--bundle-env", action="append", default=[], metavar="NAME=VALUE",
help="Bake a non-secret environment default into a commit desktop bundle. "
"Repeat for multiple variables. Runtime environment values win.")
parser.add_argument("--bundle-unset", action="append", default=[], metavar="NAME",
help="Clear an inherited variable at desktop launch, even when set. "
"Uses an explicit empty value; repeat for multiple variables.")
parser.add_argument("--prune-canaries", action="store_true",
help="Delete canary releases+tags older than 14 days")
parser.add_argument("--publish", action="store_true",
help="Actually create the tag and GitHub release (otherwise dry run)")
parser.add_argument("--remote", type=str,
help="Git remote that receives the release push and the GitHub "
"release. Required with --publish when more than one remote "
"is configured; the single remote is used when only one exists.")
parser.add_argument("--date", type=str,
help="Override release date metadata (format: YYYY.M.D)")
parser.add_argument("--no-changelog", action="store_true",
help="Skip changelog")
subcommands = parser.add_subparsers(dest="command")
release_cmd = subcommands.add_parser(
"release", help="Claim a version, cut a draft, and dispatch the gate")
release_cmd.add_argument("--commit", required=True, metavar="SHA",
help="The main commit to release")
release_cmd.add_argument("--bump", choices=["major", "minor", "patch"], default="patch")
release_cmd.add_argument("--autopublish", action="store_true",
help="Publish on green instead of leaving a draft")
release_cmd.add_argument("--skip-bundles", action="store_true",
help="Release only the tag and the Docker image: no desktop, Termux or "
"PM bundle builds, smokes, feeds or Store check. The desktop update "
"channel stays on the previous bundle release.")
release_cmd.add_argument("--skip-tests", action="store_true",
help="Emergency release: skip CI, Nix, PM bundle, install/update E2E, "
"Termux, Windows, native smoke and upgrade acceptance jobs. "
"Artifacts still build and publish.")
release_cmd.add_argument("--include-macos-intel", action="store_true",
help="Also build, test and publish the macOS Intel (darwin-x64) desktop "
"bundle. Off by default: Intel macOS is not supported on main and "
"its runner adds over an hour to the release.")
release_cmd.add_argument("--include-termux", action="store_true",
help="Also build, test and publish the Termux (Android aarch64) APT "
"package. Off by default.")
# SUPPRESS keeps a --no-changelog given before the subcommand from being
# reset by this parser's default.
release_cmd.add_argument("--no-changelog", action="store_true", default=argparse.SUPPRESS,
help="Leave the commit list out of the draft body")
release_cmd.add_argument("--remote", type=str)
changelog_cmd = subcommands.add_parser(
"changelog", help="Print the changelog the next stable release draft would carry")
changelog_cmd.add_argument("--commit", required=True, metavar="SHA",
help="Commit the release would be cut at")
changelog_cmd.add_argument("--bump", choices=["major", "minor", "patch"], default="patch")
changelog_cmd.add_argument("--remote", type=str)
publish_cmd = subcommands.add_parser(
"publish", help="Publish a green stable release through the ordered sequencer")
publish_cmd.add_argument("--version", required=True)
publish_cmd.add_argument("--remote", type=str)
abandon_cmd = subcommands.add_parser(
"abandon", help="Clear the outstanding attempt, keeping its attempt ref and writing an abandon marker")
abandon_cmd.add_argument("--version", required=True)
abandon_cmd.add_argument("--remote", type=str)
from scripts.releases.channel_build import add_arguments, validate_arguments, cmd_channel
add_arguments(parser)
args = parser.parse_args()
from scripts.releases.entrypoint import cmd_abandon, cmd_changelog, cmd_publish, cmd_release
stable_commands = {"release": cmd_release, "publish": cmd_publish, "abandon": cmd_abandon,
"changelog": cmd_changelog}
if args.command:
stable_commands[args.command](args)
return
if validate_arguments(parser, args):
cmd_channel(args)
return
if (args.bundle_env or args.bundle_unset) and args.build_commit is None:
parser.error("--bundle-env and --bundle-unset require --build-commit")
if args.build_commit is not None:
conflicting = [name for name, supplied in (
("--canary", args.canary), ("--prune-canaries", args.prune_canaries),
("--date", args.date), ("--no-changelog", args.no_changelog),
) if supplied]
if conflicting:
parser.error("--build-commit cannot be combined with " + ", ".join(conflicting))
from scripts.releases.commit_build import cmd_build_commit
modes = (
(args.build_commit is not None, cmd_build_commit),
(args.canary, cmd_canary),
(args.prune_canaries, prune_old_canaries),
)
for selected, handler in modes:
if selected:
handler(args)
return
parser.error("select release, publish, abandon, changelog, --canary, --build-commit, or a channel operation")
if __name__ == "__main__":
main()