2026-03-12 01:35:47 -07:00
|
|
|
#!/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
|
2026-03-30 17:34:43 -07:00
|
|
|
import shutil
|
2026-03-12 01:35:47 -07:00
|
|
|
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)
|
2026-04-21 19:42:36 -07:00
|
|
|
"wangqiang@wangqiangdeMac-mini.local": "xiaoqiang243",
|
2026-04-17 18:56:06 -07:00
|
|
|
"snreynolds2506@gmail.com": "snreynolds",
|
2026-03-12 01:35:47 -07:00
|
|
|
"35742124+0xbyt4@users.noreply.github.com": "0xbyt4",
|
2026-04-20 11:55:50 -07:00
|
|
|
"71184274+MassiveMassimo@users.noreply.github.com": "MassiveMassimo",
|
|
|
|
|
"massivemassimo@users.noreply.github.com": "MassiveMassimo",
|
2026-03-12 01:35:47 -07:00
|
|
|
"82637225+kshitijk4poor@users.noreply.github.com": "kshitijk4poor",
|
2026-04-22 03:35:51 -07:00
|
|
|
"keifergu@tencent.com": "keifergu",
|
2026-04-16 09:22:04 -07:00
|
|
|
"kshitijk4poor@users.noreply.github.com": "kshitijk4poor",
|
2026-04-22 05:23:59 -07:00
|
|
|
"abner.the.foreman@agentmail.to": "Abnertheforeman",
|
2026-04-22 05:51:12 -07:00
|
|
|
"harryykyle1@gmail.com": "hharry11",
|
2026-04-18 14:37:21 -07:00
|
|
|
"kshitijk4poor@gmail.com": "kshitijk4poor",
|
2026-03-12 01:35:47 -07:00
|
|
|
"16443023+stablegenius49@users.noreply.github.com": "stablegenius49",
|
|
|
|
|
"185121704+stablegenius49@users.noreply.github.com": "stablegenius49",
|
|
|
|
|
"101283333+batuhankocyigit@users.noreply.github.com": "batuhankocyigit",
|
2026-04-22 14:45:18 -07:00
|
|
|
"255305877+ismell0992-afk@users.noreply.github.com": "ismell0992-afk",
|
2026-04-17 06:26:17 -07:00
|
|
|
"valdi.jorge@gmail.com": "jvcl",
|
2026-04-21 00:52:03 -07:00
|
|
|
"francip@gmail.com": "francip",
|
2026-04-21 01:46:59 -07:00
|
|
|
"omni@comelse.com": "omnissiah-comelse",
|
2026-04-20 20:49:39 -07:00
|
|
|
"oussama.redcode@gmail.com": "mavrickdeveloper",
|
2026-03-12 01:35:47 -07:00
|
|
|
"126368201+vilkasdev@users.noreply.github.com": "vilkasdev",
|
|
|
|
|
"137614867+cutepawss@users.noreply.github.com": "cutepawss",
|
|
|
|
|
"96793918+memosr@users.noreply.github.com": "memosr",
|
2026-04-16 20:36:59 -07:00
|
|
|
"milkoor@users.noreply.github.com": "milkoor",
|
2026-04-16 21:40:35 -07:00
|
|
|
"xuerui911@gmail.com": "Fatty911",
|
2026-03-12 01:35:47 -07:00
|
|
|
"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",
|
2026-04-20 05:04:26 -07:00
|
|
|
"sir_even@icloud.com": "sirEven",
|
|
|
|
|
"36056348+sirEven@users.noreply.github.com": "sirEven",
|
2026-03-12 01:35:47 -07:00
|
|
|
"70424851+insecurejezza@users.noreply.github.com": "insecurejezza",
|
2026-04-19 11:48:14 -07:00
|
|
|
"254021826+dodo-reach@users.noreply.github.com": "dodo-reach",
|
2026-03-12 01:35:47 -07:00
|
|
|
"259807879+Bartok9@users.noreply.github.com": "Bartok9",
|
2026-04-15 15:07:11 -07:00
|
|
|
"241404605+MestreY0d4-Uninter@users.noreply.github.com": "MestreY0d4-Uninter",
|
2026-04-14 14:19:49 -07:00
|
|
|
"268667990+Roy-oss1@users.noreply.github.com": "Roy-oss1",
|
2026-04-15 22:37:46 -07:00
|
|
|
"27917469+nosleepcassette@users.noreply.github.com": "nosleepcassette",
|
2026-04-14 17:19:55 +00:00
|
|
|
"241404605+MestreY0d4-Uninter@users.noreply.github.com": "MestreY0d4-Uninter",
|
2026-04-16 05:51:37 -07:00
|
|
|
"109555139+davetist@users.noreply.github.com": "davetist",
|
2026-04-17 05:40:44 -07:00
|
|
|
"39405770+yyq4193@users.noreply.github.com": "yyq4193",
|
2026-04-17 04:09:47 -07:00
|
|
|
"Asunfly@users.noreply.github.com": "Asunfly",
|
2026-04-18 12:35:00 -07:00
|
|
|
"2500400+honghua@users.noreply.github.com": "honghua",
|
2026-04-20 05:10:02 -07:00
|
|
|
"462836+jplew@users.noreply.github.com": "jplew",
|
2026-04-18 18:52:41 -07:00
|
|
|
"nish3451@users.noreply.github.com": "nish3451",
|
2026-04-19 05:19:51 -07:00
|
|
|
"Mibayy@users.noreply.github.com": "Mibayy",
|
2026-04-20 05:15:35 -07:00
|
|
|
"mibayy@users.noreply.github.com": "Mibayy",
|
2026-04-19 15:34:02 +05:30
|
|
|
"135070653+sgaofen@users.noreply.github.com": "sgaofen",
|
fix(anthropic): complete third-party Anthropic-compatible provider support (#12846)
Third-party gateways that speak the native Anthropic protocol (MiniMax,
Zhipu GLM, Alibaba DashScope, Kimi, LiteLLM proxies) now work end-to-end
with the same feature set as direct api.anthropic.com callers. Synthesizes
eight stale community PRs into one consolidated change.
Five fixes:
- URL detection: consolidate three inline `endswith("/anthropic")`
checks in runtime_provider.py into the shared _detect_api_mode_for_url
helper. Third-party /anthropic endpoints now auto-resolve to
api_mode=anthropic_messages via one code path instead of three.
- OAuth leak-guard: all five sites that assign `_is_anthropic_oauth`
(__init__, switch_model, _try_refresh_anthropic_client_credentials,
_swap_credential, _try_activate_fallback) now gate on
`provider == "anthropic"` so a stale ANTHROPIC_TOKEN never trips
Claude-Code identity injection on third-party endpoints. Previously
only 2 of 5 sites were guarded.
- Prompt caching: new method `_anthropic_prompt_cache_policy()` returns
`(should_cache, use_native_layout)` per endpoint. Replaces three
inline conditions and the `native_anthropic=(api_mode=='anthropic_messages')`
call-site flag. Native Anthropic and third-party Anthropic gateways
both get the native cache_control layout; OpenRouter gets envelope
layout. Layout is persisted in `_primary_runtime` so fallback
restoration preserves the per-endpoint choice.
- Auxiliary client: `_try_custom_endpoint` honors
`api_mode=anthropic_messages` and builds `AnthropicAuxiliaryClient`
instead of silently downgrading to an OpenAI-wire client. Degrades
gracefully to OpenAI-wire when the anthropic SDK isn't installed.
- Config hygiene: `_update_config_for_provider` (hermes_cli/auth.py)
clears stale `api_key`/`api_mode` when switching to a built-in
provider, so a previous MiniMax custom endpoint's credentials can't
leak into a later OpenRouter session.
- Truncation continuation: length-continuation and tool-call-truncation
retry now cover `anthropic_messages` in addition to `chat_completions`
and `bedrock_converse`. Reuses the existing `_build_assistant_message`
path via `normalize_anthropic_response()` so the interim message
shape is byte-identical to the non-truncated path.
Tests: 6 new files, 42 test cases. Targeted run + tests/run_agent,
tests/agent, tests/hermes_cli all pass (4554 passed).
Synthesized from (credits preserved via Co-authored-by trailers):
#7410 @nocoo — URL detection helper
#7393 @keyuyuan — OAuth 5-site guard
#7367 @n-WN — OAuth guard (narrower cousin, kept comment)
#8636 @sgaofen — caching helper + native-vs-proxy layout split
#10954 @Only-Code-A — caching on anthropic_messages+Claude
#7648 @zhongyueming1121 — aux client anthropic_messages branch
#6096 @hansnow — /model switch clears stale api_mode
#9691 @TroyMitchell911 — anthropic_messages truncation continuation
Closes: #7366, #8294 (third-party Anthropic identity + caching).
Supersedes: #7410, #7367, #7393, #8636, #10954, #7648, #6096, #9691.
Rejects: #9621 (OpenAI-wire caching with incomplete blocklist — risky),
#7242 (superseded by #9691, stale branch),
#8321 (targets smart_model_routing which was removed in #12732).
Co-authored-by: nocoo <nocoo@users.noreply.github.com>
Co-authored-by: Keyu Yuan <leoyuan0099@gmail.com>
Co-authored-by: Zoee <30841158+n-WN@users.noreply.github.com>
Co-authored-by: sgaofen <135070653+sgaofen@users.noreply.github.com>
Co-authored-by: Only-Code-A <bxzt2006@163.com>
Co-authored-by: zhongyueming <mygamez@163.com>
Co-authored-by: Xiaohan Li <hansnow@users.noreply.github.com>
Co-authored-by: Troy Mitchell <i@troy-y.org>
2026-04-19 22:43:09 -07:00
|
|
|
"nocoo@users.noreply.github.com": "nocoo",
|
|
|
|
|
"30841158+n-WN@users.noreply.github.com": "n-WN",
|
2026-04-22 15:25:58 +08:00
|
|
|
"tsuijinglei@gmail.com": "hiddenpuppy",
|
2026-04-22 18:46:21 +05:30
|
|
|
"jerome@clawwork.ai": "HiddenPuppy",
|
fix(anthropic): complete third-party Anthropic-compatible provider support (#12846)
Third-party gateways that speak the native Anthropic protocol (MiniMax,
Zhipu GLM, Alibaba DashScope, Kimi, LiteLLM proxies) now work end-to-end
with the same feature set as direct api.anthropic.com callers. Synthesizes
eight stale community PRs into one consolidated change.
Five fixes:
- URL detection: consolidate three inline `endswith("/anthropic")`
checks in runtime_provider.py into the shared _detect_api_mode_for_url
helper. Third-party /anthropic endpoints now auto-resolve to
api_mode=anthropic_messages via one code path instead of three.
- OAuth leak-guard: all five sites that assign `_is_anthropic_oauth`
(__init__, switch_model, _try_refresh_anthropic_client_credentials,
_swap_credential, _try_activate_fallback) now gate on
`provider == "anthropic"` so a stale ANTHROPIC_TOKEN never trips
Claude-Code identity injection on third-party endpoints. Previously
only 2 of 5 sites were guarded.
- Prompt caching: new method `_anthropic_prompt_cache_policy()` returns
`(should_cache, use_native_layout)` per endpoint. Replaces three
inline conditions and the `native_anthropic=(api_mode=='anthropic_messages')`
call-site flag. Native Anthropic and third-party Anthropic gateways
both get the native cache_control layout; OpenRouter gets envelope
layout. Layout is persisted in `_primary_runtime` so fallback
restoration preserves the per-endpoint choice.
- Auxiliary client: `_try_custom_endpoint` honors
`api_mode=anthropic_messages` and builds `AnthropicAuxiliaryClient`
instead of silently downgrading to an OpenAI-wire client. Degrades
gracefully to OpenAI-wire when the anthropic SDK isn't installed.
- Config hygiene: `_update_config_for_provider` (hermes_cli/auth.py)
clears stale `api_key`/`api_mode` when switching to a built-in
provider, so a previous MiniMax custom endpoint's credentials can't
leak into a later OpenRouter session.
- Truncation continuation: length-continuation and tool-call-truncation
retry now cover `anthropic_messages` in addition to `chat_completions`
and `bedrock_converse`. Reuses the existing `_build_assistant_message`
path via `normalize_anthropic_response()` so the interim message
shape is byte-identical to the non-truncated path.
Tests: 6 new files, 42 test cases. Targeted run + tests/run_agent,
tests/agent, tests/hermes_cli all pass (4554 passed).
Synthesized from (credits preserved via Co-authored-by trailers):
#7410 @nocoo — URL detection helper
#7393 @keyuyuan — OAuth 5-site guard
#7367 @n-WN — OAuth guard (narrower cousin, kept comment)
#8636 @sgaofen — caching helper + native-vs-proxy layout split
#10954 @Only-Code-A — caching on anthropic_messages+Claude
#7648 @zhongyueming1121 — aux client anthropic_messages branch
#6096 @hansnow — /model switch clears stale api_mode
#9691 @TroyMitchell911 — anthropic_messages truncation continuation
Closes: #7366, #8294 (third-party Anthropic identity + caching).
Supersedes: #7410, #7367, #7393, #8636, #10954, #7648, #6096, #9691.
Rejects: #9621 (OpenAI-wire caching with incomplete blocklist — risky),
#7242 (superseded by #9691, stale branch),
#8321 (targets smart_model_routing which was removed in #12732).
Co-authored-by: nocoo <nocoo@users.noreply.github.com>
Co-authored-by: Keyu Yuan <leoyuan0099@gmail.com>
Co-authored-by: Zoee <30841158+n-WN@users.noreply.github.com>
Co-authored-by: sgaofen <135070653+sgaofen@users.noreply.github.com>
Co-authored-by: Only-Code-A <bxzt2006@163.com>
Co-authored-by: zhongyueming <mygamez@163.com>
Co-authored-by: Xiaohan Li <hansnow@users.noreply.github.com>
Co-authored-by: Troy Mitchell <i@troy-y.org>
2026-04-19 22:43:09 -07:00
|
|
|
"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",
|
2026-04-21 05:45:50 -07:00
|
|
|
"134848055+UNLINEARITY@users.noreply.github.com": "UNLINEARITY",
|
2026-04-21 13:29:50 -07:00
|
|
|
"ben.burtenshaw@gmail.com": "burtenshaw",
|
2026-04-22 21:15:24 +05:30
|
|
|
"roopaknijhara@gmail.com": "rnijhara",
|
2026-03-12 01:35:47 -07:00
|
|
|
# contributors (manual mapping from git names)
|
2026-04-15 13:03:31 +00:00
|
|
|
"ahmedsherif95@gmail.com": "asheriif",
|
2026-04-17 19:03:37 -07:00
|
|
|
"liujinkun@bytedance.com": "liujinkun2025",
|
2026-03-12 01:35:47 -07:00
|
|
|
"dmayhem93@gmail.com": "dmahan93",
|
2026-04-21 00:36:12 -07:00
|
|
|
"fr@tecompanytea.com": "ifrederico",
|
2026-04-20 13:11:03 -07:00
|
|
|
"cdanis@gmail.com": "cdanis",
|
2026-03-12 01:35:47 -07:00
|
|
|
"samherring99@gmail.com": "samherring99",
|
|
|
|
|
"desaiaum08@gmail.com": "Aum08Desai",
|
|
|
|
|
"shannon.sands.1979@gmail.com": "shannonsands",
|
|
|
|
|
"shannon@nousresearch.com": "shannonsands",
|
2026-04-21 00:44:45 -07:00
|
|
|
"abdi.moya@gmail.com": "AxDSan",
|
2026-03-12 01:35:47 -07:00
|
|
|
"eri@plasticlabs.ai": "Erosika",
|
|
|
|
|
"hjcpuro@gmail.com": "hjc-puro",
|
|
|
|
|
"xaydinoktay@gmail.com": "aydnOktay",
|
|
|
|
|
"abdullahfarukozden@gmail.com": "Farukest",
|
|
|
|
|
"lovre.pesut@gmail.com": "rovle",
|
2026-04-21 05:23:36 -07:00
|
|
|
"xjtumj@gmail.com": "mengjian-github",
|
fix(dingtalk): repair _extract_text for dingtalk-stream >= 0.20 SDK shape
The cherry-picked SDK compat fix (previous commit) wired process() to
parse CallbackMessage.data into a ChatbotMessage, but _extract_text()
was still written against the pre-0.20 payload shape:
* message.text changed from dict {content: ...} → TextContent object.
The old code's str(text) fallback produced 'TextContent(content=...)'
as the agent's input, so every received message came in mangled.
* rich_text moved from message.rich_text (list) to
message.rich_text_content.rich_text_list.
This preserves legacy fallbacks (dict-shaped text, bare rich_text list)
while handling the current SDK layout via hasattr(text, 'content').
Adds regression tests covering:
* webhook domain allowlist (api.*, oapi.*, and hostile lookalikes)
* _IncomingHandler.process is a coroutine function
* _extract_text against TextContent object, dict, rich_text_content,
legacy rich_text, and empty-message cases
Also adds kevinskysunny to scripts/release.py AUTHOR_MAP (release CI
blocks unmapped emails).
2026-04-17 00:38:16 -07:00
|
|
|
"kevinskysunny@gmail.com": "kevinskysunny",
|
2026-04-17 04:20:25 -07:00
|
|
|
"xiewenxuan462@gmail.com": "yule975",
|
2026-04-17 05:04:01 -07:00
|
|
|
"yiweimeng.dlut@hotmail.com": "meng93",
|
2026-03-12 01:35:47 -07:00
|
|
|
"hakanerten02@hotmail.com": "teyrebaz33",
|
2026-04-20 02:15:25 -07:00
|
|
|
"linux2010@users.noreply.github.com": "Linux2010",
|
|
|
|
|
"elmatadorgh@users.noreply.github.com": "elmatadorgh",
|
2026-04-20 03:06:14 -07:00
|
|
|
"alexazzjjtt@163.com": "alexzhu0",
|
2026-04-20 04:14:14 -07:00
|
|
|
"1180176+Swift42@users.noreply.github.com": "Swift42",
|
2026-04-15 02:56:31 +03:00
|
|
|
"ruzzgarcn@gmail.com": "Ruzzgar",
|
2026-04-20 20:53:07 -07:00
|
|
|
"yukipukikedy@gmail.com": "Yukipukii1",
|
2026-03-12 01:35:47 -07:00
|
|
|
"alireza78.crypto@gmail.com": "alireza78a",
|
|
|
|
|
"brooklyn.bb.nicholson@gmail.com": "brooklynnicholson",
|
2026-04-20 02:42:04 -07:00
|
|
|
"withapurpose37@gmail.com": "StefanIsMe",
|
2026-04-15 14:59:35 -07:00
|
|
|
"4317663+helix4u@users.noreply.github.com": "helix4u",
|
2026-04-21 14:27:07 -07:00
|
|
|
"ifkellx@users.noreply.github.com": "Ifkellx",
|
2026-04-15 15:05:11 -07:00
|
|
|
"331214+counterposition@users.noreply.github.com": "counterposition",
|
2026-04-15 16:12:31 -07:00
|
|
|
"blspear@gmail.com": "BrennerSpear",
|
2026-04-17 19:12:48 -07:00
|
|
|
"akhater@gmail.com": "akhater",
|
2026-04-15 17:10:02 -07:00
|
|
|
"239876380+handsdiff@users.noreply.github.com": "handsdiff",
|
2026-04-19 16:45:50 -07:00
|
|
|
"hesapacicam112@gmail.com": "etherman-os",
|
|
|
|
|
"mark.ramsell@rivermounts.com": "mark-ramsell",
|
2026-04-19 18:53:34 -07:00
|
|
|
"taeng02@icloud.com": "taeng0204",
|
2026-03-12 01:35:47 -07:00
|
|
|
"gpickett00@gmail.com": "gpickett00",
|
|
|
|
|
"mcosma@gmail.com": "wakamex",
|
|
|
|
|
"clawdia.nash@proton.me": "clawdia-nash",
|
|
|
|
|
"pickett.austin@gmail.com": "austinpickett",
|
2026-04-15 20:21:34 +07:00
|
|
|
"dangtc94@gmail.com": "dieutx",
|
2026-03-12 01:35:47 -07:00
|
|
|
"jaisehgal11299@gmail.com": "jaisup",
|
|
|
|
|
"percydikec@gmail.com": "PercyDikec",
|
2026-04-17 18:41:52 +01:00
|
|
|
"noonou7@gmail.com": "HenkDz",
|
2026-03-12 01:35:47 -07:00
|
|
|
"dean.kerr@gmail.com": "deankerr",
|
|
|
|
|
"socrates1024@gmail.com": "socrates1024",
|
2026-04-20 00:44:48 -07:00
|
|
|
"seanalt555@gmail.com": "Salt-555",
|
2026-03-12 01:35:47 -07:00
|
|
|
"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",
|
2026-04-17 19:17:34 -07:00
|
|
|
"hcn518@gmail.com": "pedh",
|
2026-04-20 02:08:03 -07:00
|
|
|
"haileymarshall005@gmail.com": "haileymarshall",
|
2026-04-14 15:45:09 -05:00
|
|
|
"greer.guthrie@gmail.com": "g-guthrie",
|
2026-04-13 16:31:27 -07:00
|
|
|
"kennyx102@gmail.com": "bobashopcashier",
|
2026-04-14 16:55:25 -07:00
|
|
|
"shokatalishaikh95@gmail.com": "areu01or00",
|
2026-03-12 01:35:47 -07:00
|
|
|
"bryan@intertwinesys.com": "bryanyoung",
|
|
|
|
|
"christo.mitov@gmail.com": "christomitov",
|
|
|
|
|
"hermes@nousresearch.com": "NousResearch",
|
fix(mcp-oauth): bidirectional auth_flow bridge + absolute expires_at (salvage #12025) (#12717)
* [verified] fix(mcp-oauth): bridge httpx auth_flow bidirectional generator
HermesMCPOAuthProvider.async_auth_flow wrapped the SDK's auth_flow with
'async for item in super().async_auth_flow(request): yield item', which
discards httpx's .asend(response) values and resumes the inner generator
with None. This broke every OAuth MCP server on the first HTTP response
with 'NoneType' object has no attribute 'status_code' crashing at
mcp/client/auth/oauth2.py:505.
Replace with a manual bridge that forwards .asend() values into the
inner generator, preserving httpx's bidirectional auth_flow contract.
Add tests/tools/test_mcp_oauth_bidirectional.py with two regression
tests that drive the flow through real .asend() round-trips. These
catch the bug at the unit level; prior tests only exercised
_initialize() and disk-watching, never the full generator protocol.
Verified against BetterStack MCP:
Before: 'Connection failed (11564ms): NoneType...' after 3 retries
After: 'Connected (2416ms); Tools discovered: 83'
Regression from #11383.
* [verified] fix(mcp-oauth): seed token_expiry_time + pre-flight AS discovery on cold-load
PR #11383's consolidation fixed external-refresh reloading and 401 dedup
but left two latent bugs that surfaced on BetterStack and any other OAuth
MCP with a split-origin authorization server:
1. HermesTokenStorage persisted only a relative 'expires_in', which is
meaningless after a process restart. The MCP SDK's OAuthContext
does NOT seed token_expiry_time in _initialize, so is_token_valid()
returned True for any reloaded token regardless of age. Expired
tokens shipped to servers, and app-level auth failures (e.g.
BetterStack's 'No teams found. Please check your authentication.')
were invisible to the transport-layer 401 handler.
2. Even once preemptive refresh did fire, the SDK's _refresh_token
falls back to {server_url}/token when oauth_metadata isn't cached.
For providers whose AS is at a different origin (BetterStack:
mcp.betterstack.com for MCP, betterstack.com/oauth/token for the
token endpoint), that fallback 404s and drops into full browser
re-auth on every process restart.
Fix set:
- HermesTokenStorage.set_tokens persists an absolute wall-clock
expires_at alongside the SDK's OAuthToken JSON (time.time() + TTL
at write time).
- HermesTokenStorage.get_tokens reconstructs expires_in from
max(expires_at - now, 0), clamping expired tokens to zero TTL.
Legacy files without expires_at fall back to file-mtime as a
best-effort wall-clock proxy, self-healing on the next set_tokens.
- HermesMCPOAuthProvider._initialize calls super(), then
update_token_expiry on the reloaded tokens so token_expiry_time
reflects actual remaining TTL. If tokens are loaded but
oauth_metadata is missing, pre-flight PRM + ASM discovery runs
via httpx.AsyncClient using the MCP SDK's own URL builders and
response handlers (build_protected_resource_metadata_discovery_urls,
handle_auth_metadata_response, etc.) so the SDK sees the correct
token_endpoint before the first refresh attempt. Pre-flight is
skipped when there are no stored tokens to keep fresh-install
paths zero-cost.
Test coverage (tests/tools/test_mcp_oauth_cold_load_expiry.py):
- set_tokens persists absolute expires_at
- set_tokens skips expires_at when token has no expires_in
- get_tokens round-trips expires_at -> remaining expires_in
- expired tokens reload with expires_in=0
- legacy files without expires_at fall back to mtime proxy
- _initialize seeds token_expiry_time from stored tokens
- _initialize flags expired-on-disk tokens as is_token_valid=False
- _initialize pre-flights PRM + ASM discovery with mock transport
- _initialize skips pre-flight when no tokens are stored
Verified against BetterStack MCP:
hermes mcp test betterstack -> Connected (2508ms), 83 tools
mcp_betterstack_telemetry_list_teams_tool -> real team data, not
'No teams found. Please check your authentication.'
Reference: mcp-oauth-token-diagnosis skill, Fix A.
* chore: map hermes@noushq.ai to benbarclay in AUTHOR_MAP
Needed for CI attribution check on cherry-picked commits from PR #12025.
---------
Co-authored-by: Hermes Agent <hermes@noushq.ai>
2026-04-19 16:31:07 -07:00
|
|
|
"hermes@noushq.ai": "benbarclay",
|
2026-04-14 10:17:33 -07:00
|
|
|
"chinmingcock@gmail.com": "ChimingLiu",
|
2026-03-12 01:35:47 -07:00
|
|
|
"openclaw@sparklab.ai": "openclaw",
|
|
|
|
|
"semihcvlk53@gmail.com": "Himess",
|
|
|
|
|
"erenkar950@gmail.com": "erenkarakus",
|
|
|
|
|
"adavyasharma@gmail.com": "adavyas",
|
|
|
|
|
"acaayush1111@gmail.com": "aayushchaudhary",
|
|
|
|
|
"jason@outland.art": "jasonoutland",
|
2026-04-22 15:01:50 -07:00
|
|
|
"73175452+Magaav@users.noreply.github.com": "Magaav",
|
2026-03-12 01:35:47 -07:00
|
|
|
"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",
|
2026-04-19 20:32:30 -07:00
|
|
|
"balyan.sid@gmail.com": "alt-glitch",
|
2026-04-13 22:59:23 -07:00
|
|
|
"oluwadareab12@gmail.com": "bennytimz",
|
2026-04-14 20:47:57 -07:00
|
|
|
"simon@simonmarcus.org": "simon-marcus",
|
2026-04-15 22:27:36 +03:00
|
|
|
"xowiekk@gmail.com": "Xowiek",
|
2026-04-14 20:55:34 -07:00
|
|
|
"1243352777@qq.com": "zons-zhaozhy",
|
2026-04-20 20:53:10 -07:00
|
|
|
"e.silacandmr@gmail.com": "Es1la",
|
2026-04-13 21:10:39 -07:00
|
|
|
# ── 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",
|
2026-04-20 04:55:21 -07:00
|
|
|
"hgk324@gmail.com": "houziershi",
|
2026-04-20 04:58:59 -07:00
|
|
|
"176644217+PStarH@users.noreply.github.com": "PStarH",
|
2026-04-20 05:06:04 -07:00
|
|
|
"51058514+Sanjays2402@users.noreply.github.com": "Sanjays2402",
|
2026-04-19 11:23:04 -07:00
|
|
|
"906014227@qq.com": "bingo906",
|
2026-04-13 21:10:39 -07:00
|
|
|
"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",
|
2026-04-20 14:05:15 -07:00
|
|
|
"dylan.socolobsky@lambdaclass.com": "dsocolobsky",
|
|
|
|
|
"ignacio.avecilla@lambdaclass.com": "IAvecilla",
|
2026-04-13 21:10:39 -07:00
|
|
|
"duerzy@gmail.com": "duerzy",
|
|
|
|
|
"emozilla@nousresearch.com": "emozilla",
|
|
|
|
|
"fancydirty@gmail.com": "fancydirty",
|
2026-04-19 05:44:44 -07:00
|
|
|
"farion1231@gmail.com": "farion1231",
|
2026-04-13 21:10:39 -07:00
|
|
|
"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",
|
2026-04-17 05:40:44 -07:00
|
|
|
"1342088860@qq.com": "youngDoo",
|
2026-04-13 21:10:39 -07:00
|
|
|
"kamil@gwozdz.me": "kamil-gwozdz",
|
2026-04-20 12:02:40 +05:30
|
|
|
"skmishra1991@gmail.com": "bugkill3r",
|
2026-04-13 21:10:39 -07:00
|
|
|
"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",
|
2026-04-16 05:58:52 -07:00
|
|
|
"orangeko@gmail.com": "GenKoKo",
|
2026-04-16 06:47:42 -07:00
|
|
|
"82095453+iacker@users.noreply.github.com": "iacker",
|
chore: add salvage PR contributors to AUTHOR_MAP (#11076)
Add 11 community contributors whose work was cherry-picked via
salvage PRs during the April 16 triage session. Without these
entries, contributor_audit strict mode fails for release attribution.
Contributors: sontianye, jackjin1997, danieldoderlein, lrawnsley,
taeuk178, ogzerber, cola-runner, ygd58, vominh1919, LeonSGP43,
Lubrsy706
Co-authored-by: kshitijk4poor <kshitijk4poor@users.noreply.github.com>
2026-04-16 07:44:41 -07:00
|
|
|
"sontianye@users.noreply.github.com": "sontianye",
|
|
|
|
|
"jackjin1997@users.noreply.github.com": "jackjin1997",
|
2026-04-19 23:59:16 +08:00
|
|
|
"1037461232@qq.com": "jackjin1997",
|
chore: add salvage PR contributors to AUTHOR_MAP (#11076)
Add 11 community contributors whose work was cherry-picked via
salvage PRs during the April 16 triage session. Without these
entries, contributor_audit strict mode fails for release attribution.
Contributors: sontianye, jackjin1997, danieldoderlein, lrawnsley,
taeuk178, ogzerber, cola-runner, ygd58, vominh1919, LeonSGP43,
Lubrsy706
Co-authored-by: kshitijk4poor <kshitijk4poor@users.noreply.github.com>
2026-04-16 07:44:41 -07:00
|
|
|
"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",
|
test(session-search): regression coverage for CJK LIKE fallback
Twelve tests under TestCJKSearchFallback guarding:
- CJK detection across Chinese/Japanese/Korean/Hiragana/Katakana ranges
(including the full Hangul syllables block \uac00-\ud7af, to catch
the shorter-range typo from one of the duplicate PRs)
- Substring match for multi-char Chinese, Japanese, Korean queries
- Filter preservation (source_filter, exclude_sources, role_filter)
in the LIKE path — guards against the SQL-builder bug from another
duplicate PR where filter clauses landed after LIMIT/OFFSET
- Snippet centered on the matched term (instr-based substr window),
not the leading 200 chars of content
- English fast-path untouched
- Empty/no-match cases
- Mixed CJK+English queries
Also:
- hermes_state.py: LIKE-fallback snippet is now
`substr(content, max(1, instr(content, ?) - 40), 120)`, centered on
the match instead of the whole-content default. Credit goes to
@iamagenius00 for the snippet idea in PR #11517.
- scripts/release.py: add @iamagenius00 to AUTHOR_MAP so future
release attribution resolves cleanly.
Refs #11511, #11516, #11517, #11541.
Co-authored-by: iamagenius00 <iamagenius00@users.noreply.github.com>
2026-04-18 01:56:22 -07:00
|
|
|
"iamagenius00@users.noreply.github.com": "iamagenius00",
|
2026-04-20 00:36:18 -07:00
|
|
|
"9219265+cresslank@users.noreply.github.com": "cresslank",
|
2026-04-16 23:17:20 +05:30
|
|
|
"trevmanthony@gmail.com": "trevthefoolish",
|
|
|
|
|
"ziliangpeng@users.noreply.github.com": "ziliangpeng",
|
|
|
|
|
"centripetal-star@users.noreply.github.com": "centripetal-star",
|
chore: add salvage PR contributors to AUTHOR_MAP (#11076)
Add 11 community contributors whose work was cherry-picked via
salvage PRs during the April 16 triage session. Without these
entries, contributor_audit strict mode fails for release attribution.
Contributors: sontianye, jackjin1997, danieldoderlein, lrawnsley,
taeuk178, ogzerber, cola-runner, ygd58, vominh1919, LeonSGP43,
Lubrsy706
Co-authored-by: kshitijk4poor <kshitijk4poor@users.noreply.github.com>
2026-04-16 07:44:41 -07:00
|
|
|
"LeonSGP43@users.noreply.github.com": "LeonSGP43",
|
2026-04-19 11:01:26 +05:30
|
|
|
"154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43",
|
chore: add salvage PR contributors to AUTHOR_MAP (#11076)
Add 11 community contributors whose work was cherry-picked via
salvage PRs during the April 16 triage session. Without these
entries, contributor_audit strict mode fails for release attribution.
Contributors: sontianye, jackjin1997, danieldoderlein, lrawnsley,
taeuk178, ogzerber, cola-runner, ygd58, vominh1919, LeonSGP43,
Lubrsy706
Co-authored-by: kshitijk4poor <kshitijk4poor@users.noreply.github.com>
2026-04-16 07:44:41 -07:00
|
|
|
"Lubrsy706@users.noreply.github.com": "Lubrsy706",
|
2026-04-13 21:10:39 -07:00
|
|
|
"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",
|
2026-04-23 00:12:25 +05:30
|
|
|
"taosiyuan163@153.com": "taosiyuan163",
|
2026-04-13 21:10:39 -07:00
|
|
|
"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",
|
2026-04-15 04:09:14 +03:00
|
|
|
"ysfalweshcan@gmail.com": "Junass1",
|
2026-04-13 21:10:39 -07:00
|
|
|
"ysfwaxlycan@gmail.com": "WAXLYY",
|
|
|
|
|
"yusufalweshdemir@gmail.com": "Dusk1e",
|
|
|
|
|
"zhouboli@gmail.com": "zhouboli",
|
|
|
|
|
"zqiao@microsoft.com": "tomqiaozc",
|
|
|
|
|
"zzn+pa@zzn.im": "xinbenlv",
|
2026-04-15 02:33:56 +08:00
|
|
|
"zaynjarvis@gmail.com": "ZaynJarvis",
|
2026-04-15 11:08:14 -07:00
|
|
|
"zhiheng.liu@bytedance.com": "ZaynJarvis",
|
2026-04-16 16:49:42 -07:00
|
|
|
"mbelleau@Michels-MacBook-Pro.local": "malaiwah",
|
2026-04-17 00:52:43 -07:00
|
|
|
"michel.belleau@malaiwah.com": "malaiwah",
|
2026-04-17 05:41:31 -07:00
|
|
|
"gnanasekaran.sekareee@gmail.com": "gnanam1990",
|
2026-04-17 05:47:53 -07:00
|
|
|
"jz.pentest@gmail.com": "0xyg3n",
|
2026-04-17 06:38:28 -07:00
|
|
|
"hypnosis.mda@gmail.com": "Hypn0sis",
|
|
|
|
|
"ywt000818@gmail.com": "OwenYWT",
|
2026-04-16 20:22:52 -07:00
|
|
|
"dhandhalyabhavik@gmail.com": "v1k22",
|
2026-04-17 00:55:07 -07:00
|
|
|
"rucchizhao@zhaochenfeideMacBook-Pro.local": "RucchiZ",
|
2026-04-20 20:49:49 -07:00
|
|
|
"tannerfokkens@Mac.attlocal.net": "tannerfokkens-maker",
|
2026-04-17 04:20:14 -07:00
|
|
|
"lehaolin98@outlook.com": "LehaoLin",
|
2026-04-17 04:31:17 -07:00
|
|
|
"yuewang1@microsoft.com": "imink",
|
|
|
|
|
"1736355688@qq.com": "hedgeho9X",
|
|
|
|
|
"bernylinville@devopsthink.org": "bernylinville",
|
2026-04-17 05:40:56 -07:00
|
|
|
"brian@bde.io": "briandevans",
|
|
|
|
|
"hubin_ll@qq.com": "LLQWQ",
|
2026-04-17 06:42:20 -07:00
|
|
|
"memosr_email@gmail.com": "memosr",
|
|
|
|
|
"anthhub@163.com": "anthhub",
|
|
|
|
|
"shenuu@gmail.com": "shenuu",
|
|
|
|
|
"xiayh17@gmail.com": "xiayh0107",
|
2026-04-18 02:24:35 +08:00
|
|
|
"zhujianxyz@gmail.com": "opriz",
|
2026-04-17 13:09:14 -07:00
|
|
|
"asurla@nvidia.com": "anniesurla",
|
2026-04-17 20:31:47 +08:00
|
|
|
"limkuan24@gmail.com": "WideLee",
|
2026-04-17 21:27:43 -07:00
|
|
|
"aviralarora002@gmail.com": "AviArora02-commits",
|
2026-04-19 22:04:09 -07:00
|
|
|
"draixagent@gmail.com": "draix",
|
2026-04-18 12:27:22 -07:00
|
|
|
"junminliu@gmail.com": "JimLiu",
|
2026-04-18 22:46:36 +05:30
|
|
|
"jarvischer@gmail.com": "maxchernin",
|
fix: wire _ephemeral_max_output_tokens into chat_completions and add NVIDIA NIM default
Based on #12152 by @LVT382009.
Two fixes to run_agent.py:
1. _ephemeral_max_output_tokens consumption in chat_completions path:
The error-recovery ephemeral override was only consumed in the
anthropic_messages branch of _build_api_kwargs. All chat_completions
providers (OpenRouter, NVIDIA NIM, Qwen, Alibaba, custom, etc.)
silently ignored it. Now consumed at highest priority, matching the
anthropic pattern.
2. NVIDIA NIM max_tokens default (16384):
NVIDIA NIM falls back to a very low internal default when max_tokens
is omitted, causing models like GLM-4.7 to truncate immediately
(thinking tokens exhaust the budget before the response starts).
3. Progressive length-continuation boost:
When finish_reason='length' triggers a continuation retry, the output
budget now grows progressively (2x base on retry 1, 3x on retry 2,
capped at 32768) via _ephemeral_max_output_tokens. Previously the
retry loop just re-sent the same token limit on all 3 attempts.
2026-04-18 22:49:30 +05:30
|
|
|
"levantam.98.2324@gmail.com": "LVT382009",
|
2026-04-19 11:38:42 -07:00
|
|
|
"zhurongcheng@rcrai.com": "heykb",
|
2026-04-20 02:33:28 -07:00
|
|
|
"withapurpose37@gmail.com": "StefanIsMe",
|
2026-04-20 01:55:11 -07:00
|
|
|
"261797239+lumenradley@users.noreply.github.com": "lumenradley",
|
2026-04-20 02:42:28 -07:00
|
|
|
"166376523+sjz-ks@users.noreply.github.com": "sjz-ks",
|
2026-04-20 02:46:12 -07:00
|
|
|
"haileymarshall005@gmail.com": "haileymarshall",
|
2026-04-20 12:24:15 -07:00
|
|
|
"aniruddhaadak80@users.noreply.github.com": "aniruddhaadak80",
|
2026-04-20 11:02:24 -07:00
|
|
|
"zheng.jerilyn@gmail.com": "jerilynzheng",
|
fix: extend hostname-match provider detection across remaining call sites
Aslaaen's fix in the original PR covered _detect_api_mode_for_url and the
two openai/xai sites in run_agent.py. This finishes the sweep: the same
substring-match false-positive class (e.g. https://api.openai.com.evil/v1,
https://proxy/api.openai.com/v1, https://api.anthropic.com.example/v1)
existed in eight more call sites, and the hostname helper was duplicated
in two modules.
- utils: add shared base_url_hostname() (single source of truth).
- hermes_cli/runtime_provider, run_agent: drop local duplicates, import
from utils. Reuse the cached AIAgent._base_url_hostname attribute
everywhere it's already populated.
- agent/auxiliary_client: switch codex-wrap auto-detect, max_completion_tokens
gate (auxiliary_max_tokens_param), and custom-endpoint max_tokens kwarg
selection to hostname equality.
- run_agent: native-anthropic check in the Claude-style model branch
and in the AIAgent init provider-auto-detect branch.
- agent/model_metadata: Anthropic /v1/models context-length lookup.
- hermes_cli/providers.determine_api_mode: anthropic / openai URL
heuristics for custom/unknown providers (the /anthropic path-suffix
convention for third-party gateways is preserved).
- tools/delegate_tool: anthropic detection for delegated subagent
runtimes.
- hermes_cli/setup, hermes_cli/tools_config: setup-wizard vision-endpoint
native-OpenAI detection (paired with deduping the repeated check into
a single is_native_openai boolean per branch).
Tests:
- tests/test_base_url_hostname.py covers the helper directly
(path-containing-host, host-suffix, trailing dot, port, case).
- tests/hermes_cli/test_determine_api_mode_hostname.py adds the same
regression class for determine_api_mode, plus a test that the
/anthropic third-party gateway convention still wins.
Also: add asslaenn5@gmail.com → Aslaaen to scripts/release.py AUTHOR_MAP.
2026-04-20 20:58:01 -07:00
|
|
|
"asslaenn5@gmail.com": "Aslaaen",
|
2026-04-21 02:06:45 -07:00
|
|
|
"shalompmc0505@naver.com": "pinion05",
|
2026-04-20 12:54:48 +09:00
|
|
|
"105142614+VTRiot@users.noreply.github.com": "VTRiot",
|
2026-04-22 11:20:16 +08:00
|
|
|
"vivien000812@gmail.com": "iamagenius00",
|
2026-04-22 16:32:53 -07:00
|
|
|
"89228157+Feranmi10@users.noreply.github.com": "Feranmi10",
|
2026-04-22 17:23:29 -07:00
|
|
|
"simon@gtcl.us": "simon-gtcl",
|
2026-04-22 17:24:02 -07:00
|
|
|
"suzukaze.haduki@gmail.com": "houko",
|
2026-03-12 01:35:47 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
2026-03-30 17:34:43 -07:00
|
|
|
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),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-03-12 01:35:47 -07:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-03-30 17:34:43 -07:00
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
2026-03-12 01:35:47 -07:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-03-30 17:34:43 -07:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-03-12 01:35:47 -07:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-04-13 16:31:27 -07:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-03-12 01:35:47 -07:00
|
|
|
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"
|
|
|
|
|
|
2026-04-13 16:31:27 -07:00
|
|
|
# Format: hash|author_name|author_email|subject\0body
|
|
|
|
|
# Using %x00 (null) as separator between subject and body
|
2026-03-12 01:35:47 -07:00
|
|
|
log = git(
|
|
|
|
|
"log", range_spec,
|
2026-04-13 16:31:27 -07:00
|
|
|
"--format=%H|%an|%ae|%s%x00%b%x00",
|
2026-03-12 01:35:47 -07:00
|
|
|
"--no-merges",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not log:
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
commits = []
|
2026-04-13 16:31:27 -07:00
|
|
|
# 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:
|
2026-03-12 01:35:47 -07:00
|
|
|
continue
|
2026-04-13 16:31:27 -07:00
|
|
|
# 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)
|
2026-03-12 01:35:47 -07:00
|
|
|
if len(parts) != 4:
|
|
|
|
|
continue
|
|
|
|
|
sha, name, email, subject = parts
|
2026-04-13 16:31:27 -07:00
|
|
|
coauthor_info = parse_coauthors(body)
|
|
|
|
|
coauthors = [resolve_author(ca["name"], ca["email"]) for ca in coauthor_info]
|
2026-03-12 01:35:47 -07:00
|
|
|
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),
|
2026-04-13 16:31:27 -07:00
|
|
|
"coauthors": coauthors,
|
2026-03-12 01:35:47 -07:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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)
|
2026-04-13 16:31:27 -07:00
|
|
|
for coauthor in commit.get("coauthors", []):
|
|
|
|
|
if coauthor not in teknium_aliases:
|
|
|
|
|
all_authors.add(coauthor)
|
2026-03-12 01:35:47 -07:00
|
|
|
|
|
|
|
|
# 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
|
2026-04-13 16:31:27 -07:00
|
|
|
for coauthor in commit.get("coauthors", []):
|
|
|
|
|
if coauthor not in teknium_aliases:
|
|
|
|
|
author_counts[coauthor] += 1
|
2026-03-12 01:35:47 -07:00
|
|
|
|
|
|
|
|
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}"
|
|
|
|
|
|
2026-03-30 17:34:43 -07:00
|
|
|
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}")
|
2026-03-12 01:35:47 -07:00
|
|
|
|
|
|
|
|
# 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
|
2026-03-30 17:34:43 -07:00
|
|
|
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
|
2026-03-12 01:35:47 -07:00
|
|
|
print(f" ✓ Committed version bump")
|
|
|
|
|
|
|
|
|
|
# Create annotated tag
|
2026-03-30 17:34:43 -07:00
|
|
|
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
|
2026-03-12 01:35:47 -07:00
|
|
|
print(f" ✓ Created tag {tag_name}")
|
|
|
|
|
|
|
|
|
|
# Push
|
2026-03-30 17:34:43 -07:00
|
|
|
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)}")
|
2026-03-12 01:35:47 -07:00
|
|
|
|
|
|
|
|
# Create GitHub release
|
|
|
|
|
changelog_file = REPO_ROOT / ".release_notes.md"
|
|
|
|
|
changelog_file.write_text(changelog)
|
|
|
|
|
|
2026-03-30 17:34:43 -07:00
|
|
|
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
|
2026-03-12 01:35:47 -07:00
|
|
|
|
2026-03-30 17:34:43 -07:00
|
|
|
if result and result.returncode == 0:
|
|
|
|
|
changelog_file.unlink(missing_ok=True)
|
2026-03-12 01:35:47 -07:00
|
|
|
print(f" ✓ GitHub release created: {result.stdout.strip()}")
|
2026-03-30 17:34:43 -07:00
|
|
|
print(f"\n 🎉 Release v{new_version} ({tag_name}) published!")
|
2026-03-12 01:35:47 -07:00
|
|
|
else:
|
2026-03-30 17:34:43 -07:00
|
|
|
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})")
|
2026-03-12 01:35:47 -07:00
|
|
|
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()
|