chore: prepare Hermes for Homebrew packaging (#4099)
Co-authored-by: Yabuku-xD <78594762+Yabuku-xD@users.noreply.github.com>
This commit is contained in:
4
MANIFEST.in
Normal file
4
MANIFEST.in
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
graft skills
|
||||||
|
graft optional-skills
|
||||||
|
global-exclude __pycache__
|
||||||
|
global-exclude *.py[cod]
|
||||||
@@ -325,9 +325,9 @@ def _check_unavailable_skill(command_name: str) -> str | None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Check optional skills (shipped with repo but not installed)
|
# Check optional skills (shipped with repo but not installed)
|
||||||
from hermes_constants import get_hermes_home
|
from hermes_constants import get_hermes_home, get_optional_skills_dir
|
||||||
repo_root = Path(__file__).resolve().parent.parent
|
repo_root = Path(__file__).resolve().parent.parent
|
||||||
optional_dir = repo_root / "optional-skills"
|
optional_dir = get_optional_skills_dir(repo_root / "optional-skills")
|
||||||
if optional_dir.exists():
|
if optional_dir.exists():
|
||||||
for skill_md in optional_dir.rglob("SKILL.md"):
|
for skill_md in optional_dir.rglob("SKILL.md"):
|
||||||
name = skill_md.parent.name.lower().replace("_", "-")
|
name = skill_md.parent.name.lower().replace("_", "-")
|
||||||
@@ -4695,6 +4695,10 @@ class GatewayRunner:
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from hermes_cli.config import is_managed, format_managed_message
|
||||||
|
|
||||||
|
if is_managed():
|
||||||
|
return f"✗ {format_managed_message('update Hermes Agent')}"
|
||||||
|
|
||||||
project_root = Path(__file__).parent.parent.resolve()
|
project_root = Path(__file__).parent.parent.resolve()
|
||||||
git_dir = project_root / '.git'
|
git_dir = project_root / '.git'
|
||||||
|
|||||||
@@ -432,10 +432,11 @@ def build_welcome_banner(console: Console, model: str, cwd: str,
|
|||||||
try:
|
try:
|
||||||
behind = get_update_result(timeout=0.5)
|
behind = get_update_result(timeout=0.5)
|
||||||
if behind and behind > 0:
|
if behind and behind > 0:
|
||||||
|
from hermes_cli.config import recommended_update_command
|
||||||
commits_word = "commit" if behind == 1 else "commits"
|
commits_word = "commit" if behind == 1 else "commits"
|
||||||
right_lines.append(
|
right_lines.append(
|
||||||
f"[bold yellow]⚠ {behind} {commits_word} behind[/]"
|
f"[bold yellow]⚠ {behind} {commits_word} behind[/]"
|
||||||
f"[dim yellow] — run [bold]hermes update[/bold] to update[/]"
|
f"[dim yellow] — run [bold]{recommended_update_command()}[/bold] to update[/]"
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Never break the banner over an update check
|
pass # Never break the banner over an update check
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from hermes_cli.config import get_hermes_home, get_config_path, load_config, save_config
|
from hermes_cli.config import get_hermes_home, get_config_path, load_config, save_config
|
||||||
|
from hermes_constants import get_optional_skills_dir
|
||||||
from hermes_cli.setup import (
|
from hermes_cli.setup import (
|
||||||
Colors,
|
Colors,
|
||||||
color,
|
color,
|
||||||
@@ -27,8 +28,7 @@ logger = logging.getLogger(__name__)
|
|||||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||||
|
|
||||||
_OPENCLAW_SCRIPT = (
|
_OPENCLAW_SCRIPT = (
|
||||||
PROJECT_ROOT
|
get_optional_skills_dir(PROJECT_ROOT / "optional-skills")
|
||||||
/ "optional-skills"
|
|
||||||
/ "migration"
|
/ "migration"
|
||||||
/ "openclaw-migration"
|
/ "openclaw-migration"
|
||||||
/ "scripts"
|
/ "scripts"
|
||||||
|
|||||||
@@ -52,26 +52,86 @@ from hermes_cli.default_soul import DEFAULT_SOUL_MD
|
|||||||
# Managed mode (NixOS declarative config)
|
# Managed mode (NixOS declarative config)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
_MANAGED_TRUE_VALUES = ("true", "1", "yes")
|
||||||
|
_MANAGED_SYSTEM_NAMES = {
|
||||||
|
"brew": "Homebrew",
|
||||||
|
"homebrew": "Homebrew",
|
||||||
|
"nix": "NixOS",
|
||||||
|
"nixos": "NixOS",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_managed_system() -> Optional[str]:
|
||||||
|
"""Return the package manager owning this install, if any."""
|
||||||
|
raw = os.getenv("HERMES_MANAGED", "").strip()
|
||||||
|
if raw:
|
||||||
|
normalized = raw.lower()
|
||||||
|
if normalized in _MANAGED_TRUE_VALUES:
|
||||||
|
return "NixOS"
|
||||||
|
return _MANAGED_SYSTEM_NAMES.get(normalized, raw)
|
||||||
|
|
||||||
|
managed_marker = get_hermes_home() / ".managed"
|
||||||
|
if managed_marker.exists():
|
||||||
|
return "NixOS"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def is_managed() -> bool:
|
def is_managed() -> bool:
|
||||||
"""Check if hermes is running in Nix-managed mode.
|
"""Check if Hermes is running in package-manager-managed mode.
|
||||||
|
|
||||||
Two signals: the HERMES_MANAGED env var (set by the systemd service),
|
Two signals: the HERMES_MANAGED env var (set by the systemd service),
|
||||||
or a .managed marker file in HERMES_HOME (set by the NixOS activation
|
or a .managed marker file in HERMES_HOME (set by the NixOS activation
|
||||||
script, so interactive shells also see it).
|
script, so interactive shells also see it).
|
||||||
"""
|
"""
|
||||||
if os.getenv("HERMES_MANAGED", "").lower() in ("true", "1", "yes"):
|
return get_managed_system() is not None
|
||||||
return True
|
|
||||||
managed_marker = get_hermes_home() / ".managed"
|
|
||||||
return managed_marker.exists()
|
def get_managed_update_command() -> Optional[str]:
|
||||||
|
"""Return the preferred upgrade command for a managed install."""
|
||||||
|
managed_system = get_managed_system()
|
||||||
|
if managed_system == "Homebrew":
|
||||||
|
return "brew upgrade hermes-agent"
|
||||||
|
if managed_system == "NixOS":
|
||||||
|
return "sudo nixos-rebuild switch"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def recommended_update_command() -> str:
|
||||||
|
"""Return the best update command for the current installation."""
|
||||||
|
return get_managed_update_command() or "hermes update"
|
||||||
|
|
||||||
|
|
||||||
|
def format_managed_message(action: str = "modify this Hermes installation") -> str:
|
||||||
|
"""Build a user-facing error for managed installs."""
|
||||||
|
managed_system = get_managed_system() or "a package manager"
|
||||||
|
raw = os.getenv("HERMES_MANAGED", "").strip().lower()
|
||||||
|
|
||||||
|
if managed_system == "NixOS":
|
||||||
|
env_hint = "true" if raw in _MANAGED_TRUE_VALUES else raw or "true"
|
||||||
|
return (
|
||||||
|
f"Cannot {action}: this Hermes installation is managed by NixOS "
|
||||||
|
f"(HERMES_MANAGED={env_hint}).\n"
|
||||||
|
"Edit services.hermes-agent.settings in your configuration.nix and run:\n"
|
||||||
|
" sudo nixos-rebuild switch"
|
||||||
|
)
|
||||||
|
|
||||||
|
if managed_system == "Homebrew":
|
||||||
|
env_hint = raw or "homebrew"
|
||||||
|
return (
|
||||||
|
f"Cannot {action}: this Hermes installation is managed by Homebrew "
|
||||||
|
f"(HERMES_MANAGED={env_hint}).\n"
|
||||||
|
"Use:\n"
|
||||||
|
" brew upgrade hermes-agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"Cannot {action}: this Hermes installation is managed by {managed_system}.\n"
|
||||||
|
"Use your package manager to upgrade or reinstall Hermes."
|
||||||
|
)
|
||||||
|
|
||||||
def managed_error(action: str = "modify configuration"):
|
def managed_error(action: str = "modify configuration"):
|
||||||
"""Print user-friendly error for managed mode."""
|
"""Print user-friendly error for managed mode."""
|
||||||
print(
|
print(format_managed_message(action), file=sys.stderr)
|
||||||
f"Cannot {action}: configuration is managed by NixOS (HERMES_MANAGED=true).\n"
|
|
||||||
"Edit services.hermes-agent.settings in your configuration.nix and run:\n"
|
|
||||||
" sudo nixos-rebuild switch",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -2467,10 +2467,14 @@ def cmd_version(args):
|
|||||||
# Show update status (synchronous — acceptable since user asked for version info)
|
# Show update status (synchronous — acceptable since user asked for version info)
|
||||||
try:
|
try:
|
||||||
from hermes_cli.banner import check_for_updates
|
from hermes_cli.banner import check_for_updates
|
||||||
|
from hermes_cli.config import recommended_update_command
|
||||||
behind = check_for_updates()
|
behind = check_for_updates()
|
||||||
if behind and behind > 0:
|
if behind and behind > 0:
|
||||||
commits_word = "commit" if behind == 1 else "commits"
|
commits_word = "commit" if behind == 1 else "commits"
|
||||||
print(f"Update available: {behind} {commits_word} behind — run 'hermes update'")
|
print(
|
||||||
|
f"Update available: {behind} {commits_word} behind — "
|
||||||
|
f"run '{recommended_update_command()}'"
|
||||||
|
)
|
||||||
elif behind == 0:
|
elif behind == 0:
|
||||||
print("Up to date")
|
print("Up to date")
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -2821,6 +2825,11 @@ def _invalidate_update_cache():
|
|||||||
def cmd_update(args):
|
def cmd_update(args):
|
||||||
"""Update Hermes Agent to the latest version."""
|
"""Update Hermes Agent to the latest version."""
|
||||||
import shutil
|
import shutil
|
||||||
|
from hermes_cli.config import is_managed, managed_error
|
||||||
|
|
||||||
|
if is_managed():
|
||||||
|
managed_error("update Hermes Agent")
|
||||||
|
return
|
||||||
|
|
||||||
print("⚕ Updating Hermes Agent...")
|
print("⚕ Updating Hermes Agent...")
|
||||||
print()
|
print()
|
||||||
|
|||||||
@@ -265,10 +265,11 @@ def cmd_install(identifier: str, force: bool = False) -> None:
|
|||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if mv_int > _SUPPORTED_MANIFEST_VERSION:
|
if mv_int > _SUPPORTED_MANIFEST_VERSION:
|
||||||
|
from hermes_cli.config import recommended_update_command
|
||||||
console.print(
|
console.print(
|
||||||
f"[red]Error:[/red] Plugin '{plugin_name}' requires manifest_version "
|
f"[red]Error:[/red] Plugin '{plugin_name}' requires manifest_version "
|
||||||
f"{mv}, but this installer only supports up to {_SUPPORTED_MANIFEST_VERSION}.\n"
|
f"{mv}, but this installer only supports up to {_SUPPORTED_MANIFEST_VERSION}.\n"
|
||||||
f"Run [bold]hermes update[/bold] to get a newer installer."
|
f"Run [bold]{recommended_update_command()}[/bold] to get a newer installer."
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
from hermes_constants import get_optional_skills_dir
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||||
@@ -3121,8 +3123,7 @@ def _skip_configured_section(
|
|||||||
|
|
||||||
|
|
||||||
_OPENCLAW_SCRIPT = (
|
_OPENCLAW_SCRIPT = (
|
||||||
PROJECT_ROOT
|
get_optional_skills_dir(PROJECT_ROOT / "optional-skills")
|
||||||
/ "optional-skills"
|
|
||||||
/ "migration"
|
/ "migration"
|
||||||
/ "openclaw-migration"
|
/ "openclaw-migration"
|
||||||
/ "scripts"
|
/ "scripts"
|
||||||
|
|||||||
@@ -17,6 +17,20 @@ def get_hermes_home() -> Path:
|
|||||||
return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||||
|
|
||||||
|
|
||||||
|
def get_optional_skills_dir(default: Path | None = None) -> Path:
|
||||||
|
"""Return the optional-skills directory, honoring package-manager wrappers.
|
||||||
|
|
||||||
|
Packaged installs may ship ``optional-skills`` outside the Python package
|
||||||
|
tree and expose it via ``HERMES_OPTIONAL_SKILLS``.
|
||||||
|
"""
|
||||||
|
override = os.getenv("HERMES_OPTIONAL_SKILLS", "").strip()
|
||||||
|
if override:
|
||||||
|
return Path(override)
|
||||||
|
if default is not None:
|
||||||
|
return default
|
||||||
|
return get_hermes_home() / "optional-skills"
|
||||||
|
|
||||||
|
|
||||||
def get_hermes_dir(new_subpath: str, old_name: str) -> Path:
|
def get_hermes_dir(new_subpath: str, old_name: str) -> Path:
|
||||||
"""Resolve a Hermes subdirectory with backward compatibility.
|
"""Resolve a Hermes subdirectory with backward compatibility.
|
||||||
|
|
||||||
|
|||||||
14
packaging/homebrew/README.md
Normal file
14
packaging/homebrew/README.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
Homebrew packaging notes for Hermes Agent.
|
||||||
|
|
||||||
|
Use `packaging/homebrew/hermes-agent.rb` as a tap or `homebrew-core` starting point.
|
||||||
|
|
||||||
|
Key choices:
|
||||||
|
- Stable builds should target the semver-named sdist asset attached to each GitHub release, not the CalVer tag tarball.
|
||||||
|
- `faster-whisper` now lives in the `voice` extra, which keeps wheel-only transitive dependencies out of the base Homebrew formula.
|
||||||
|
- The wrapper exports `HERMES_BUNDLED_SKILLS`, `HERMES_OPTIONAL_SKILLS`, and `HERMES_MANAGED=homebrew` so packaged installs keep runtime assets and defer upgrades to Homebrew.
|
||||||
|
|
||||||
|
Typical update flow:
|
||||||
|
1. Bump the formula `url`, `version`, and `sha256`.
|
||||||
|
2. Refresh Python resources with `brew update-python-resources --print-only hermes-agent`.
|
||||||
|
3. Keep `ignore_packages: %w[certifi cryptography pydantic]`.
|
||||||
|
4. Verify `brew audit --new --strict hermes-agent` and `brew test hermes-agent`.
|
||||||
48
packaging/homebrew/hermes-agent.rb
Normal file
48
packaging/homebrew/hermes-agent.rb
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
class HermesAgent < Formula
|
||||||
|
include Language::Python::Virtualenv
|
||||||
|
|
||||||
|
desc "Self-improving AI agent that creates skills from experience"
|
||||||
|
homepage "https://hermes-agent.nousresearch.com"
|
||||||
|
# Stable source should point at the semver-named sdist asset attached by
|
||||||
|
# scripts/release.py, not the CalVer tag tarball.
|
||||||
|
url "https://github.com/NousResearch/hermes-agent/releases/download/v2026.3.30/hermes_agent-0.6.0.tar.gz"
|
||||||
|
sha256 "<replace-with-release-asset-sha256>"
|
||||||
|
license "MIT"
|
||||||
|
|
||||||
|
depends_on "certifi" => :no_linkage
|
||||||
|
depends_on "cryptography" => :no_linkage
|
||||||
|
depends_on "libyaml"
|
||||||
|
depends_on "python@3.14"
|
||||||
|
|
||||||
|
pypi_packages ignore_packages: %w[certifi cryptography pydantic]
|
||||||
|
|
||||||
|
# Refresh resource stanzas after bumping the source url/version:
|
||||||
|
# brew update-python-resources --print-only hermes-agent
|
||||||
|
|
||||||
|
def install
|
||||||
|
venv = virtualenv_create(libexec, "python3.14")
|
||||||
|
venv.pip_install resources
|
||||||
|
venv.pip_install buildpath
|
||||||
|
|
||||||
|
pkgshare.install "skills", "optional-skills"
|
||||||
|
|
||||||
|
%w[hermes hermes-agent hermes-acp].each do |exe|
|
||||||
|
next unless (libexec/"bin"/exe).exist?
|
||||||
|
|
||||||
|
(bin/exe).write_env_script(
|
||||||
|
libexec/"bin"/exe,
|
||||||
|
HERMES_BUNDLED_SKILLS: pkgshare/"skills",
|
||||||
|
HERMES_OPTIONAL_SKILLS: pkgshare/"optional-skills",
|
||||||
|
HERMES_MANAGED: "homebrew"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test do
|
||||||
|
assert_match "Hermes Agent v#{version}", shell_output("#{bin}/hermes version")
|
||||||
|
|
||||||
|
managed = shell_output("#{bin}/hermes update 2>&1")
|
||||||
|
assert_match "managed by Homebrew", managed
|
||||||
|
assert_match "brew upgrade hermes-agent", managed
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -32,7 +32,6 @@ dependencies = [
|
|||||||
"fal-client>=0.13.1,<1",
|
"fal-client>=0.13.1,<1",
|
||||||
# Text-to-speech (Edge TTS is free, no API key needed)
|
# Text-to-speech (Edge TTS is free, no API key needed)
|
||||||
"edge-tts>=7.2.7,<8",
|
"edge-tts>=7.2.7,<8",
|
||||||
"faster-whisper>=1.0.0,<2",
|
|
||||||
# Skills Hub (GitHub App JWT auth — optional, only needed for bot identity)
|
# Skills Hub (GitHub App JWT auth — optional, only needed for bot identity)
|
||||||
"PyJWT[crypto]>=2.12.0,<3", # CVE-2026-32597
|
"PyJWT[crypto]>=2.12.0,<3", # CVE-2026-32597
|
||||||
]
|
]
|
||||||
@@ -47,7 +46,13 @@ slack = ["slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"]
|
|||||||
matrix = ["matrix-nio[e2e]>=0.24.0,<1"]
|
matrix = ["matrix-nio[e2e]>=0.24.0,<1"]
|
||||||
cli = ["simple-term-menu>=1.0,<2"]
|
cli = ["simple-term-menu>=1.0,<2"]
|
||||||
tts-premium = ["elevenlabs>=1.0,<2"]
|
tts-premium = ["elevenlabs>=1.0,<2"]
|
||||||
voice = ["sounddevice>=0.4.6,<1", "numpy>=1.24.0,<3"]
|
voice = [
|
||||||
|
# Local STT pulls in wheel-only transitive deps (ctranslate2, onnxruntime),
|
||||||
|
# so keep it out of the base install for source-build packagers like Homebrew.
|
||||||
|
"faster-whisper>=1.0.0,<2",
|
||||||
|
"sounddevice>=0.4.6,<1",
|
||||||
|
"numpy>=1.24.0,<3",
|
||||||
|
]
|
||||||
pty = [
|
pty = [
|
||||||
"ptyprocess>=0.7.0,<1; sys_platform != 'win32'",
|
"ptyprocess>=0.7.0,<1; sys_platform != 'win32'",
|
||||||
"pywinpty>=2.0.0,<3; sys_platform == 'win32'",
|
"pywinpty>=2.0.0,<3; sys_platform == 'win32'",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import argparse
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
@@ -128,6 +129,16 @@ def git(*args, cwd=None):
|
|||||||
return result.stdout.strip()
|
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():
|
def get_last_tag():
|
||||||
"""Get the most recent CalVer tag."""
|
"""Get the most recent CalVer tag."""
|
||||||
tags = git("tag", "--list", "v20*", "--sort=-v:refname")
|
tags = git("tag", "--list", "v20*", "--sort=-v:refname")
|
||||||
@@ -136,6 +147,18 @@ def get_last_tag():
|
|||||||
return None
|
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():
|
def get_current_version():
|
||||||
"""Read current semver from __init__.py."""
|
"""Read current semver from __init__.py."""
|
||||||
content = VERSION_FILE.read_text()
|
content = VERSION_FILE.read_text()
|
||||||
@@ -192,6 +215,41 @@ def update_version_files(semver: str, calver_date: str):
|
|||||||
PYPROJECT_FILE.write_text(pyproject)
|
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:
|
def resolve_author(name: str, email: str) -> str:
|
||||||
"""Resolve a git author to a GitHub @mention."""
|
"""Resolve a git author to a GitHub @mention."""
|
||||||
# Try email lookup first
|
# Try email lookup first
|
||||||
@@ -424,18 +482,10 @@ def main():
|
|||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
calver_date = f"{now.year}.{now.month}.{now.day}"
|
calver_date = f"{now.year}.{now.month}.{now.day}"
|
||||||
|
|
||||||
tag_name = f"v{calver_date}"
|
base_tag = f"v{calver_date}"
|
||||||
|
tag_name, calver_date = next_available_tag(base_tag)
|
||||||
# Check for existing tag with same date
|
if tag_name != base_tag:
|
||||||
existing = git("tag", "--list", tag_name)
|
print(f"Note: Tag {base_tag} already exists, using {tag_name}")
|
||||||
if existing and not args.publish:
|
|
||||||
# Append a suffix for same-day releases
|
|
||||||
suffix = 2
|
|
||||||
while git("tag", "--list", f"{tag_name}.{suffix}"):
|
|
||||||
suffix += 1
|
|
||||||
tag_name = f"{tag_name}.{suffix}"
|
|
||||||
calver_date = f"{calver_date}.{suffix}"
|
|
||||||
print(f"Note: Tag {tag_name[:-2]} already exists, using {tag_name}")
|
|
||||||
|
|
||||||
# Determine semver
|
# Determine semver
|
||||||
current_version = get_current_version()
|
current_version = get_current_version()
|
||||||
@@ -494,41 +544,83 @@ def main():
|
|||||||
print(f" ✓ Updated version files to v{new_version} ({calver_date})")
|
print(f" ✓ Updated version files to v{new_version} ({calver_date})")
|
||||||
|
|
||||||
# Commit version bump
|
# Commit version bump
|
||||||
git("add", str(VERSION_FILE), str(PYPROJECT_FILE))
|
add_result = git_result("add", str(VERSION_FILE), str(PYPROJECT_FILE))
|
||||||
git("commit", "-m", f"chore: bump version to v{new_version} ({calver_date})")
|
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")
|
print(f" ✓ Committed version bump")
|
||||||
|
|
||||||
# Create annotated tag
|
# Create annotated tag
|
||||||
git("tag", "-a", tag_name, "-m",
|
tag_result = git_result(
|
||||||
f"Hermes Agent v{new_version} ({calver_date})\n\nWeekly release")
|
"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}")
|
print(f" ✓ Created tag {tag_name}")
|
||||||
|
|
||||||
# Push
|
# Push
|
||||||
push_result = git("push", "origin", "HEAD", "--tags")
|
push_result = git_result("push", "origin", "HEAD", "--tags")
|
||||||
print(f" ✓ Pushed to origin")
|
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
|
# Create GitHub release
|
||||||
changelog_file = REPO_ROOT / ".release_notes.md"
|
changelog_file = REPO_ROOT / ".release_notes.md"
|
||||||
changelog_file.write_text(changelog)
|
changelog_file.write_text(changelog)
|
||||||
|
|
||||||
result = subprocess.run(
|
gh_cmd = [
|
||||||
["gh", "release", "create", tag_name,
|
"gh", "release", "create", tag_name,
|
||||||
"--title", f"Hermes Agent v{new_version} ({calver_date})",
|
"--title", f"Hermes Agent v{new_version} ({calver_date})",
|
||||||
"--notes-file", str(changelog_file)],
|
"--notes-file", str(changelog_file),
|
||||||
capture_output=True, text=True,
|
]
|
||||||
cwd=str(REPO_ROOT),
|
gh_cmd.extend(str(path) for path in artifacts)
|
||||||
)
|
|
||||||
|
|
||||||
changelog_file.unlink(missing_ok=True)
|
gh_bin = shutil.which("gh")
|
||||||
|
if gh_bin:
|
||||||
if result.returncode == 0:
|
result = subprocess.run(
|
||||||
print(f" ✓ GitHub release created: {result.stdout.strip()}")
|
gh_cmd,
|
||||||
|
capture_output=True, text=True,
|
||||||
|
cwd=str(REPO_ROOT),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
print(f" ✗ GitHub release failed: {result.stderr}")
|
result = None
|
||||||
print(f" Tag was created. Create the release manually:")
|
|
||||||
print(f" gh release create {tag_name} --title 'Hermes Agent v{new_version} ({calver_date})'")
|
|
||||||
|
|
||||||
print(f"\n 🎉 Release v{new_version} ({tag_name}) published!")
|
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:
|
else:
|
||||||
print(f"\n{'='*60}")
|
print(f"\n{'='*60}")
|
||||||
print(f" Dry run complete. To publish, add --publish")
|
print(f" Dry run complete. To publish, add --publish")
|
||||||
|
|||||||
@@ -45,6 +45,17 @@ def _make_runner():
|
|||||||
class TestHandleUpdateCommand:
|
class TestHandleUpdateCommand:
|
||||||
"""Tests for GatewayRunner._handle_update_command."""
|
"""Tests for GatewayRunner._handle_update_command."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_managed_install_returns_package_manager_guidance(self, monkeypatch):
|
||||||
|
runner = _make_runner()
|
||||||
|
event = _make_event()
|
||||||
|
monkeypatch.setenv("HERMES_MANAGED", "homebrew")
|
||||||
|
|
||||||
|
result = await runner._handle_update_command(event)
|
||||||
|
|
||||||
|
assert "managed by Homebrew" in result
|
||||||
|
assert "brew upgrade hermes-agent" in result
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_no_git_directory(self, tmp_path):
|
async def test_no_git_directory(self, tmp_path):
|
||||||
"""Returns an error when .git does not exist."""
|
"""Returns an error when .git does not exist."""
|
||||||
|
|||||||
54
tests/hermes_cli/test_managed_installs.py
Normal file
54
tests/hermes_cli/test_managed_installs.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from hermes_cli.config import (
|
||||||
|
format_managed_message,
|
||||||
|
get_managed_system,
|
||||||
|
recommended_update_command,
|
||||||
|
)
|
||||||
|
from hermes_cli.main import cmd_update
|
||||||
|
from tools.skills_hub import OptionalSkillSource
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_managed_system_homebrew(monkeypatch):
|
||||||
|
monkeypatch.setenv("HERMES_MANAGED", "homebrew")
|
||||||
|
|
||||||
|
assert get_managed_system() == "Homebrew"
|
||||||
|
assert recommended_update_command() == "brew upgrade hermes-agent"
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_managed_message_homebrew(monkeypatch):
|
||||||
|
monkeypatch.setenv("HERMES_MANAGED", "homebrew")
|
||||||
|
|
||||||
|
message = format_managed_message("update Hermes Agent")
|
||||||
|
|
||||||
|
assert "managed by Homebrew" in message
|
||||||
|
assert "brew upgrade hermes-agent" in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_recommended_update_command_defaults_to_hermes_update(monkeypatch):
|
||||||
|
monkeypatch.delenv("HERMES_MANAGED", raising=False)
|
||||||
|
|
||||||
|
assert recommended_update_command() == "hermes update"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cmd_update_blocks_managed_homebrew(monkeypatch, capsys):
|
||||||
|
monkeypatch.setenv("HERMES_MANAGED", "homebrew")
|
||||||
|
|
||||||
|
with patch("hermes_cli.main.subprocess.run") as mock_run:
|
||||||
|
cmd_update(SimpleNamespace())
|
||||||
|
|
||||||
|
assert not mock_run.called
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "managed by Homebrew" in captured.err
|
||||||
|
assert "brew upgrade hermes-agent" in captured.err
|
||||||
|
|
||||||
|
|
||||||
|
def test_optional_skill_source_honors_env_override(monkeypatch, tmp_path):
|
||||||
|
optional_dir = tmp_path / "optional-skills"
|
||||||
|
optional_dir.mkdir()
|
||||||
|
monkeypatch.setenv("HERMES_OPTIONAL_SKILLS", str(optional_dir))
|
||||||
|
|
||||||
|
source = OptionalSkillSource()
|
||||||
|
|
||||||
|
assert source._optional_dir == optional_dir
|
||||||
22
tests/test_packaging_metadata.py
Normal file
22
tests/test_packaging_metadata.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_faster_whisper_is_not_a_base_dependency():
|
||||||
|
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
|
||||||
|
deps = data["project"]["dependencies"]
|
||||||
|
|
||||||
|
assert not any(dep.startswith("faster-whisper") for dep in deps)
|
||||||
|
|
||||||
|
voice_extra = data["project"]["optional-dependencies"]["voice"]
|
||||||
|
assert any(dep.startswith("faster-whisper") for dep in voice_extra)
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_includes_bundled_skills():
|
||||||
|
manifest = (REPO_ROOT / "MANIFEST.in").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "graft skills" in manifest
|
||||||
|
assert "graft optional-skills" in manifest
|
||||||
@@ -2115,7 +2115,11 @@ class OptionalSkillSource(SkillSource):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._optional_dir = Path(__file__).parent.parent / "optional-skills"
|
from hermes_constants import get_optional_skills_dir
|
||||||
|
|
||||||
|
self._optional_dir = get_optional_skills_dir(
|
||||||
|
Path(__file__).parent.parent / "optional-skills"
|
||||||
|
)
|
||||||
|
|
||||||
def source_id(self) -> str:
|
def source_id(self) -> str:
|
||||||
return "official"
|
return "official"
|
||||||
|
|||||||
Reference in New Issue
Block a user