mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-09 03:11:58 +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/
|
/venv/
|
||||||
/_pycache/
|
/_pycache/
|
||||||
*.pyc*
|
*.pyc*
|
||||||
__pycache__/
|
__pycache__/
|
||||||
.venv/
|
.venv/
|
||||||
.vscode/
|
.vscode/
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.development.local
|
.env.development.local
|
||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
.env.development
|
.env.development
|
||||||
.env.test
|
.env.test
|
||||||
export*
|
export*
|
||||||
__pycache__/model_tools.cpython-310.pyc
|
__pycache__/model_tools.cpython-310.pyc
|
||||||
__pycache__/web_tools.cpython-310.pyc
|
__pycache__/web_tools.cpython-310.pyc
|
||||||
logs/
|
logs/
|
||||||
data/
|
data/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
tmp/
|
tmp/
|
||||||
temp_vision_images/
|
temp_vision_images/
|
||||||
hermes-*/*
|
hermes-*/*
|
||||||
examples/
|
examples/
|
||||||
tests/quick_test_dataset.jsonl
|
tests/quick_test_dataset.jsonl
|
||||||
tests/sample_dataset.jsonl
|
tests/sample_dataset.jsonl
|
||||||
run_datagen_kimik2-thinking.sh
|
run_datagen_kimik2-thinking.sh
|
||||||
run_datagen_megascience_glm4-6.sh
|
run_datagen_megascience_glm4-6.sh
|
||||||
run_datagen_sonnet.sh
|
run_datagen_sonnet.sh
|
||||||
source-data/*
|
source-data/*
|
||||||
run_datagen_megascience_glm4-6.sh
|
run_datagen_megascience_glm4-6.sh
|
||||||
data/*
|
data/*
|
||||||
node_modules/
|
node_modules/
|
||||||
browser-use/
|
browser-use/
|
||||||
agent-browser/
|
agent-browser/
|
||||||
# Private keys
|
# Private keys
|
||||||
*.ppk
|
*.ppk
|
||||||
*.pem
|
*.pem
|
||||||
privvy*
|
privvy*
|
||||||
images/
|
images/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
hermes_agent.egg-info/
|
hermes_agent.egg-info/
|
||||||
wandb/
|
wandb/
|
||||||
testlogs
|
testlogs
|
||||||
|
|
||||||
# CLI config (may contain sensitive SSH paths)
|
# CLI config (may contain sensitive SSH paths)
|
||||||
cli-config.yaml
|
cli-config.yaml
|
||||||
|
|
||||||
# Skills Hub state (lives in ~/.hermes/skills/.hub/ at runtime, but just in case)
|
# Skills Hub state (lives in ~/.hermes/skills/.hub/ at runtime, but just in case)
|
||||||
skills/.hub/
|
skills/.hub/
|
||||||
ignored/
|
ignored/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
environments/benchmarks/evals/
|
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)
|
# Extracted CLI modules (Phase 3)
|
||||||
from hermes_cli.banner import (
|
from hermes_cli.banner import (
|
||||||
cprint as _cprint, _GOLD, _BOLD, _DIM, _RST,
|
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,
|
get_available_skills as _get_available_skills,
|
||||||
build_welcome_banner,
|
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
|
# Wrap in a panel with the title
|
||||||
outer_panel = Panel(
|
outer_panel = Panel(
|
||||||
layout_table,
|
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,
|
border_style=_border_c,
|
||||||
padding=(0, 2),
|
padding=(0, 2),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -11,4 +11,5 @@ Provides subcommands for:
|
||||||
- hermes cron - Manage cron jobs
|
- 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
|
# 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]██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/]
|
HERMES_AGENT_LOGO = """[bold #FFD700]██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/]
|
||||||
[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")
|
border_color = _skin_color("banner_border", "#CD7F32")
|
||||||
outer_panel = Panel(
|
outer_panel = Panel(
|
||||||
layout_table,
|
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,
|
border_style=border_color,
|
||||||
padding=(0, 2),
|
padding=(0, 2),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ os.environ.setdefault("MSWEA_SILENT_STARTUP", "1")
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from hermes_cli import __version__
|
from hermes_cli import __version__, __release_date__
|
||||||
from hermes_constants import OPENROUTER_BASE_URL
|
from hermes_constants import OPENROUTER_BASE_URL
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -1484,7 +1484,7 @@ def cmd_config(args):
|
||||||
|
|
||||||
def cmd_version(args):
|
def cmd_version(args):
|
||||||
"""Show version."""
|
"""Show version."""
|
||||||
print(f"Hermes Agent v{__version__}")
|
print(f"Hermes Agent v{__version__} ({__release_date__})")
|
||||||
print(f"Project: {PROJECT_ROOT}")
|
print(f"Project: {PROJECT_ROOT}")
|
||||||
|
|
||||||
# Show Python version
|
# 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