diff --git a/Dockerfile b/Dockerfile index de0bea6e7fe..d21a58a29c1 100644 --- a/Dockerfile +++ b/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 ` 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 ---------- diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 91e51e6fbf0..fcc8edf20d5 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 4c99816259e..f60384ade72 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -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) diff --git a/hermes_cli/build_info.py b/hermes_cli/build_info.py deleted file mode 100644 index e4cc6f09974..00000000000 --- a/hermes_cli/build_info.py +++ /dev/null @@ -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 ``/.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 ``/.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 diff --git a/hermes_cli/dump.py b/hermes_cli/dump.py index e8ed12963e6..1aa3a8572c2 100644 --- a/hermes_cli/dump.py +++ b/hermes_cli/dump.py @@ -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 ``/.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)" diff --git a/hermes_cli/version_info.py b/hermes_cli/version_info.py index 0fb96f4f6c7..afc8378fd5d 100644 --- a/hermes_cli/version_info.py +++ b/hermes_cli/version_info.py @@ -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 diff --git a/nix/hermes-agent.nix b/nix/hermes-agent.nix index 4e23bcc3fa8..8c2f8c580a6 100644 --- a/nix/hermes-agent.nix +++ b/nix/hermes-agent.nix @@ -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 < --branch --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()) diff --git a/tests/hermes_cli/test_build_info.py b/tests/hermes_cli/test_build_info.py deleted file mode 100644 index 994c13e1dcf..00000000000 --- a/tests/hermes_cli/test_build_info.py +++ /dev/null @@ -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 ``/.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 diff --git a/tests/hermes_cli/test_update_check.py b/tests/hermes_cli/test_update_check.py index 48a8da3045c..1e24d50d531 100644 --- a/tests/hermes_cli/test_update_check.py +++ b/tests/hermes_cli/test_update_check.py @@ -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"), \ diff --git a/tests/hermes_cli/test_version_info.py b/tests/hermes_cli/test_version_info.py index e09d9dc3ab6..d564a2ed7ff 100644 --- a/tests/hermes_cli/test_version_info.py +++ b/tests/hermes_cli/test_version_info.py @@ -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"