diff --git a/.gitignore b/.gitignore index 82f772956..cc30cd9d4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,52 +1,55 @@ -/venv/ -/_pycache/ -*.pyc* -__pycache__/ -.venv/ -.vscode/ -.env -.env.local -.env.development.local -.env.test.local -.env.production.local -.env.development -.env.test -export* -__pycache__/model_tools.cpython-310.pyc -__pycache__/web_tools.cpython-310.pyc -logs/ -data/ -.pytest_cache/ -tmp/ -temp_vision_images/ -hermes-*/* -examples/ -tests/quick_test_dataset.jsonl -tests/sample_dataset.jsonl -run_datagen_kimik2-thinking.sh -run_datagen_megascience_glm4-6.sh -run_datagen_sonnet.sh -source-data/* -run_datagen_megascience_glm4-6.sh -data/* -node_modules/ -browser-use/ -agent-browser/ -# Private keys -*.ppk -*.pem -privvy* -images/ -__pycache__/ -hermes_agent.egg-info/ -wandb/ -testlogs - -# CLI config (may contain sensitive SSH paths) -cli-config.yaml - -# Skills Hub state (lives in ~/.hermes/skills/.hub/ at runtime, but just in case) -skills/.hub/ +/venv/ +/_pycache/ +*.pyc* +__pycache__/ +.venv/ +.vscode/ +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +.env.development +.env.test +export* +__pycache__/model_tools.cpython-310.pyc +__pycache__/web_tools.cpython-310.pyc +logs/ +data/ +.pytest_cache/ +tmp/ +temp_vision_images/ +hermes-*/* +examples/ +tests/quick_test_dataset.jsonl +tests/sample_dataset.jsonl +run_datagen_kimik2-thinking.sh +run_datagen_megascience_glm4-6.sh +run_datagen_sonnet.sh +source-data/* +run_datagen_megascience_glm4-6.sh +data/* +node_modules/ +browser-use/ +agent-browser/ +# Private keys +*.ppk +*.pem +privvy* +images/ +__pycache__/ +hermes_agent.egg-info/ +wandb/ +testlogs + +# CLI config (may contain sensitive SSH paths) +cli-config.yaml + +# Skills Hub state (lives in ~/.hermes/skills/.hub/ at runtime, but just in case) +skills/.hub/ ignored/ .worktrees/ environments/benchmarks/evals/ + +# Release script temp files +.release_notes.md diff --git a/cli.py b/cli.py index 7f2b2394a..b540f13b1 100755 --- a/cli.py +++ b/cli.py @@ -416,7 +416,7 @@ from model_tools import get_tool_definitions, get_toolset_for_tool # Extracted CLI modules (Phase 3) from hermes_cli.banner import ( cprint as _cprint, _GOLD, _BOLD, _DIM, _RST, - VERSION, HERMES_AGENT_LOGO, HERMES_CADUCEUS, COMPACT_BANNER, + VERSION, RELEASE_DATE, HERMES_AGENT_LOGO, HERMES_CADUCEUS, COMPACT_BANNER, get_available_skills as _get_available_skills, build_welcome_banner, ) @@ -993,7 +993,7 @@ def build_welcome_banner(console: Console, model: str, cwd: str, tools: List[dic # Wrap in a panel with the title outer_panel = Panel( layout_table, - title=f"[bold {_title_c}]{_agent_name} {VERSION}[/]", + title=f"[bold {_title_c}]{_agent_name} v{VERSION} ({RELEASE_DATE})[/]", border_style=_border_c, padding=(0, 2), ) diff --git a/hermes_cli/__init__.py b/hermes_cli/__init__.py index 7e647afc3..58f002df2 100644 --- a/hermes_cli/__init__.py +++ b/hermes_cli/__init__.py @@ -11,4 +11,5 @@ Provides subcommands for: - hermes cron - Manage cron jobs """ -__version__ = "v1.0.0" +__version__ = "0.1.0" +__release_date__ = "2026.3.12" diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 8ab4425dc..f1925651c 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -62,7 +62,7 @@ def _skin_branding(key: str, fallback: str) -> str: # ASCII Art & Branding # ========================================================================= -from hermes_cli import __version__ as VERSION +from hermes_cli import __version__ as VERSION, __release_date__ as RELEASE_DATE HERMES_AGENT_LOGO = """[bold #FFD700]██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/] [bold #FFD700]██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/] @@ -380,7 +380,7 @@ def build_welcome_banner(console: Console, model: str, cwd: str, border_color = _skin_color("banner_border", "#CD7F32") outer_panel = Panel( layout_table, - title=f"[bold {title_color}]{agent_name} {VERSION}[/]", + title=f"[bold {title_color}]{agent_name} v{VERSION} ({RELEASE_DATE})[/]", border_style=border_color, padding=(0, 2), ) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 480aba7bf..fe591212a 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -51,7 +51,7 @@ os.environ.setdefault("MSWEA_SILENT_STARTUP", "1") import logging -from hermes_cli import __version__ +from hermes_cli import __version__, __release_date__ from hermes_constants import OPENROUTER_BASE_URL logger = logging.getLogger(__name__) @@ -1484,7 +1484,7 @@ def cmd_config(args): def cmd_version(args): """Show version.""" - print(f"Hermes Agent v{__version__}") + print(f"Hermes Agent v{__version__} ({__release_date__})") print(f"Project: {PROJECT_ROOT}") # Show Python version diff --git a/scripts/release.py b/scripts/release.py new file mode 100755 index 000000000..cafb30321 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,540 @@ +#!/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()