mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
945 lines
37 KiB
Python
Executable file
945 lines
37 KiB
Python
Executable file
#!/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 re
|
|
import shutil
|
|
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)
|
|
"wangqiang@wangqiangdeMac-mini.local": "xiaoqiang243",
|
|
"snreynolds2506@gmail.com": "snreynolds",
|
|
"35742124+0xbyt4@users.noreply.github.com": "0xbyt4",
|
|
"71184274+MassiveMassimo@users.noreply.github.com": "MassiveMassimo",
|
|
"massivemassimo@users.noreply.github.com": "MassiveMassimo",
|
|
"82637225+kshitijk4poor@users.noreply.github.com": "kshitijk4poor",
|
|
"keifergu@tencent.com": "keifergu",
|
|
"kshitijk4poor@users.noreply.github.com": "kshitijk4poor",
|
|
"abner.the.foreman@agentmail.to": "Abnertheforeman",
|
|
"harryykyle1@gmail.com": "hharry11",
|
|
"kshitijk4poor@gmail.com": "kshitijk4poor",
|
|
"16443023+stablegenius49@users.noreply.github.com": "stablegenius49",
|
|
"185121704+stablegenius49@users.noreply.github.com": "stablegenius49",
|
|
"101283333+batuhankocyigit@users.noreply.github.com": "batuhankocyigit",
|
|
"255305877+ismell0992-afk@users.noreply.github.com": "ismell0992-afk",
|
|
"valdi.jorge@gmail.com": "jvcl",
|
|
"francip@gmail.com": "francip",
|
|
"omni@comelse.com": "omnissiah-comelse",
|
|
"oussama.redcode@gmail.com": "mavrickdeveloper",
|
|
"126368201+vilkasdev@users.noreply.github.com": "vilkasdev",
|
|
"137614867+cutepawss@users.noreply.github.com": "cutepawss",
|
|
"96793918+memosr@users.noreply.github.com": "memosr",
|
|
"milkoor@users.noreply.github.com": "milkoor",
|
|
"xuerui911@gmail.com": "Fatty911",
|
|
"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",
|
|
"sir_even@icloud.com": "sirEven",
|
|
"36056348+sirEven@users.noreply.github.com": "sirEven",
|
|
"70424851+insecurejezza@users.noreply.github.com": "insecurejezza",
|
|
"254021826+dodo-reach@users.noreply.github.com": "dodo-reach",
|
|
"259807879+Bartok9@users.noreply.github.com": "Bartok9",
|
|
"241404605+MestreY0d4-Uninter@users.noreply.github.com": "MestreY0d4-Uninter",
|
|
"268667990+Roy-oss1@users.noreply.github.com": "Roy-oss1",
|
|
"27917469+nosleepcassette@users.noreply.github.com": "nosleepcassette",
|
|
"241404605+MestreY0d4-Uninter@users.noreply.github.com": "MestreY0d4-Uninter",
|
|
"109555139+davetist@users.noreply.github.com": "davetist",
|
|
"39405770+yyq4193@users.noreply.github.com": "yyq4193",
|
|
"Asunfly@users.noreply.github.com": "Asunfly",
|
|
"2500400+honghua@users.noreply.github.com": "honghua",
|
|
"462836+jplew@users.noreply.github.com": "jplew",
|
|
"nish3451@users.noreply.github.com": "nish3451",
|
|
"Mibayy@users.noreply.github.com": "Mibayy",
|
|
"mibayy@users.noreply.github.com": "Mibayy",
|
|
"135070653+sgaofen@users.noreply.github.com": "sgaofen",
|
|
"nocoo@users.noreply.github.com": "nocoo",
|
|
"30841158+n-WN@users.noreply.github.com": "n-WN",
|
|
"tsuijinglei@gmail.com": "hiddenpuppy",
|
|
"jerome@clawwork.ai": "HiddenPuppy",
|
|
"leoyuan0099@gmail.com": "keyuyuan",
|
|
"bxzt2006@163.com": "Only-Code-A",
|
|
"i@troy-y.org": "TroyMitchell911",
|
|
"mygamez@163.com": "zhongyueming1121",
|
|
"hansnow@users.noreply.github.com": "hansnow",
|
|
"134848055+UNLINEARITY@users.noreply.github.com": "UNLINEARITY",
|
|
"ben.burtenshaw@gmail.com": "burtenshaw",
|
|
"roopaknijhara@gmail.com": "rnijhara",
|
|
# contributors (manual mapping from git names)
|
|
"ahmedsherif95@gmail.com": "asheriif",
|
|
"liujinkun@bytedance.com": "liujinkun2025",
|
|
"dmayhem93@gmail.com": "dmahan93",
|
|
"fr@tecompanytea.com": "ifrederico",
|
|
"cdanis@gmail.com": "cdanis",
|
|
"samherring99@gmail.com": "samherring99",
|
|
"desaiaum08@gmail.com": "Aum08Desai",
|
|
"shannon.sands.1979@gmail.com": "shannonsands",
|
|
"shannon@nousresearch.com": "shannonsands",
|
|
"abdi.moya@gmail.com": "AxDSan",
|
|
"eri@plasticlabs.ai": "Erosika",
|
|
"hjcpuro@gmail.com": "hjc-puro",
|
|
"xaydinoktay@gmail.com": "aydnOktay",
|
|
"abdullahfarukozden@gmail.com": "Farukest",
|
|
"lovre.pesut@gmail.com": "rovle",
|
|
"xjtumj@gmail.com": "mengjian-github",
|
|
"kevinskysunny@gmail.com": "kevinskysunny",
|
|
"xiewenxuan462@gmail.com": "yule975",
|
|
"yiweimeng.dlut@hotmail.com": "meng93",
|
|
"hakanerten02@hotmail.com": "teyrebaz33",
|
|
"linux2010@users.noreply.github.com": "Linux2010",
|
|
"elmatadorgh@users.noreply.github.com": "elmatadorgh",
|
|
"alexazzjjtt@163.com": "alexzhu0",
|
|
"1180176+Swift42@users.noreply.github.com": "Swift42",
|
|
"ruzzgarcn@gmail.com": "Ruzzgar",
|
|
"yukipukikedy@gmail.com": "Yukipukii1",
|
|
"alireza78.crypto@gmail.com": "alireza78a",
|
|
"brooklyn.bb.nicholson@gmail.com": "brooklynnicholson",
|
|
"withapurpose37@gmail.com": "StefanIsMe",
|
|
"4317663+helix4u@users.noreply.github.com": "helix4u",
|
|
"ifkellx@users.noreply.github.com": "Ifkellx",
|
|
"331214+counterposition@users.noreply.github.com": "counterposition",
|
|
"blspear@gmail.com": "BrennerSpear",
|
|
"akhater@gmail.com": "akhater",
|
|
"Cos_Admin@PTG-COS.lodluvup4uaudnm3ycd14giyug.xx.internal.cloudapp.net": "akhater",
|
|
"239876380+handsdiff@users.noreply.github.com": "handsdiff",
|
|
"hesapacicam112@gmail.com": "etherman-os",
|
|
"mark.ramsell@rivermounts.com": "mark-ramsell",
|
|
"taeng02@icloud.com": "taeng0204",
|
|
"gpickett00@gmail.com": "gpickett00",
|
|
"mcosma@gmail.com": "wakamex",
|
|
"clawdia.nash@proton.me": "clawdia-nash",
|
|
"pickett.austin@gmail.com": "austinpickett",
|
|
"dangtc94@gmail.com": "dieutx",
|
|
"jaisehgal11299@gmail.com": "jaisup",
|
|
"percydikec@gmail.com": "PercyDikec",
|
|
"noonou7@gmail.com": "HenkDz",
|
|
"dean.kerr@gmail.com": "deankerr",
|
|
"socrates1024@gmail.com": "socrates1024",
|
|
"seanalt555@gmail.com": "Salt-555",
|
|
"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",
|
|
"hcn518@gmail.com": "pedh",
|
|
"haileymarshall005@gmail.com": "haileymarshall",
|
|
"greer.guthrie@gmail.com": "g-guthrie",
|
|
"kennyx102@gmail.com": "bobashopcashier",
|
|
"shokatalishaikh95@gmail.com": "areu01or00",
|
|
"bryan@intertwinesys.com": "bryanyoung",
|
|
"christo.mitov@gmail.com": "christomitov",
|
|
"hermes@nousresearch.com": "NousResearch",
|
|
"hermes@noushq.ai": "benbarclay",
|
|
"chinmingcock@gmail.com": "ChimingLiu",
|
|
"openclaw@sparklab.ai": "openclaw",
|
|
"semihcvlk53@gmail.com": "Himess",
|
|
"erenkar950@gmail.com": "erenkarakus",
|
|
"adavyasharma@gmail.com": "adavyas",
|
|
"acaayush1111@gmail.com": "aayushchaudhary",
|
|
"jason@outland.art": "jasonoutland",
|
|
"73175452+Magaav@users.noreply.github.com": "Magaav",
|
|
"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": "alt-glitch",
|
|
"oluwadareab12@gmail.com": "bennytimz",
|
|
"simon@simonmarcus.org": "simon-marcus",
|
|
"xowiekk@gmail.com": "Xowiek",
|
|
"1243352777@qq.com": "zons-zhaozhy",
|
|
"e.silacandmr@gmail.com": "Es1la",
|
|
# ── bulk addition: 75 emails resolved via API, PR salvage bodies, noreply
|
|
# crossref, and GH contributor list matching (April 2026 audit) ──
|
|
"1115117931@qq.com": "aaronagent",
|
|
"1506751656@qq.com": "hqhq1025",
|
|
"364939526@qq.com": "luyao618",
|
|
"hgk324@gmail.com": "houziershi",
|
|
"176644217+PStarH@users.noreply.github.com": "PStarH",
|
|
"51058514+Sanjays2402@users.noreply.github.com": "Sanjays2402",
|
|
"906014227@qq.com": "bingo906",
|
|
"aaronwong1999@icloud.com": "AaronWong1999",
|
|
"agents@kylefrench.dev": "DeployFaith",
|
|
"angelos@oikos.lan.home.malaiwah.com": "angelos",
|
|
"aptx4561@gmail.com": "cokemine",
|
|
"arilotter@gmail.com": "ethernet8023",
|
|
"ben@nousresearch.com": "benbarclay",
|
|
"birdiegyal@gmail.com": "yyovil",
|
|
"boschi1997@gmail.com": "nicoloboschi",
|
|
"chef.ya@gmail.com": "cherifya",
|
|
"chlqhdtn98@gmail.com": "BongSuCHOI",
|
|
"coffeemjj@gmail.com": "Cafexss",
|
|
"dalianmao0107@gmail.com": "dalianmao000",
|
|
"der@konsi.org": "konsisumer",
|
|
"dgrieco@redhat.com": "DomGrieco",
|
|
"dhicham.pro@gmail.com": "spideystreet",
|
|
"dipp.who@gmail.com": "dippwho",
|
|
"don.rhm@gmail.com": "donrhmexe",
|
|
"dorukardahan@hotmail.com": "dorukardahan",
|
|
"dsocolobsky@gmail.com": "dsocolobsky",
|
|
"dylan.socolobsky@lambdaclass.com": "dsocolobsky",
|
|
"ignacio.avecilla@lambdaclass.com": "IAvecilla",
|
|
"duerzy@gmail.com": "duerzy",
|
|
"emozilla@nousresearch.com": "emozilla",
|
|
"fancydirty@gmail.com": "fancydirty",
|
|
"farion1231@gmail.com": "farion1231",
|
|
"floptopbot33@gmail.com": "flobo3",
|
|
"fontana.pedro93@gmail.com": "pefontana",
|
|
"francis.x.fitzpatrick@gmail.com": "fxfitz",
|
|
"frank@helmschrott.de": "Helmi",
|
|
"gaixg94@gmail.com": "gaixianggeng",
|
|
"geoff.wellman@gmail.com": "geoffwellman",
|
|
"han.shan@live.cn": "jamesarch",
|
|
"haolong@microsoft.com": "LongOddCode",
|
|
"hata1234@gmail.com": "hata1234",
|
|
"hmbown@gmail.com": "Hmbown",
|
|
"iacobs@m0n5t3r.info": "m0n5t3r",
|
|
"jiayuw794@gmail.com": "JiayuuWang",
|
|
"jonny@nousresearch.com": "jquesnelle",
|
|
"juan.ovalle@mistral.ai": "jjovalle99",
|
|
"julien.talbot@ergonomia.re": "Julientalbot",
|
|
"kagura.chen28@gmail.com": "kagura-agent",
|
|
"1342088860@qq.com": "youngDoo",
|
|
"kamil@gwozdz.me": "kamil-gwozdz",
|
|
"skmishra1991@gmail.com": "bugkill3r",
|
|
"karamusti912@gmail.com": "MustafaKara7",
|
|
"kira@ariaki.me": "kira-ariaki",
|
|
"knopki@duck.com": "knopki",
|
|
"limars874@gmail.com": "limars874",
|
|
"lisicheng168@gmail.com": "lesterli",
|
|
"mingjwan@microsoft.com": "MagicRay1217",
|
|
"orangeko@gmail.com": "GenKoKo",
|
|
"82095453+iacker@users.noreply.github.com": "iacker",
|
|
"sontianye@users.noreply.github.com": "sontianye",
|
|
"jackjin1997@users.noreply.github.com": "jackjin1997",
|
|
"1037461232@qq.com": "jackjin1997",
|
|
"danieldoderlein@users.noreply.github.com": "danieldoderlein",
|
|
"lrawnsley@users.noreply.github.com": "lrawnsley",
|
|
"taeuk178@users.noreply.github.com": "taeuk178",
|
|
"ogzerber@users.noreply.github.com": "ogzerber",
|
|
"cola-runner@users.noreply.github.com": "cola-runner",
|
|
"ygd58@users.noreply.github.com": "ygd58",
|
|
"vominh1919@users.noreply.github.com": "vominh1919",
|
|
"iamagenius00@users.noreply.github.com": "iamagenius00",
|
|
"9219265+cresslank@users.noreply.github.com": "cresslank",
|
|
"trevmanthony@gmail.com": "trevthefoolish",
|
|
"ziliangpeng@users.noreply.github.com": "ziliangpeng",
|
|
"centripetal-star@users.noreply.github.com": "centripetal-star",
|
|
"LeonSGP43@users.noreply.github.com": "LeonSGP43",
|
|
"154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43",
|
|
"Lubrsy706@users.noreply.github.com": "Lubrsy706",
|
|
"niyant@spicefi.xyz": "spniyant",
|
|
"olafthiele@gmail.com": "olafthiele",
|
|
"oncuevtv@gmail.com": "sprmn24",
|
|
"programming@olafthiele.com": "olafthiele",
|
|
"r2668940489@gmail.com": "r266-tech",
|
|
"s5460703@gmail.com": "BlackishGreen33",
|
|
"saul.jj.wu@gmail.com": "SaulJWu",
|
|
"shenhaocheng19990111@gmail.com": "hcshen0111",
|
|
"sjtuwbh@gmail.com": "Cygra",
|
|
"srhtsrht17@gmail.com": "Sertug17",
|
|
"stephenschoettler@gmail.com": "stephenschoettler",
|
|
"tanishq231003@gmail.com": "yyovil",
|
|
"taosiyuan163@153.com": "taosiyuan163",
|
|
"tesseracttars@gmail.com": "tesseracttars-creator",
|
|
"tianliangjay@gmail.com": "xingkongliang",
|
|
"tranquil_flow@protonmail.com": "Tranquil-Flow",
|
|
"unayung@gmail.com": "Unayung",
|
|
"vorvul.danylo@gmail.com": "WorldInnovationsDepartment",
|
|
"win4r@outlook.com": "win4r",
|
|
"xush@xush.org": "KUSH42",
|
|
"yangzhi.see@gmail.com": "SeeYangZhi",
|
|
"yongtenglei@gmail.com": "yongtenglei",
|
|
"young@YoungdeMacBook-Pro.local": "YoungYang963",
|
|
"ysfalweshcan@gmail.com": "Junass1",
|
|
"ysfwaxlycan@gmail.com": "WAXLYY",
|
|
"yusufalweshdemir@gmail.com": "Dusk1e",
|
|
"zhouboli@gmail.com": "zhouboli",
|
|
"zqiao@microsoft.com": "tomqiaozc",
|
|
"zzn+pa@zzn.im": "xinbenlv",
|
|
"zaynjarvis@gmail.com": "ZaynJarvis",
|
|
"zhiheng.liu@bytedance.com": "ZaynJarvis",
|
|
"mbelleau@Michels-MacBook-Pro.local": "malaiwah",
|
|
"michel.belleau@malaiwah.com": "malaiwah",
|
|
"gnanasekaran.sekareee@gmail.com": "gnanam1990",
|
|
"jz.pentest@gmail.com": "0xyg3n",
|
|
"hypnosis.mda@gmail.com": "Hypn0sis",
|
|
"ywt000818@gmail.com": "OwenYWT",
|
|
"dhandhalyabhavik@gmail.com": "v1k22",
|
|
"rucchizhao@zhaochenfeideMacBook-Pro.local": "RucchiZ",
|
|
"tannerfokkens@Mac.attlocal.net": "tannerfokkens-maker",
|
|
"lehaolin98@outlook.com": "LehaoLin",
|
|
"yuewang1@microsoft.com": "imink",
|
|
"1736355688@qq.com": "hedgeho9X",
|
|
"bernylinville@devopsthink.org": "bernylinville",
|
|
"brian@bde.io": "briandevans",
|
|
"hubin_ll@qq.com": "LLQWQ",
|
|
"memosr_email@gmail.com": "memosr",
|
|
"anthhub@163.com": "anthhub",
|
|
"shenuu@gmail.com": "shenuu",
|
|
"xiayh17@gmail.com": "xiayh0107",
|
|
"zhujianxyz@gmail.com": "opriz",
|
|
"asurla@nvidia.com": "anniesurla",
|
|
"limkuan24@gmail.com": "WideLee",
|
|
"aviralarora002@gmail.com": "AviArora02-commits",
|
|
"draixagent@gmail.com": "draix",
|
|
"junminliu@gmail.com": "JimLiu",
|
|
"jarvischer@gmail.com": "maxchernin",
|
|
"levantam.98.2324@gmail.com": "LVT382009",
|
|
"zhurongcheng@rcrai.com": "heykb",
|
|
"withapurpose37@gmail.com": "StefanIsMe",
|
|
"261797239+lumenradley@users.noreply.github.com": "lumenradley",
|
|
"166376523+sjz-ks@users.noreply.github.com": "sjz-ks",
|
|
"haileymarshall005@gmail.com": "haileymarshall",
|
|
"aniruddhaadak80@users.noreply.github.com": "aniruddhaadak80",
|
|
"zheng.jerilyn@gmail.com": "jerilynzheng",
|
|
"asslaenn5@gmail.com": "Aslaaen",
|
|
"shalompmc0505@naver.com": "pinion05",
|
|
"105142614+VTRiot@users.noreply.github.com": "VTRiot",
|
|
"vivien000812@gmail.com": "iamagenius00",
|
|
"89228157+Feranmi10@users.noreply.github.com": "Feranmi10",
|
|
"simon@gtcl.us": "simon-gtcl",
|
|
"suzukaze.haduki@gmail.com": "houko",
|
|
"cliff@cigii.com": "cgarwood82",
|
|
"anna@oa.ke": "anna-oake",
|
|
"jaffarkeikei@gmail.com": "jaffarkeikei",
|
|
"hxp@hxp.plus": "hxp-plus",
|
|
"3580442280@qq.com": "Tianworld",
|
|
"wujianxu91@gmail.com": "wujhsu",
|
|
"zhrh120@gmail.com": "niyoh120",
|
|
"vrinek@hey.com": "vrinek",
|
|
"268198004+xandersbell@users.noreply.github.com": "xandersbell",
|
|
"somme4096@gmail.com": "Somme4096",
|
|
"brian@tiuxo.com": "brianclemens",
|
|
"25944632+yudaiyan@users.noreply.github.com": "yudaiyan",
|
|
"chayton@sina.com": "ycbai",
|
|
"longsizhuo@gmail.com": "longsizhuo",
|
|
"chenb19870707@gmail.com": "ms-alan",
|
|
"276886827+WuTianyi123@users.noreply.github.com": "WuTianyi123",
|
|
"22549957+li0near@users.noreply.github.com": "li0near",
|
|
"23434080+sicnuyudidi@users.noreply.github.com": "sicnuyudidi",
|
|
"haimu0x0@proton.me": "haimu0x",
|
|
"abdelmajidnidnasser1@gmail.com": "NIDNASSER-Abdelmajid",
|
|
"projectadmin@wit.id": "projectadmin-dev",
|
|
"mrigankamondal10@gmail.com": "Dev-Mriganka",
|
|
"132275809+shushuzn@users.noreply.github.com": "shushuzn",
|
|
"ibrahimozsarac@gmail.com": "iborazzi",
|
|
"130149563+A-afflatus@users.noreply.github.com": "A-afflatus",
|
|
"huangkwell@163.com": "huangke19",
|
|
"tanishq@exa.ai": "10ishq",
|
|
"363708+christopherwoodall@users.noreply.github.com": "christopherwoodall",
|
|
"zhang9w0v5@qq.com": "zhang9w0v5",
|
|
"fuleinist@outlook.com": "fuleinist",
|
|
"43494187+Llugaes@users.noreply.github.com": "Llugaes",
|
|
"fengtianyu88@users.noreply.github.com": "fengtianyu88",
|
|
"l.moncany@gmail.com": "lmoncany",
|
|
"fatinghenji@users.noreply.github.com": "fatinghenji",
|
|
"xin.peng.dr@gmail.com": "xinpengdr",
|
|
"mike@mikewaters.net": "mikewaters",
|
|
}
|
|
|
|
|
|
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 git_result(*args, cwd=None):
|
|
"""Run a git command and return the full CompletedProcess."""
|
|
return subprocess.run(
|
|
["git"] + list(args),
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=cwd or str(REPO_ROOT),
|
|
)
|
|
|
|
|
|
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 next_available_tag(base_tag: str) -> tuple[str, str]:
|
|
"""Return a tag/calver pair, suffixing same-day releases when needed."""
|
|
if not git("tag", "--list", base_tag):
|
|
return base_tag, base_tag.removeprefix("v")
|
|
|
|
suffix = 2
|
|
while git("tag", "--list", f"{base_tag}.{suffix}"):
|
|
suffix += 1
|
|
tag_name = f"{base_tag}.{suffix}"
|
|
return tag_name, tag_name.removeprefix("v")
|
|
|
|
|
|
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 build_release_artifacts(semver: str) -> list[Path]:
|
|
"""Build sdist/wheel artifacts for the current release.
|
|
|
|
Returns the artifact paths when the local environment has ``python -m build``
|
|
available. If build tooling is missing or the build fails, returns an empty
|
|
list and lets the release proceed without attached Python artifacts.
|
|
"""
|
|
dist_dir = REPO_ROOT / "dist"
|
|
shutil.rmtree(dist_dir, ignore_errors=True)
|
|
|
|
result = subprocess.run(
|
|
[sys.executable, "-m", "build", "--sdist", "--wheel"],
|
|
cwd=str(REPO_ROOT),
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if result.returncode != 0:
|
|
print(" ⚠ Could not build Python release artifacts.")
|
|
stderr = result.stderr.strip()
|
|
stdout = result.stdout.strip()
|
|
if stderr:
|
|
print(f" {stderr.splitlines()[-1]}")
|
|
elif stdout:
|
|
print(f" {stdout.splitlines()[-1]}")
|
|
print(" Install the 'build' package to attach semver-named sdist/wheel assets.")
|
|
return []
|
|
|
|
artifacts = sorted(p for p in dist_dir.iterdir() if p.is_file())
|
|
matching = [p for p in artifacts if semver in p.name]
|
|
if not matching:
|
|
print(" ⚠ Built artifacts did not match the expected release version.")
|
|
return []
|
|
return matching
|
|
|
|
|
|
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 parse_coauthors(body: str) -> list:
|
|
"""Extract Co-authored-by trailers from a commit message body.
|
|
|
|
Returns a list of {'name': ..., 'email': ...} dicts.
|
|
Filters out AI assistants and bots (Claude, Copilot, Cursor, etc.).
|
|
"""
|
|
if not body:
|
|
return []
|
|
# AI/bot emails to ignore in co-author trailers
|
|
_ignored_emails = {"noreply@anthropic.com", "noreply@github.com",
|
|
"cursoragent@cursor.com", "hermes@nousresearch.com"}
|
|
_ignored_names = re.compile(r"^(Claude|Copilot|Cursor Agent|GitHub Actions?|dependabot|renovate)", re.IGNORECASE)
|
|
pattern = re.compile(r"Co-authored-by:\s*(.+?)\s*<([^>]+)>", re.IGNORECASE)
|
|
results = []
|
|
for m in pattern.finditer(body):
|
|
name, email = m.group(1).strip(), m.group(2).strip()
|
|
if email in _ignored_emails or _ignored_names.match(name):
|
|
continue
|
|
results.append({"name": name, "email": email})
|
|
return results
|
|
|
|
|
|
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\0body
|
|
# Using %x00 (null) as separator between subject and body
|
|
log = git(
|
|
"log", range_spec,
|
|
"--format=%H|%an|%ae|%s%x00%b%x00",
|
|
"--no-merges",
|
|
)
|
|
|
|
if not log:
|
|
return []
|
|
|
|
commits = []
|
|
# Split on double-null to get each commit entry, since body ends with \0
|
|
# and format ends with \0, each record ends with \0\0 between entries
|
|
for entry in log.split("\0\0"):
|
|
entry = entry.strip()
|
|
if not entry:
|
|
continue
|
|
# Split on first null to separate "hash|name|email|subject" from "body"
|
|
if "\0" in entry:
|
|
header, body = entry.split("\0", 1)
|
|
body = body.strip()
|
|
else:
|
|
header = entry
|
|
body = ""
|
|
parts = header.split("|", 3)
|
|
if len(parts) != 4:
|
|
continue
|
|
sha, name, email, subject = parts
|
|
coauthor_info = parse_coauthors(body)
|
|
coauthors = [resolve_author(ca["name"], ca["email"]) for ca in coauthor_info]
|
|
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),
|
|
"coauthors": coauthors,
|
|
})
|
|
|
|
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)
|
|
for coauthor in commit.get("coauthors", []):
|
|
if coauthor not in teknium_aliases:
|
|
all_authors.add(coauthor)
|
|
|
|
# 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
|
|
for coauthor in commit.get("coauthors", []):
|
|
if coauthor not in teknium_aliases:
|
|
author_counts[coauthor] += 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}"
|
|
|
|
base_tag = f"v{calver_date}"
|
|
tag_name, calver_date = next_available_tag(base_tag)
|
|
if tag_name != base_tag:
|
|
print(f"Note: Tag {base_tag} 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
|
|
add_result = git_result("add", str(VERSION_FILE), str(PYPROJECT_FILE))
|
|
if add_result.returncode != 0:
|
|
print(f" ✗ Failed to stage version files: {add_result.stderr.strip()}")
|
|
return
|
|
|
|
commit_result = git_result(
|
|
"commit", "-m", f"chore: bump version to v{new_version} ({calver_date})"
|
|
)
|
|
if commit_result.returncode != 0:
|
|
print(f" ✗ Failed to commit version bump: {commit_result.stderr.strip()}")
|
|
return
|
|
print(f" ✓ Committed version bump")
|
|
|
|
# Create annotated tag
|
|
tag_result = git_result(
|
|
"tag", "-a", tag_name, "-m",
|
|
f"Hermes Agent v{new_version} ({calver_date})\n\nWeekly release"
|
|
)
|
|
if tag_result.returncode != 0:
|
|
print(f" ✗ Failed to create tag {tag_name}: {tag_result.stderr.strip()}")
|
|
return
|
|
print(f" ✓ Created tag {tag_name}")
|
|
|
|
# Push
|
|
push_result = git_result("push", "origin", "HEAD", "--tags")
|
|
if push_result.returncode == 0:
|
|
print(f" ✓ Pushed to origin")
|
|
else:
|
|
print(f" ✗ Failed to push to origin: {push_result.stderr.strip()}")
|
|
print(" Continue manually after fixing access:")
|
|
print(" git push origin HEAD --tags")
|
|
|
|
# Build semver-named Python artifacts so downstream packagers
|
|
# (e.g. Homebrew) can target them without relying on CalVer tag names.
|
|
artifacts = build_release_artifacts(new_version)
|
|
if artifacts:
|
|
print(" ✓ Built release artifacts:")
|
|
for artifact in artifacts:
|
|
print(f" - {artifact.relative_to(REPO_ROOT)}")
|
|
|
|
# Create GitHub release
|
|
changelog_file = REPO_ROOT / ".release_notes.md"
|
|
changelog_file.write_text(changelog)
|
|
|
|
gh_cmd = [
|
|
"gh", "release", "create", tag_name,
|
|
"--title", f"Hermes Agent v{new_version} ({calver_date})",
|
|
"--notes-file", str(changelog_file),
|
|
]
|
|
gh_cmd.extend(str(path) for path in artifacts)
|
|
|
|
gh_bin = shutil.which("gh")
|
|
if gh_bin:
|
|
result = subprocess.run(
|
|
gh_cmd,
|
|
capture_output=True, text=True,
|
|
cwd=str(REPO_ROOT),
|
|
)
|
|
else:
|
|
result = None
|
|
|
|
if result and result.returncode == 0:
|
|
changelog_file.unlink(missing_ok=True)
|
|
print(f" ✓ GitHub release created: {result.stdout.strip()}")
|
|
print(f"\n 🎉 Release v{new_version} ({tag_name}) published!")
|
|
else:
|
|
if result is None:
|
|
print(" ✗ GitHub release skipped: `gh` CLI not found.")
|
|
else:
|
|
print(f" ✗ GitHub release failed: {result.stderr.strip()}")
|
|
print(f" Release notes kept at: {changelog_file}")
|
|
print(f" Tag was created locally. Create the release manually:")
|
|
print(
|
|
f" gh release create {tag_name} --title 'Hermes Agent v{new_version} ({calver_date})' "
|
|
f"--notes-file .release_notes.md {' '.join(str(path) for path in artifacts)}"
|
|
)
|
|
print(f"\n ✓ Release artifacts prepared for manual publish: v{new_version} ({tag_name})")
|
|
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()
|