541 lines
19 KiB
Python
541 lines
19 KiB
Python
|
|
#!/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()
|