#!/usr/bin/env python3 """Hermes Agent Release Script Generates changelogs and creates GitHub releases with CalVer tags. Usage: # Preview changelog (dry run) python scripts/release.py # Preview with semver bump python scripts/release.py --bump minor # Create the release python scripts/release.py --bump minor --publish # First release (no previous tag) python scripts/release.py --bump minor --publish --first-release # Override CalVer date (e.g. for a belated release) python scripts/release.py --bump minor --publish --date 2026.3.15 """ import argparse import json import os import re import subprocess import sys from collections import defaultdict from datetime import datetime from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent VERSION_FILE = REPO_ROOT / "hermes_cli" / "__init__.py" PYPROJECT_FILE = REPO_ROOT / "pyproject.toml" # ────────────────────────────────────────────────────────────────────── # Git email → GitHub username mapping # ────────────────────────────────────────────────────────────────────── # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { # teknium (multiple emails) "teknium1@gmail.com": "teknium1", "teknium@nousresearch.com": "teknium1", "127238744+teknium1@users.noreply.github.com": "teknium1", # contributors (from noreply pattern) "35742124+0xbyt4@users.noreply.github.com": "0xbyt4", "82637225+kshitijk4poor@users.noreply.github.com": "kshitijk4poor", "16443023+stablegenius49@users.noreply.github.com": "stablegenius49", "185121704+stablegenius49@users.noreply.github.com": "stablegenius49", "101283333+batuhankocyigit@users.noreply.github.com": "batuhankocyigit", "126368201+vilkasdev@users.noreply.github.com": "vilkasdev", "137614867+cutepawss@users.noreply.github.com": "cutepawss", "96793918+memosr@users.noreply.github.com": "memosr", "131039422+SHL0MS@users.noreply.github.com": "SHL0MS", "77628552+raulvidis@users.noreply.github.com": "raulvidis", "145567217+Aum08Desai@users.noreply.github.com": "Aum08Desai", "256820943+kshitij-eliza@users.noreply.github.com": "kshitij-eliza", "44278268+shitcoinsherpa@users.noreply.github.com": "shitcoinsherpa", "104278804+Sertug17@users.noreply.github.com": "Sertug17", "112503481+caentzminger@users.noreply.github.com": "caentzminger", "258577966+voidborne-d@users.noreply.github.com": "voidborne-d", "70424851+insecurejezza@users.noreply.github.com": "insecurejezza", "259807879+Bartok9@users.noreply.github.com": "Bartok9", # contributors (manual mapping from git names) "dmayhem93@gmail.com": "dmahan93", "samherring99@gmail.com": "samherring99", "desaiaum08@gmail.com": "Aum08Desai", "shannon.sands.1979@gmail.com": "shannonsands", "shannon@nousresearch.com": "shannonsands", "eri@plasticlabs.ai": "Erosika", "hjcpuro@gmail.com": "hjc-puro", "xaydinoktay@gmail.com": "aydnOktay", "abdullahfarukozden@gmail.com": "Farukest", "lovre.pesut@gmail.com": "rovle", "hakanerten02@hotmail.com": "teyrebaz33", "alireza78.crypto@gmail.com": "alireza78a", "brooklyn.bb.nicholson@gmail.com": "brooklynnicholson", "gpickett00@gmail.com": "gpickett00", "mcosma@gmail.com": "wakamex", "clawdia.nash@proton.me": "clawdia-nash", "pickett.austin@gmail.com": "austinpickett", "jaisehgal11299@gmail.com": "jaisup", "percydikec@gmail.com": "PercyDikec", "dean.kerr@gmail.com": "deankerr", "socrates1024@gmail.com": "socrates1024", "satelerd@gmail.com": "satelerd", "numman.ali@gmail.com": "nummanali", "0xNyk@users.noreply.github.com": "0xNyk", "0xnykcd@googlemail.com": "0xNyk", "buraysandro9@gmail.com": "buray", "contact@jomar.fr": "joshmartinelle", "camilo@tekelala.com": "tekelala", "vincentcharlebois@gmail.com": "vincentcharlebois", "aryan@synvoid.com": "aryansingh", "johnsonblake1@gmail.com": "blakejohnson", "bryan@intertwinesys.com": "bryanyoung", "christo.mitov@gmail.com": "christomitov", "hermes@nousresearch.com": "NousResearch", "openclaw@sparklab.ai": "openclaw", "semihcvlk53@gmail.com": "Himess", "erenkar950@gmail.com": "erenkarakus", "adavyasharma@gmail.com": "adavyas", "acaayush1111@gmail.com": "aayushchaudhary", "jason@outland.art": "jasonoutland", "mrflu1918@proton.me": "SPANISHFLU", "morganemoss@gmai.com": "mormio", "kopjop926@gmail.com": "cesareth", "fuleinist@gmail.com": "fuleinist", "jack.47@gmail.com": "JackTheGit", "dalvidjr2022@gmail.com": "Jr-kenny", "m@statecraft.systems": "mbierling", "balyan.sid@gmail.com": "balyansid", } def git(*args, cwd=None): """Run a git command and return stdout.""" result = subprocess.run( ["git"] + list(args), capture_output=True, text=True, 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 get_last_tag(): """Get the most recent CalVer tag.""" tags = git("tag", "--list", "v20*", "--sort=-v:refname") if tags: return tags.split("\n")[0] return None def get_current_version(): """Read current semver from __init__.py.""" content = VERSION_FILE.read_text() match = re.search(r'__version__\s*=\s*"([^"]+)"', content) return match.group(1) if match else "0.0.0" def bump_version(current: str, part: str) -> str: """Bump a semver version string.""" parts = current.split(".") if len(parts) != 3: parts = ["0", "0", "0"] major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2]) if part == "major": major += 1 minor = 0 patch = 0 elif part == "minor": minor += 1 patch = 0 elif part == "patch": patch += 1 else: raise ValueError(f"Unknown bump part: {part}") return f"{major}.{minor}.{patch}" def update_version_files(semver: str, calver_date: str): """Update version strings in source files.""" # Update __init__.py content = VERSION_FILE.read_text() content = re.sub( r'__version__\s*=\s*"[^"]+"', f'__version__ = "{semver}"', content, ) content = re.sub( r'__release_date__\s*=\s*"[^"]+"', f'__release_date__ = "{calver_date}"', content, ) VERSION_FILE.write_text(content) # Update pyproject.toml pyproject = PYPROJECT_FILE.read_text() pyproject = re.sub( r'^version\s*=\s*"[^"]+"', f'version = "{semver}"', pyproject, flags=re.MULTILINE, ) PYPROJECT_FILE.write_text(pyproject) def resolve_author(name: str, email: str) -> str: """Resolve a git author to a GitHub @mention.""" # Try email lookup first gh_user = AUTHOR_MAP.get(email) if gh_user: return f"@{gh_user}" # Try noreply pattern noreply_match = re.match(r"(\d+)\+(.+)@users\.noreply\.github\.com", email) if noreply_match: return f"@{noreply_match.group(2)}" # Try username@users.noreply.github.com noreply_match2 = re.match(r"(.+)@users\.noreply\.github\.com", email) if noreply_match2: return f"@{noreply_match2.group(1)}" # Fallback to git name return name 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 get_commits(since_tag=None): """Get commits since a tag (or all commits if None).""" if since_tag: range_spec = f"{since_tag}..HEAD" else: range_spec = "HEAD" # Format: hash|author_name|author_email|subject log = git( "log", range_spec, "--format=%H|%an|%ae|%s", "--no-merges", ) if not log: return [] commits = [] for line in log.split("\n"): if not line.strip(): continue parts = line.split("|", 3) if len(parts) != 4: continue sha, name, email, subject = parts 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), }) return commits def get_pr_number(subject: str) -> str: """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): """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("") 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("") # Group commits by category categories = defaultdict(list) all_authors = set() teknium_aliases = {"@teknium1"} for commit in commits: categories[commit["category"]].append(commit) author = commit["github_author"] if author not in teknium_aliases: all_authors.add(author) # 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 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 main(): parser = argparse.ArgumentParser(description="Hermes Agent Release Tool") parser.add_argument("--bump", choices=["major", "minor", "patch"], help="Which semver component to bump") parser.add_argument("--publish", action="store_true", help="Actually create the tag and GitHub release (otherwise dry run)") parser.add_argument("--date", type=str, help="Override CalVer date (format: YYYY.M.D)") parser.add_argument("--first-release", action="store_true", help="Mark as first release (no previous tag expected)") parser.add_argument("--output", type=str, help="Write changelog to file instead of stdout") args = parser.parse_args() # Determine CalVer date if args.date: calver_date = args.date else: now = datetime.now() calver_date = f"{now.year}.{now.month}.{now.day}" tag_name = f"v{calver_date}" # Check for existing tag with same date existing = git("tag", "--list", tag_name) if existing and not args.publish: # Append a suffix for same-day releases suffix = 2 while git("tag", "--list", f"{tag_name}.{suffix}"): suffix += 1 tag_name = f"{tag_name}.{suffix}" calver_date = f"{calver_date}.{suffix}" print(f"Note: Tag {tag_name[:-2]} already exists, using {tag_name}") # Determine semver current_version = get_current_version() if args.bump: new_version = bump_version(current_version, args.bump) else: new_version = current_version # Get previous tag prev_tag = get_last_tag() if not prev_tag and not args.first_release: print("No previous tags found. Use --first-release for the initial release.") print(f"Would create tag: {tag_name}") print(f"Would set version: {new_version}") # Get commits commits = get_commits(since_tag=prev_tag) if not commits: print("No new commits since last tag.") if not args.first_release: return print(f"{'='*60}") print(f" Hermes Agent Release Preview") print(f"{'='*60}") print(f" CalVer tag: {tag_name}") print(f" SemVer: v{current_version} → v{new_version}") print(f" Previous tag: {prev_tag or '(none — first release)'}") print(f" Commits: {len(commits)}") print(f" Unique authors: {len(set(c['github_author'] for c in commits))}") print(f" Mode: {'PUBLISH' if args.publish else 'DRY RUN'}") print(f"{'='*60}") print() # Generate changelog changelog = generate_changelog( commits, tag_name, new_version, prev_tag=prev_tag, first_release=args.first_release, ) if args.output: Path(args.output).write_text(changelog) print(f"Changelog written to {args.output}") else: print(changelog) if args.publish: print(f"\n{'='*60}") print(" Publishing release...") print(f"{'='*60}") # Update version files if args.bump: update_version_files(new_version, calver_date) print(f" ✓ Updated version files to v{new_version} ({calver_date})") # Commit version bump git("add", str(VERSION_FILE), str(PYPROJECT_FILE)) git("commit", "-m", f"chore: bump version to v{new_version} ({calver_date})") print(f" ✓ Committed version bump") # Create annotated tag git("tag", "-a", tag_name, "-m", f"Hermes Agent v{new_version} ({calver_date})\n\nWeekly release") print(f" ✓ Created tag {tag_name}") # Push push_result = git("push", "origin", "HEAD", "--tags") print(f" ✓ Pushed to origin") # Create GitHub release changelog_file = REPO_ROOT / ".release_notes.md" changelog_file.write_text(changelog) result = subprocess.run( ["gh", "release", "create", tag_name, "--title", f"Hermes Agent v{new_version} ({calver_date})", "--notes-file", str(changelog_file)], capture_output=True, text=True, cwd=str(REPO_ROOT), ) changelog_file.unlink(missing_ok=True) if result.returncode == 0: print(f" ✓ GitHub release created: {result.stdout.strip()}") else: print(f" ✗ GitHub release failed: {result.stderr}") print(f" Tag was created. Create the release manually:") print(f" gh release create {tag_name} --title 'Hermes Agent v{new_version} ({calver_date})'") print(f"\n 🎉 Release v{new_version} ({tag_name}) published!") else: print(f"\n{'='*60}") print(f" Dry run complete. To publish, add --publish") print(f" Example: python scripts/release.py --bump minor --publish") print(f"{'='*60}") if __name__ == "__main__": main()