| |
| """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" |
|
|
| |
| |
| |
|
|
| |
| AUTHOR_MAP = { |
| |
| "teknium1@gmail.com": "teknium1", |
| "teknium@nousresearch.com": "teknium1", |
| "127238744+teknium1@users.noreply.github.com": "teknium1", |
| |
| "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", |
| |
| "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.""" |
| |
| 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) |
|
|
| |
| 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.""" |
| |
| gh_user = AUTHOR_MAP.get(email) |
| if gh_user: |
| return f"@{gh_user}" |
|
|
| |
| noreply_match = re.match(r"(\d+)\+(.+)@users\.noreply\.github\.com", email) |
| if noreply_match: |
| return f"@{noreply_match.group(2)}" |
|
|
| |
| noreply_match2 = re.match(r"(.+)@users\.noreply\.github\.com", email) |
| if noreply_match2: |
| return f"@{noreply_match2.group(1)}" |
|
|
| |
| return name |
|
|
|
|
| def categorize_commit(subject: str) -> str: |
| """Categorize a commit by its conventional commit prefix.""" |
| subject_lower = subject.lower() |
|
|
| |
| 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 |
|
|
| |
| 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.""" |
| |
| 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) |
| |
| cleaned = cleaned.strip() |
| |
| 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" |
|
|
| |
| 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 = [] |
|
|
| |
| 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("") |
|
|
| |
| 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_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"] |
|
|
| |
| 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("") |
|
|
| |
| if all_authors: |
| |
| 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("") |
|
|
| |
| 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() |
|
|
| |
| 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}" |
|
|
| |
| existing = git("tag", "--list", tag_name) |
| if existing and not args.publish: |
| |
| 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}") |
|
|
| |
| current_version = get_current_version() |
| if args.bump: |
| new_version = bump_version(current_version, args.bump) |
| else: |
| new_version = current_version |
|
|
| |
| 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}") |
|
|
| |
| 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() |
|
|
| |
| 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}") |
|
|
| |
| if args.bump: |
| update_version_files(new_version, calver_date) |
| print(f" β Updated version files to v{new_version} ({calver_date})") |
|
|
| |
| 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") |
|
|
| |
| git("tag", "-a", tag_name, "-m", |
| f"Hermes Agent v{new_version} ({calver_date})\n\nWeekly release") |
| print(f" β Created tag {tag_name}") |
|
|
| |
| push_result = git("push", "origin", "HEAD", "--tags") |
| print(f" β Pushed to origin") |
|
|
| |
| 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() |
|
|