mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat: add versioning infrastructure and release script
- Fix version mismatch: __init__.py had 'v1.0.0', pyproject.toml had '0.1.0' Now both use '0.1.0' (no v prefix — added in display code only) - Add __release_date__ for CalVer date tracking alongside SemVer version - Fix double-v bug in cmd_version (was printing 'vv1.0.0') - Update banner title to show 'Hermes Agent v0.1.0 (2026.3.12)' format - Update cli.py banner to match new format - Add scripts/release.py: full release automation tool - Generates categorized changelogs from git history - Maps git authors to GitHub @mentions (70+ contributors) - Supports dry-run preview and --publish mode - Creates annotated CalVer git tags + GitHub Releases - Bumps semver in source files automatically - Usage: python scripts/release.py --bump minor --publish - Add .release_notes.md to .gitignore Versioning scheme: CalVer tags (v2026.3.12) + SemVer display (v0.1.0)
This commit is contained in:
parent
1956b9d97a
commit
323ca70846
6 changed files with 600 additions and 56 deletions
101
.gitignore
vendored
101
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
cli.py
4
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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
540
scripts/release.py
Executable file
540
scripts/release.py
Executable file
|
|
@ -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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue