Files
hermes-agent/scripts/release.py
ethernet e78e3a77be refactor(release): move the author map out of release.py
release.py was 2,804 lines, 2,076 of them the frozen legacy author dict.
The map and its resolver now live in scripts/releases/: authors.py holds
the directory loader, the merged AUTHOR_MAP and resolve_author, and
authors_legacy.py holds only the frozen LEGACY_AUTHOR_MAP literal (same
1,895 entries, same order). release.py drops to 707 lines.

The contributor-check job and audit_pr_attribution.py grep the legacy
file for quoted emails, so both now read authors_legacy.py. The
importers (contributor_audit.py, add_contributor.py), contributors/README
and the tests read the defining modules. No behaviour change.
2026-09-24 14:08:22 -04:00

708 lines
30 KiB
Python
Executable File

#!/usr/bin/env -S bash -c 'exec "$BASH" "$(dirname "$0")/_hermes-python" "$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
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 ( # noqa: E402
_CANARY_TAG_RE, STABLE_TAG_RE, canary_tag_for_date, canary_timestamp,
is_canary_tag,
)
from scripts.releases.authors import resolve_author # noqa: E402
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),
)
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),
)
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),
)
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),
)
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",
)
# 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",
)
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(timezone.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(timezone.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),
)
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.")
# 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)
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_publish, cmd_release
stable_commands = {"release": cmd_release, "publish": cmd_publish, "abandon": cmd_abandon}
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, --canary, --build-commit, or a channel operation")
if __name__ == "__main__":
main()