mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
refactor(version): centralize build provenance on install-stamp.json
Replace the three separate provenance paths (Nix env vars, Docker .hermes_build_sha, and live git probes) with a single install-stamp.json file read by version_info.py. All packagers (Docker, Nix, desktop) now call the shared scripts/write_install_stamp.py to produce the same canonical stamp. - Add scripts/write_install_stamp.py: shared stamp builder for all packagers - Refactor version_info.py: read stamp file first, fall back to live git - Delete hermes_cli/build_info.py (superseded by the stamp) - Refactor dump.py to use version_info instead of its own git/build_sha path - Refactor banner.py check_for_updates() to use stamp commit instead of HERMES_REVISION env vars - Dockerfile: write .hermes_build_info.json via the shared script - nix/hermes-agent.nix: write .hermes_build_info.json instead of setting HERMES_REVISION_* env vars via makeWrapper - Desktop build (package.json): call the shared Python script instead of the JS-only write-build-stamp.mjs for stamp generation - Update all tests to mock the stamp file instead of env vars
This commit is contained in:
parent
1a4c4eca0d
commit
813f3a0020
11 changed files with 465 additions and 263 deletions
26
Dockerfile
26
Dockerfile
|
|
@ -288,26 +288,16 @@ RUN mkdir -p /opt/hermes/bin && \
|
|||
# `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services
|
||||
# run as the default hermes user (UID 10000).
|
||||
|
||||
# ---------- Bake build-time git revision ----------
|
||||
# .dockerignore excludes .git, so `git rev-parse HEAD` from inside the
|
||||
# container always returns nothing — meaning `hermes dump` reports
|
||||
# "(unknown)" and the startup banner drops its `· upstream <sha>` suffix.
|
||||
# That makes support triage from container bug reports impossible:
|
||||
# we can't tell which commit the user is actually running.
|
||||
#
|
||||
# Fix: write the commit SHA passed via the HERMES_GIT_SHA build-arg to
|
||||
# /opt/hermes/.hermes_build_sha at build time, and have
|
||||
# hermes_cli/build_info.py read it at runtime. Both `hermes dump` and
|
||||
# version_info.get_version_info() try the baked SHA first, then fall back
|
||||
# to live `git rev-parse` for source installs (unchanged behaviour).
|
||||
#
|
||||
# The arg is optional — local `docker build` without --build-arg simply
|
||||
# omits the file, and the runtime falls back to live-git lookup. CI
|
||||
# (.github/workflows/docker.yml) passes ${{ github.sha }} so
|
||||
# every published image has it.
|
||||
# ---------- Bake build-time install stamp ----------
|
||||
# .dockerignore excludes .git, so runtime git lookups always fail. The shared
|
||||
# stamp script writes a canonical install-stamp.json that version_info.py
|
||||
# reads at runtime — no env vars or separate build_info module needed.
|
||||
ARG HERMES_GIT_SHA=
|
||||
RUN if [ -n "${HERMES_GIT_SHA}" ]; then \
|
||||
printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha; \
|
||||
python3 scripts/write_install_stamp.py \
|
||||
--output /opt/hermes/.hermes_build_info.json \
|
||||
--commit "${HERMES_GIT_SHA}" \
|
||||
--source docker; \
|
||||
fi
|
||||
|
||||
# ---------- s6-overlay service wiring ----------
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
"profile:main:cpu": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
|
||||
"start": "npm run build && electron .",
|
||||
"prebuild": "npm run clean",
|
||||
"build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps.mjs",
|
||||
"build": "node scripts/assert-root-install.mjs && python3 ../../scripts/write_install_stamp.py --output build/install-stamp.json && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps.mjs",
|
||||
"postbuild": "node scripts/assert-dist-built.mjs",
|
||||
"prebuilder": "node scripts/patch-electron-builder-mac-binary.mjs",
|
||||
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.mjs",
|
||||
|
|
|
|||
|
|
@ -262,9 +262,9 @@ def _check_via_local_git(repo_dir: Path) -> Optional[int]:
|
|||
def check_for_updates() -> Optional[int]:
|
||||
"""Check whether a Hermes update is available.
|
||||
|
||||
Two paths: if ``HERMES_REVISION`` is set (nix builds embed it), compare
|
||||
it to upstream main via ``git ls-remote``. Otherwise look for a local
|
||||
git checkout and count commits behind ``origin/main``.
|
||||
Two paths: if the install stamp provides a commit (packaged builds),
|
||||
compare it to upstream main via ``git ls-remote``. Otherwise look for a
|
||||
local git checkout and count commits behind ``origin/main``.
|
||||
|
||||
Returns the number of commits behind, ``UPDATE_AVAILABLE_NO_COUNT`` (-1)
|
||||
if behind but the count is unknown, ``0`` if up-to-date, or ``None`` if
|
||||
|
|
@ -272,15 +272,27 @@ def check_for_updates() -> Optional[int]:
|
|||
"""
|
||||
hermes_home = get_hermes_home()
|
||||
cache_file = hermes_home / ".update_check"
|
||||
embedded_rev = os.environ.get("HERMES_REVISION") or None
|
||||
|
||||
# Only immutable packaged-build provenance should use the remote SHA
|
||||
# comparison. Source installs resolve as ``git`` too, but retain a local
|
||||
# checkout and can calculate the exact behind count below.
|
||||
try:
|
||||
from hermes_cli.version_info import get_version_info
|
||||
|
||||
version_info = get_version_info()
|
||||
embedded_rev = (
|
||||
version_info.commit
|
||||
if version_info.source in {"nix", "docker", "build"}
|
||||
else None
|
||||
)
|
||||
except Exception:
|
||||
embedded_rev = None
|
||||
|
||||
# Docker images have no working tree to count commits against — the
|
||||
# published image excludes `.git` (see .dockerignore) and sets no
|
||||
# HERMES_REVISION (that's nix-only). Returning None makes both the Rich
|
||||
# banner (build_welcome_banner) and the Ink badge (branding.tsx, guarded
|
||||
# on `typeof === 'number' && > 0`) show nothing. The dashboard's REST
|
||||
# `/api/hermes/update/check` endpoint short-circuits docker the same way
|
||||
# (web_server.py); mirror that here so the banner/TUI surfaces agree.
|
||||
# published image excludes `.git` (see .dockerignore). Returning None
|
||||
# makes both the Rich banner and the Ink badge show nothing.
|
||||
# The dashboard's REST `/api/hermes/update/check` endpoint short-circuits
|
||||
# docker the same way (web_server.py); mirror that here so surfaces agree.
|
||||
try:
|
||||
from hermes_cli.config import detect_install_method, get_project_root
|
||||
if detect_install_method(get_project_root()) == "docker":
|
||||
|
|
@ -309,13 +321,8 @@ def check_for_updates() -> Optional[int]:
|
|||
# Prefer the running code's location over the profile-scoped path.
|
||||
# $HERMES_HOME/hermes-agent/ may be a stale copy from --clone-all;
|
||||
# Path(__file__) always resolves to the actual installed checkout.
|
||||
repo_dir = Path(__file__).parent.parent.resolve()
|
||||
if not (repo_dir / ".git").exists():
|
||||
repo_dir = hermes_home / "hermes-agent"
|
||||
if not (repo_dir / ".git").exists():
|
||||
# No git checkout and no embedded revision — can't determine
|
||||
# update status. This is the Docker path (already short-circuited
|
||||
# above) or an unsupported install without a source tree.
|
||||
repo_dir = _resolve_repo_dir()
|
||||
if repo_dir is None:
|
||||
behind = None
|
||||
else:
|
||||
behind = _check_via_local_git(repo_dir)
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
"""
|
||||
Baked-in build metadata for Hermes Agent.
|
||||
|
||||
Source installs report their git revision live via ``git rev-parse`` (see
|
||||
``hermes_cli/dump.py`` and ``hermes_cli/banner.py``). That doesn't work inside
|
||||
the published Docker image because ``.dockerignore`` excludes ``.git``, so
|
||||
those callsites fall back to ``"(unknown)"`` / drop the banner suffix entirely.
|
||||
|
||||
To make ``hermes dump`` and the startup banner identify the exact commit the
|
||||
image was built from, the Docker build writes the build-time ``$HERMES_GIT_SHA``
|
||||
arg into ``<project_root>/.hermes_build_sha``. This module is the single
|
||||
read-side helper consumed by both callsites — keeping the lookup in one place
|
||||
so the file path and missing-file behaviour stay consistent.
|
||||
|
||||
Behaviour:
|
||||
|
||||
- Returns ``None`` when the file is absent. Source installs and dev images
|
||||
built without the ``HERMES_GIT_SHA`` build-arg fall through to live-git
|
||||
resolution in the caller, so non-Docker installs are unaffected.
|
||||
- Returns ``None`` on any IO / decoding error. The build-sha is a nice-to-have
|
||||
for support triage; nothing in the CLI is allowed to crash because of it.
|
||||
- Truncates to ``short`` characters (default 8) to match the format used by
|
||||
``git rev-parse --short=8`` throughout the codebase.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Path is resolved relative to this module so it works regardless of cwd —
|
||||
# matches the pattern used by ``banner._resolve_repo_dir``.
|
||||
_BUILD_SHA_FILE = Path(__file__).parent.parent / ".hermes_build_sha"
|
||||
|
||||
|
||||
def get_build_sha(short: int = 8) -> Optional[str]:
|
||||
"""Return the baked-in build SHA, truncated to ``short`` chars, or None.
|
||||
|
||||
Reads ``<project_root>/.hermes_build_sha`` if present. The file is
|
||||
written by the Dockerfile's ``HERMES_GIT_SHA`` build-arg and contains
|
||||
the full 40-character commit hash on a single line.
|
||||
"""
|
||||
try:
|
||||
if not _BUILD_SHA_FILE.is_file():
|
||||
return None
|
||||
sha = _BUILD_SHA_FILE.read_text(encoding="utf-8").strip()
|
||||
except Exception:
|
||||
return None
|
||||
if not sha:
|
||||
return None
|
||||
return sha[:short] if short and short > 0 else sha
|
||||
|
|
@ -54,37 +54,18 @@ def _dotenv_key_names() -> set[str]:
|
|||
def _get_git_commit(project_root: Path) -> str:
|
||||
"""Return short git commit hash, or '(unknown)'.
|
||||
|
||||
Source installs and dev images resolve this live via ``git rev-parse``.
|
||||
The published Docker image excludes ``.git`` from the build context, so
|
||||
that lookup always fails — we fall back to the baked-in build SHA written
|
||||
to ``<project_root>/.hermes_build_sha`` by the Dockerfile's
|
||||
``HERMES_GIT_SHA`` build-arg (see ``hermes_cli/build_info.py``).
|
||||
The output format is identical regardless of source.
|
||||
Uses ``version_info.get_version_info()`` which reads the install stamp
|
||||
first (Docker/Nix), then falls back to live ``git rev-parse`` for source
|
||||
installs.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--short=8", "HEAD"],
|
||||
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5,
|
||||
cwd=str(project_root),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
value = result.stdout.strip()
|
||||
if value:
|
||||
return value
|
||||
from hermes_cli.version_info import get_version_info
|
||||
|
||||
info = get_version_info()
|
||||
if info.commit:
|
||||
return info.commit[:8]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fall back to the build-time baked SHA (populated in published Docker
|
||||
# images, absent otherwise). Defers the import so the dump module
|
||||
# stays cheap on non-dump code paths.
|
||||
try:
|
||||
from hermes_cli.build_info import get_build_sha
|
||||
baked = get_build_sha(short=8)
|
||||
if baked:
|
||||
return baked
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "(unknown)"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,19 @@
|
|||
|
||||
``__version__`` remains the package/API version. This module adds a display
|
||||
suffix only when it can prove the number of commits since that release.
|
||||
|
||||
Resolution order:
|
||||
1. Install stamp (``.hermes_build_info.json``) — written at build time by
|
||||
``scripts/write_install_stamp.py`` for Docker/Nix, or by
|
||||
``write-build-stamp.mjs`` for the desktop app. The stamp is authoritative
|
||||
for packaged builds.
|
||||
2. Live git — for source/dev installs with a ``.git`` directory.
|
||||
3. Unknown — no stamp and no git, can't determine provenance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -22,7 +31,7 @@ class VersionInfo:
|
|||
distance: int | None
|
||||
commit: str | None
|
||||
branch: str | None
|
||||
source: Literal["git", "nix", "build", "unknown"]
|
||||
source: Literal["git", "nix", "docker", "build", "unknown"]
|
||||
dirty: bool = False
|
||||
|
||||
|
||||
|
|
@ -75,28 +84,73 @@ def _parse_nonnegative(value: str | None) -> int | None:
|
|||
return parsed if parsed >= 0 else None
|
||||
|
||||
|
||||
def _nix_version_info() -> VersionInfo | None:
|
||||
commit = os.environ.get("HERMES_REVISION") or None
|
||||
current_count = _parse_nonnegative(os.environ.get("HERMES_REVISION_COUNT"))
|
||||
release_count = _parse_nonnegative(os.environ.get("HERMES_RELEASE_REV_COUNT"))
|
||||
if not commit:
|
||||
# --- Install stamp reader ---------------------------------------------------
|
||||
|
||||
# The stamp file lives alongside the code in source installs (Docker writes
|
||||
# it to the project root) or at a path the Nix wrapper sets via env var
|
||||
# (the derivation output and the venv are separate store paths).
|
||||
def _resolve_stamp_file() -> Path | None:
|
||||
override = os.environ.get("HERMES_BUILD_INFO")
|
||||
if override:
|
||||
p = Path(override)
|
||||
return p if p.is_file() else None
|
||||
# Source/Docker: next to the code root.
|
||||
p = Path(__file__).parent.parent / ".hermes_build_info.json"
|
||||
return p if p.is_file() else None
|
||||
|
||||
|
||||
def _stamp_version_info() -> VersionInfo | None:
|
||||
"""Read provenance from a build-time install stamp."""
|
||||
stamp_file = _resolve_stamp_file()
|
||||
if stamp_file is None:
|
||||
return None
|
||||
distance = (
|
||||
max(0, current_count - release_count)
|
||||
if current_count is not None and release_count is not None
|
||||
else None
|
||||
)
|
||||
try:
|
||||
raw = stamp_file.read_text(encoding="utf-8")
|
||||
data = json.loads(raw)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
if not isinstance(data, dict) or "commit" not in data:
|
||||
return None
|
||||
|
||||
commit = data.get("commit") or None
|
||||
if not commit or set(commit) == {"0"}:
|
||||
# All-zero placeholder = fallback stamp, not real provenance.
|
||||
return None
|
||||
|
||||
base_version = data.get("baseVersion") or __version__
|
||||
display_version = data.get("displayVersion") or base_version
|
||||
distance = data.get("distance")
|
||||
if isinstance(distance, str):
|
||||
distance = _parse_nonnegative(distance)
|
||||
|
||||
# Normalize source labels — the stamp's "source" field describes the
|
||||
# build environment (ci/local/docker/nix/fallback), not the runtime
|
||||
# provenance path. Map known packaged sources to their runtime label.
|
||||
stamp_source = str(data.get("source") or "")
|
||||
if stamp_source in ("docker", "nix"):
|
||||
source: Literal["git", "nix", "docker", "build", "unknown"] = stamp_source
|
||||
elif stamp_source in ("ci", "local"):
|
||||
# CI/local stamps are still git-based provenance — the commit was
|
||||
# resolved from git at build time and baked into the stamp.
|
||||
source = "git"
|
||||
else:
|
||||
source = "build"
|
||||
|
||||
return VersionInfo(
|
||||
__version__,
|
||||
_derived_version(__version__, distance, os.environ.get("HERMES_REVISION_DIRTY") == "1"),
|
||||
distance,
|
||||
base_version,
|
||||
display_version,
|
||||
distance if isinstance(distance, int) else None,
|
||||
commit,
|
||||
os.environ.get("HERMES_REVISION_BRANCH") or None,
|
||||
"nix",
|
||||
os.environ.get("HERMES_REVISION_DIRTY") == "1",
|
||||
data.get("branch") or None,
|
||||
source,
|
||||
bool(data.get("dirty")),
|
||||
)
|
||||
|
||||
|
||||
# --- Git provenance (source/dev installs) -----------------------------------
|
||||
|
||||
|
||||
def _git_version_info(repo_dir: Path) -> VersionInfo:
|
||||
commit = _run_git(repo_dir, "rev-parse", "HEAD")
|
||||
branch = _run_git(repo_dir, "branch", "--show-current")
|
||||
|
|
@ -129,6 +183,8 @@ def _git_version_info(repo_dir: Path) -> VersionInfo:
|
|||
)
|
||||
|
||||
|
||||
# --- Cache + public API -----------------------------------------------------
|
||||
|
||||
_cached_version_info: VersionInfo | None = None
|
||||
|
||||
|
||||
|
|
@ -139,24 +195,23 @@ def _reset_version_info_cache() -> None:
|
|||
|
||||
|
||||
def get_version_info() -> VersionInfo:
|
||||
"""Return cached provenance from Nix metadata, git, or a baked SHA."""
|
||||
"""Return cached provenance from install stamp, git, or unknown."""
|
||||
global _cached_version_info
|
||||
if _cached_version_info is not None:
|
||||
return _cached_version_info
|
||||
|
||||
info = _nix_version_info()
|
||||
# 1. Install stamp (packaged builds: Docker, Nix)
|
||||
info = _stamp_version_info()
|
||||
|
||||
# 2. Live git (source/dev installs)
|
||||
if info is None:
|
||||
repo_dir = _resolve_repo_dir()
|
||||
if repo_dir is not None:
|
||||
info = _git_version_info(repo_dir)
|
||||
else:
|
||||
try:
|
||||
from hermes_cli.build_info import get_build_sha
|
||||
|
||||
commit = get_build_sha(short=0)
|
||||
except Exception:
|
||||
commit = None
|
||||
info = VersionInfo(__version__, __version__, None, commit, None, "build" if commit else "unknown")
|
||||
# 3. Unknown — no stamp, no git
|
||||
if info is None:
|
||||
info = VersionInfo(__version__, __version__, None, None, None, "unknown")
|
||||
|
||||
_cached_version_info = info
|
||||
return info
|
||||
|
|
|
|||
|
|
@ -42,10 +42,21 @@
|
|||
extraDependencyGroups ? [ ],
|
||||
}:
|
||||
let
|
||||
version = (fromTOML (builtins.readFile ../pyproject.toml)).project.version;
|
||||
versionModule = builtins.readFile ../hermes_cli/__init__.py;
|
||||
releaseRevCountLine = lib.findFirst (line: lib.hasPrefix "__release_rev_count__" line) null (lib.splitString "\n" versionModule);
|
||||
releaseRevCountMatch = if releaseRevCountLine == null then null else builtins.match ".*= ([0-9]+)" releaseRevCountLine;
|
||||
releaseRevCount = if releaseRevCountMatch == null then null else builtins.fromJSON (builtins.elemAt releaseRevCountMatch 0);
|
||||
|
||||
# Install stamp values — written to .hermes_build_info.json so the Python
|
||||
# runtime (CLI, TUI) reads one file instead of env vars or .git probes.
|
||||
stampDistance = if revCount != null && releaseRevCount != null then lib.trivial.max 0 (revCount - releaseRevCount) else null;
|
||||
stampDisplayVersion =
|
||||
if stampDistance != null && stampDistance > 0 then "${version}+${toString stampDistance}"
|
||||
else if dirty && stampDistance == null then "${version}+?"
|
||||
else version;
|
||||
stampBranch = if branch != null then branch else "unknown";
|
||||
|
||||
nodejs = nodejs_22;
|
||||
mkHermesVenv =
|
||||
extraDependencyGroups:
|
||||
|
|
@ -169,7 +180,7 @@ let
|
|||
in
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "hermes-agent";
|
||||
version = (fromTOML (builtins.readFile ../pyproject.toml)).project.version;
|
||||
inherit version;
|
||||
|
||||
dontUnpack = true;
|
||||
dontBuild = true;
|
||||
|
|
@ -190,6 +201,13 @@ stdenv.mkDerivation (finalAttrs: {
|
|||
ln -s ${hermesWeb} $out/share/hermes-agent/web_dist
|
||||
ln -s ${hermesTui}/lib/hermes-tui $out/ui-tui
|
||||
|
||||
# Write the canonical install stamp. version_info.py reads this at
|
||||
# runtime instead of probing env vars or .git — one file, one source
|
||||
# of truth for the Python runtime (CLI, TUI).
|
||||
cat > $out/share/hermes-agent/.hermes_build_info.json <<STAMP
|
||||
{"schemaVersion":2,"commit":${builtins.toJSON rev},"branch":${builtins.toJSON stampBranch},"baseVersion":"${version}","displayVersion":"${stampDisplayVersion}","distance":${builtins.toJSON stampDistance},"dirty":${if dirty then "true" else "false"},"source":"nix"}
|
||||
STAMP
|
||||
|
||||
${lib.concatMapStringsSep "\n"
|
||||
(name: ''
|
||||
makeWrapper ${hermesVenv}/bin/${name} $out/bin/${name} \
|
||||
|
|
@ -202,28 +220,9 @@ stdenv.mkDerivation (finalAttrs: {
|
|||
--set HERMES_WEB_DIST $out/share/hermes-agent/web_dist \
|
||||
--set HERMES_TUI_DIR $out/ui-tui \
|
||||
--set HERMES_PYTHON ${hermesVenv}/bin/python3 \
|
||||
--set HERMES_NODE ${lib.getExe nodejs}${
|
||||
# Fold the line continuation INTO the optionalString: a bare
|
||||
# `\` on the line above an empty expansion would dangle onto a
|
||||
# blank line, ending the makeWrapper command early and running
|
||||
# the next flag as its own shell command (`--suffix: command
|
||||
# not found`). Only reproduces when rev == null (dirty trees).
|
||||
lib.optionalString (rev != null) " \\
|
||||
--set HERMES_REVISION ${rev}" +
|
||||
lib.optionalString (revCount != null && releaseRevCount != null) " \\
|
||||
--set HERMES_REVISION_COUNT ${toString revCount} \\
|
||||
--set HERMES_RELEASE_REV_COUNT ${toString releaseRevCount}" +
|
||||
# Always set the branch: on a dirty tree flakes can't determine
|
||||
# sourceInfo.ref, so fall back to "unknown" rather than letting
|
||||
# the runtime pick up its self-update default ("main").
|
||||
" \\
|
||||
--set HERMES_REVISION_BRANCH ${if branch != null then branch else "unknown"}" +
|
||||
lib.optionalString dirty " \\\n --set HERMES_REVISION_DIRTY 1"
|
||||
}${
|
||||
lib.optionalString (
|
||||
extraPythonPackages != [ ]
|
||||
) " \\\n --suffix PYTHONPATH : \"${pythonPath}\""
|
||||
}
|
||||
--set HERMES_NODE ${lib.getExe nodejs} \
|
||||
--set HERMES_BUILD_INFO $out/share/hermes-agent/.hermes_build_info.json${lib.optionalString (extraPythonPackages != [ ]) " \\
|
||||
--suffix PYTHONPATH : \"${pythonPath}\""}
|
||||
'')
|
||||
[
|
||||
"hermes"
|
||||
|
|
|
|||
217
scripts/write_install_stamp.py
Normal file
217
scripts/write_install_stamp.py
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
"""Generate canonical install-stamp.json for packaged Hermes builds.
|
||||
|
||||
All packagers (Docker, Nix, desktop) call this script to produce the same
|
||||
``install-stamp.json`` file. Runtime surfaces (CLI, TUI, desktop) read the
|
||||
stamp through ``hermes_cli.version_info`` — no env vars, no separate
|
||||
docker/nix code paths.
|
||||
|
||||
Usage::
|
||||
|
||||
# From a repo root with .git available (dev/CI builds):
|
||||
python scripts/write_install_stamp.py --output /path/to/install-stamp.json
|
||||
|
||||
# Override provenance for reproducible/packaged builds:
|
||||
python scripts/write_install_stamp.py --output ... \\
|
||||
--commit <sha> --branch <name> --dirty \\
|
||||
--base-version 0.19.0 --distance 42 --source nix
|
||||
|
||||
# Docker (no .git, commit known from build arg):
|
||||
python scripts/write_install_stamp.py --output /opt/hermes/.hermes_build_info.json \\
|
||||
--commit ${HERMES_GIT_SHA} --source docker
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
STAMP_SCHEMA_VERSION = 2
|
||||
_REPO_ROOT = Path(__file__).parent.parent.resolve()
|
||||
|
||||
# Hermes's historical tags use a four-digit calendar year as their major
|
||||
# component (for example v2026.7.20). Restrict release majors to three digits
|
||||
# so these date tags cannot masquerade as the v0.x.y SemVer boundaries.
|
||||
_SEMVER_TAG_RE = re.compile(r"^v(0|[1-9]\d{0,2})\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$")
|
||||
_LEGACY_CALVER_TAG_RE = re.compile(r"^v20\d{2}\.\d+\.\d+(?:\.\d+)?$")
|
||||
|
||||
FALLBACK_COMMIT = "0" * 40
|
||||
|
||||
|
||||
def _run_git(*args: str, cwd: str | Path = _REPO_ROOT) -> str | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", *args], capture_output=True, text=True, timeout=5, cwd=str(cwd)
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
value = (result.stdout or "").strip()
|
||||
return value if result.returncode == 0 and value else None
|
||||
|
||||
|
||||
def _parse_release_metadata() -> tuple[str | None, str | None]:
|
||||
"""Read __version__ and __release_date__ from hermes_cli/__init__.py."""
|
||||
try:
|
||||
text = (_REPO_ROOT / "hermes_cli" / "__init__.py").read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None, None
|
||||
version = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', text)
|
||||
date = re.search(r'__release_date__\s*=\s*["\']([^"\']+)["\']', text)
|
||||
return (version.group(1) if version else None, date.group(1) if date else None)
|
||||
|
||||
|
||||
def _resolve_commit_from_env() -> str | None:
|
||||
"""CI builds pass the commit via $GITHUB_SHA."""
|
||||
return os.environ.get("GITHUB_SHA") or None
|
||||
|
||||
|
||||
def _resolve_commit_from_git() -> str | None:
|
||||
return _run_git("rev-parse", "HEAD")
|
||||
|
||||
|
||||
def _resolve_branch_from_env() -> str | None:
|
||||
return os.environ.get("GITHUB_REF_NAME") or os.environ.get("GITHUB_HEAD_REF") or None
|
||||
|
||||
|
||||
def _resolve_branch_from_git() -> str | None:
|
||||
branch = _run_git("rev-parse", "--abbrev-ref", "HEAD")
|
||||
return branch if branch and branch != "HEAD" else None
|
||||
|
||||
|
||||
def _resolve_dirty_from_git() -> bool:
|
||||
status = _run_git("status", "--porcelain", "-uno")
|
||||
return status is not None and len(status) > 0
|
||||
|
||||
|
||||
def _compute_distance(base_version: str | None, release_date: str | None) -> int | None:
|
||||
"""Count commits since the release tag, trying SemVer then CalVer fallback."""
|
||||
if not base_version:
|
||||
return None
|
||||
|
||||
# Try SemVer tag first, then legacy CalVer tag.
|
||||
for tag in (f"v{base_version}", f"v{release_date}" if release_date else None):
|
||||
if not tag:
|
||||
continue
|
||||
raw = _run_git("rev-list", "--count", f"{tag}..HEAD")
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
count = int(raw)
|
||||
except ValueError:
|
||||
continue
|
||||
if count >= 0:
|
||||
return count
|
||||
return None
|
||||
|
||||
|
||||
def build_stamp(
|
||||
*,
|
||||
commit: str | None = None,
|
||||
branch: str | None = None,
|
||||
dirty: bool | None = None,
|
||||
base_version: str | None = None,
|
||||
distance: int | None = None,
|
||||
source: str = "local",
|
||||
) -> dict:
|
||||
"""Build a stamp dict from explicit args, filling gaps from git/env.
|
||||
|
||||
Args override detection — if you pass ``commit``, it's used directly.
|
||||
``source`` identifies where the stamp came from (``ci``, ``local``,
|
||||
``docker``, ``nix``, ``fallback``).
|
||||
"""
|
||||
_base_version, _release_date = _parse_release_metadata()
|
||||
if base_version is None:
|
||||
base_version = _base_version
|
||||
|
||||
# Commit: explicit > CI env > git
|
||||
if commit is None:
|
||||
commit = _resolve_commit_from_env()
|
||||
source = "ci" if commit else source
|
||||
if commit is None:
|
||||
commit = _resolve_commit_from_git()
|
||||
source = "local" if commit else source
|
||||
if not commit:
|
||||
commit = FALLBACK_COMMIT
|
||||
source = "fallback"
|
||||
|
||||
# Branch: explicit > CI env > git
|
||||
if branch is None:
|
||||
branch = _resolve_branch_from_env()
|
||||
if branch is None:
|
||||
branch = _resolve_branch_from_git()
|
||||
if branch is None:
|
||||
branch = "unknown"
|
||||
|
||||
# Dirty: explicit > git
|
||||
if dirty is None:
|
||||
dirty = _resolve_dirty_from_git()
|
||||
|
||||
# Distance: explicit > computed from git
|
||||
if distance is None:
|
||||
distance = _compute_distance(base_version, _release_date)
|
||||
|
||||
# Display version
|
||||
display_version = base_version or ""
|
||||
if distance is not None and distance > 0:
|
||||
display_version = f"{display_version}+{distance}"
|
||||
elif dirty and distance is None:
|
||||
display_version = f"{display_version}+?"
|
||||
|
||||
return {
|
||||
"schemaVersion": STAMP_SCHEMA_VERSION,
|
||||
"commit": commit,
|
||||
"branch": branch,
|
||||
"builtAt": datetime.now(timezone.utc).isoformat(),
|
||||
"dirty": dirty,
|
||||
"source": source,
|
||||
"baseVersion": base_version,
|
||||
"displayVersion": display_version,
|
||||
"distance": distance,
|
||||
}
|
||||
|
||||
|
||||
def write_stamp(output: str | Path, **kwargs) -> dict:
|
||||
"""Build and write an install-stamp.json to ``output``. Returns the stamp."""
|
||||
stamp = build_stamp(**kwargs)
|
||||
out_path = Path(output)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(json.dumps(stamp, indent=2) + "\n", encoding="utf-8")
|
||||
return stamp
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Write install-stamp.json")
|
||||
parser.add_argument("--output", "-o", required=True, help="Output file path")
|
||||
parser.add_argument("--commit", default=None, help="Override commit SHA")
|
||||
parser.add_argument("--branch", default=None, help="Override branch name")
|
||||
parser.add_argument("--dirty", action="store_true", default=None, help="Mark as dirty")
|
||||
parser.add_argument("--base-version", default=None, help="Override base version")
|
||||
parser.add_argument("--distance", type=int, default=None, help="Override commit distance")
|
||||
parser.add_argument("--source", default="local", help="Stamp source label")
|
||||
args = parser.parse_args()
|
||||
|
||||
stamp = write_stamp(
|
||||
args.output,
|
||||
commit=args.commit,
|
||||
branch=args.branch,
|
||||
dirty=args.dirty,
|
||||
base_version=args.base_version,
|
||||
distance=args.distance,
|
||||
source=args.source,
|
||||
)
|
||||
|
||||
commit_short = stamp["commit"][:12]
|
||||
branch_str = f" ({stamp['branch']})" if stamp["branch"] else ""
|
||||
dirty_str = " [DIRTY]" if stamp["dirty"] else ""
|
||||
fallback_str = " [FALLBACK]" if stamp["source"] == "fallback" else ""
|
||||
print(f"[write_install_stamp] wrote {args.output} -> {commit_short}{branch_str}{dirty_str}{fallback_str}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
"""Tests for hermes_cli.build_info — baked-in build SHA resolution.
|
||||
|
||||
The build SHA is written by the Dockerfile's ``HERMES_GIT_SHA`` build-arg
|
||||
into ``<project_root>/.hermes_build_sha``. These tests cover the read-side
|
||||
helper: missing file, malformed file, truncation, and error tolerance.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def test_get_build_sha_returns_none_when_file_absent(tmp_path):
|
||||
"""Source installs: no file present → None, callers fall back to git."""
|
||||
from hermes_cli import build_info
|
||||
|
||||
missing = tmp_path / ".hermes_build_sha" # never created
|
||||
|
||||
with patch.object(build_info, "_BUILD_SHA_FILE", missing):
|
||||
assert build_info.get_build_sha() is None
|
||||
|
||||
|
||||
def test_get_build_sha_reads_baked_file(tmp_path):
|
||||
"""Docker image case: file exists with full 40-char SHA → truncated to 8."""
|
||||
from hermes_cli import build_info
|
||||
|
||||
sha_file = tmp_path / ".hermes_build_sha"
|
||||
sha_file.write_text("abcdef1234567890abcdef1234567890abcdef12\n")
|
||||
|
||||
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file):
|
||||
assert build_info.get_build_sha() == "abcdef12"
|
||||
|
||||
|
||||
def test_get_build_sha_respects_short_argument(tmp_path):
|
||||
"""``short=N`` truncates to N chars; ``short<=0`` returns full SHA."""
|
||||
from hermes_cli import build_info
|
||||
|
||||
sha_file = tmp_path / ".hermes_build_sha"
|
||||
full_sha = "abcdef1234567890abcdef1234567890abcdef12"
|
||||
sha_file.write_text(full_sha + "\n")
|
||||
|
||||
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file):
|
||||
assert build_info.get_build_sha(short=12) == "abcdef123456"
|
||||
assert build_info.get_build_sha(short=0) == full_sha
|
||||
assert build_info.get_build_sha(short=-1) == full_sha
|
||||
|
||||
|
||||
def test_get_build_sha_strips_whitespace(tmp_path):
|
||||
"""The Dockerfile uses ``printf '%s\\n'`` — strip the trailing newline."""
|
||||
from hermes_cli import build_info
|
||||
|
||||
sha_file = tmp_path / ".hermes_build_sha"
|
||||
sha_file.write_text(" abcdef1234567890\n\n")
|
||||
|
||||
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file):
|
||||
assert build_info.get_build_sha() == "abcdef12"
|
||||
|
||||
|
||||
def test_get_build_sha_returns_none_for_empty_file(tmp_path):
|
||||
"""A whitespace-only file is treated as absent."""
|
||||
from hermes_cli import build_info
|
||||
|
||||
sha_file = tmp_path / ".hermes_build_sha"
|
||||
sha_file.write_text(" \n\n")
|
||||
|
||||
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file):
|
||||
assert build_info.get_build_sha() is None
|
||||
|
||||
|
||||
def test_get_build_sha_swallows_read_errors(tmp_path):
|
||||
"""Any IO exception from the read returns None — never raises."""
|
||||
from hermes_cli import build_info
|
||||
|
||||
sha_file = tmp_path / ".hermes_build_sha"
|
||||
sha_file.write_text("abcdef1234567890\n")
|
||||
|
||||
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file), \
|
||||
patch.object(Path, "read_text", side_effect=OSError("boom")):
|
||||
assert build_info.get_build_sha() is None
|
||||
|
|
@ -20,6 +20,7 @@ def test_check_for_updates_uses_cache(tmp_path, monkeypatch):
|
|||
"""When cache is fresh, check_for_updates should return cached value without calling git."""
|
||||
from hermes_cli.banner import check_for_updates
|
||||
from hermes_cli import __version__
|
||||
from pathlib import Path
|
||||
|
||||
# Create a fake git repo and fresh cache
|
||||
repo_dir = tmp_path / "hermes-agent"
|
||||
|
|
@ -30,6 +31,10 @@ def test_check_for_updates_uses_cache(tmp_path, monkeypatch):
|
|||
cache_file.write_text(json.dumps({"ts": time.time(), "behind": 3, "ver": __version__}))
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
# Ensure version_info doesn't find a stamp or git repo (so it doesn't
|
||||
# call subprocess before the cache check).
|
||||
monkeypatch.setattr("hermes_cli.version_info._resolve_stamp_file", lambda: None)
|
||||
monkeypatch.setattr("hermes_cli.version_info._resolve_repo_dir", lambda: None)
|
||||
with patch("hermes_cli.banner.subprocess.run") as mock_run:
|
||||
result = check_for_updates()
|
||||
|
||||
|
|
@ -45,6 +50,9 @@ def test_check_for_updates_invalidates_on_version_change(tmp_path, monkeypatch):
|
|||
'behind' count survived the upgrade. The version guard forces a recheck.
|
||||
"""
|
||||
import hermes_cli.banner as banner
|
||||
from hermes_cli.version_info import _reset_version_info_cache
|
||||
|
||||
_reset_version_info_cache()
|
||||
|
||||
# No local git checkout -> the PyPI path is exercised (pip-install class).
|
||||
fake_banner = tmp_path / "hermes_cli" / "banner.py"
|
||||
|
|
@ -59,7 +67,9 @@ def test_check_for_updates_invalidates_on_version_change(tmp_path, monkeypatch):
|
|||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("HERMES_REVISION", raising=False)
|
||||
# No stamp file and no git checkout -> version_info returns commit=None
|
||||
monkeypatch.setattr("hermes_cli.version_info._resolve_stamp_file", lambda: None)
|
||||
monkeypatch.setattr("hermes_cli.version_info._resolve_repo_dir", lambda: None)
|
||||
with patch("hermes_cli.banner.subprocess.run") as mock_run:
|
||||
result = banner.check_for_updates()
|
||||
|
||||
|
|
@ -96,6 +106,26 @@ def test_check_for_updates_expired_cache(tmp_path, monkeypatch):
|
|||
assert mock_run.call_count == 4
|
||||
|
||||
|
||||
def test_check_for_updates_uses_exact_local_count_for_live_git(tmp_path, monkeypatch):
|
||||
"""A source checkout keeps the full-clone exact-count update path."""
|
||||
import hermes_cli.banner as banner
|
||||
from hermes_cli.version_info import VersionInfo, _reset_version_info_cache
|
||||
|
||||
_reset_version_info_cache()
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
live_git_info = VersionInfo("0.19.0", "0.19.0+3", 3, "a" * 40, "main", "git")
|
||||
|
||||
with patch("hermes_cli.version_info.get_version_info", return_value=live_git_info), \
|
||||
patch.object(banner, "_check_via_local_git", return_value=7) as local_check, \
|
||||
patch.object(banner, "_check_via_rev") as immutable_check, \
|
||||
patch.object(banner, "_resolve_repo_dir", return_value=tmp_path):
|
||||
result = banner.check_for_updates()
|
||||
|
||||
assert result == 7
|
||||
local_check.assert_called_once_with(tmp_path)
|
||||
immutable_check.assert_not_called()
|
||||
|
||||
|
||||
def test_check_for_updates_official_ssh_origin_uses_https_probe(tmp_path):
|
||||
"""Passive update checks must not trigger SSH auth for official installs."""
|
||||
import hermes_cli.banner as banner
|
||||
|
|
@ -224,6 +254,7 @@ def test_check_via_local_git_full_clone_keeps_exact_count(tmp_path):
|
|||
def test_check_for_updates_no_git_dir(tmp_path, monkeypatch):
|
||||
"""Returns None when .git directory doesn't exist anywhere (no source tree)."""
|
||||
import hermes_cli.banner as banner
|
||||
from pathlib import Path
|
||||
|
||||
# Create a fake banner.py so the fallback path also has no .git
|
||||
fake_banner = tmp_path / "hermes_cli" / "banner.py"
|
||||
|
|
@ -232,6 +263,8 @@ def test_check_for_updates_no_git_dir(tmp_path, monkeypatch):
|
|||
|
||||
monkeypatch.setattr(banner, "__file__", str(fake_banner))
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setattr("hermes_cli.version_info._resolve_stamp_file", lambda: None)
|
||||
monkeypatch.setattr("hermes_cli.version_info._resolve_repo_dir", lambda: None)
|
||||
with patch("hermes_cli.banner.subprocess.run") as mock_run:
|
||||
result = banner.check_for_updates()
|
||||
assert result is None
|
||||
|
|
@ -258,15 +291,17 @@ def test_check_for_updates_fallback_to_project_root(tmp_path, monkeypatch):
|
|||
def test_check_for_updates_docker_returns_none(tmp_path, monkeypatch):
|
||||
"""Inside the Docker image, check_for_updates() must short-circuit to None.
|
||||
|
||||
Regression: the published image excludes .git (.dockerignore) and sets no
|
||||
HERMES_REVISION (nix-only), so without a docker guard check_for_updates()
|
||||
would fall through and try to probe a non-existent git checkout. The guard
|
||||
must return None (so the > 0 render guards stay false) AND not reach the
|
||||
git probe or write a cache entry.
|
||||
Regression: the published image excludes .git (.dockerignore), so without
|
||||
a docker guard check_for_updates() would fall through and try to probe a
|
||||
non-existent git checkout. The guard must return None (so the > 0 render
|
||||
guards stay false) AND not reach the git probe or write a cache entry.
|
||||
"""
|
||||
import hermes_cli.banner as banner
|
||||
from pathlib import Path
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setattr("hermes_cli.version_info._resolve_stamp_file", lambda: None)
|
||||
monkeypatch.setattr("hermes_cli.version_info._resolve_repo_dir", lambda: None)
|
||||
cache_file = tmp_path / ".update_check"
|
||||
|
||||
with patch("hermes_cli.config.detect_install_method", return_value="docker"), \
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from hermes_cli.version_info import (
|
||||
VersionInfo,
|
||||
_derived_version,
|
||||
_reset_version_info_cache,
|
||||
_stamp_version_info,
|
||||
format_display_version,
|
||||
get_version_info,
|
||||
)
|
||||
|
|
@ -25,32 +27,74 @@ def test_derived_version_shows_plus_question_for_dirty_unknown_distance():
|
|||
assert _derived_version("0.19.0", 0, dirty=True) == "0.19.0"
|
||||
|
||||
|
||||
def test_get_version_info_uses_nix_revision_metadata(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_REVISION", "a" * 40)
|
||||
monkeypatch.setenv("HERMES_REVISION_COUNT", "123")
|
||||
monkeypatch.setenv("HERMES_RELEASE_REV_COUNT", "120")
|
||||
monkeypatch.setenv("HERMES_REVISION_BRANCH", "feature/version")
|
||||
def test_stamp_version_info_reads_nix_stamp(tmp_path, monkeypatch):
|
||||
stamp = {
|
||||
"schemaVersion": 2,
|
||||
"commit": "a" * 40,
|
||||
"branch": "feature/version",
|
||||
"baseVersion": "0.19.0",
|
||||
"displayVersion": "0.19.0+3",
|
||||
"distance": 3,
|
||||
"dirty": False,
|
||||
"source": "nix",
|
||||
}
|
||||
stamp_file = tmp_path / ".hermes_build_info.json"
|
||||
stamp_file.write_text(json.dumps(stamp))
|
||||
monkeypatch.setattr("hermes_cli.version_info._resolve_stamp_file", lambda: stamp_file)
|
||||
|
||||
info = get_version_info()
|
||||
|
||||
assert info == VersionInfo("0.19.0", "0.19.0+3", 3, "a" * 40, "feature/version", "nix")
|
||||
|
||||
|
||||
def test_get_version_info_shows_plus_question_for_dirty_nix_without_counts(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_REVISION", "a" * 40)
|
||||
monkeypatch.setenv("HERMES_REVISION_DIRTY", "1")
|
||||
monkeypatch.delenv("HERMES_REVISION_COUNT", raising=False)
|
||||
monkeypatch.delenv("HERMES_RELEASE_REV_COUNT", raising=False)
|
||||
def test_stamp_version_info_reads_docker_stamp_with_unknown_distance(tmp_path, monkeypatch):
|
||||
stamp = {
|
||||
"schemaVersion": 2,
|
||||
"commit": "b" * 40,
|
||||
"branch": "unknown",
|
||||
"baseVersion": "0.19.0",
|
||||
"displayVersion": "0.19.0+?",
|
||||
"distance": None,
|
||||
"dirty": True,
|
||||
"source": "docker",
|
||||
}
|
||||
stamp_file = tmp_path / ".hermes_build_info.json"
|
||||
stamp_file.write_text(json.dumps(stamp))
|
||||
monkeypatch.setattr("hermes_cli.version_info._resolve_stamp_file", lambda: stamp_file)
|
||||
|
||||
info = get_version_info()
|
||||
|
||||
assert info == VersionInfo("0.19.0", "0.19.0+?", None, "a" * 40, None, "nix", True)
|
||||
assert info == VersionInfo("0.19.0", "0.19.0+?", None, "b" * 40, "unknown", "docker", True)
|
||||
|
||||
|
||||
def test_stamp_version_info_ignores_fallback_commit(tmp_path, monkeypatch):
|
||||
"""All-zero commit means the stamp couldn't resolve a real SHA — skip it."""
|
||||
stamp = {
|
||||
"schemaVersion": 2,
|
||||
"commit": "0" * 40,
|
||||
"branch": "main",
|
||||
"source": "fallback",
|
||||
}
|
||||
stamp_file = tmp_path / ".hermes_build_info.json"
|
||||
stamp_file.write_text(json.dumps(stamp))
|
||||
monkeypatch.setattr("hermes_cli.version_info._resolve_stamp_file", lambda: stamp_file)
|
||||
monkeypatch.setattr("hermes_cli.version_info._resolve_repo_dir", lambda: None)
|
||||
|
||||
info = get_version_info()
|
||||
|
||||
assert info.source == "unknown"
|
||||
assert info.commit is None
|
||||
|
||||
|
||||
def test_stamp_version_info_returns_none_when_file_missing(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("hermes_cli.version_info._resolve_stamp_file", lambda: None)
|
||||
assert _stamp_version_info() is None
|
||||
|
||||
|
||||
def test_get_version_info_counts_commits_after_semver_tag(tmp_path, monkeypatch):
|
||||
repo = tmp_path / "repo"
|
||||
(repo / ".git").mkdir(parents=True)
|
||||
monkeypatch.delenv("HERMES_REVISION", raising=False)
|
||||
monkeypatch.setattr("hermes_cli.version_info._resolve_stamp_file", lambda: None)
|
||||
monkeypatch.setattr("hermes_cli.version_info._resolve_repo_dir", lambda: repo)
|
||||
|
||||
def run(command, **_kwargs):
|
||||
|
|
@ -71,6 +115,7 @@ def test_get_version_info_counts_commits_after_semver_tag(tmp_path, monkeypatch)
|
|||
def test_get_version_info_falls_back_to_legacy_release_date_tag(tmp_path, monkeypatch):
|
||||
repo = tmp_path / "repo"
|
||||
(repo / ".git").mkdir(parents=True)
|
||||
monkeypatch.setattr("hermes_cli.version_info._resolve_stamp_file", lambda: None)
|
||||
monkeypatch.setattr("hermes_cli.version_info._resolve_repo_dir", lambda: repo)
|
||||
|
||||
calls = []
|
||||
|
|
@ -96,14 +141,16 @@ def test_get_version_info_falls_back_to_legacy_release_date_tag(tmp_path, monkey
|
|||
assert ("git", "rev-list", "--count", "v2026.7.20..HEAD") in calls
|
||||
|
||||
|
||||
def test_get_version_info_keeps_base_version_when_provenance_is_unavailable(monkeypatch):
|
||||
def test_get_version_info_unknown_when_no_stamp_and_no_git(monkeypatch):
|
||||
from pathlib import Path
|
||||
|
||||
monkeypatch.setattr("hermes_cli.version_info._resolve_stamp_file", lambda: None)
|
||||
monkeypatch.setattr("hermes_cli.version_info._resolve_repo_dir", lambda: None)
|
||||
monkeypatch.setattr("hermes_cli.build_info.get_build_sha", lambda short=0: "deadbeef" if short == 0 else "deadbeef")
|
||||
|
||||
info = get_version_info()
|
||||
|
||||
assert info.base_version == "0.19.0"
|
||||
assert info.derived_version == "0.19.0"
|
||||
assert info.distance is None
|
||||
assert info.commit == "deadbeef"
|
||||
assert info.source == "build"
|
||||
assert info.commit is None
|
||||
assert info.source == "unknown"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue