diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index a39c4150daa92..acfc3c6e3b3ba 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -98,6 +98,10 @@ jobs: # collide with the failed first attempt. A genuine persistent failure # still fails the job — only the first attempt has continue-on-error. # Refs: docker/setup-buildx-action#510 + + - name: Write install stamp + run: python3 scripts/write_install_stamp.py --output install-stamp.json --distribution docker + - name: Set up Docker Buildx id: buildx continue-on-error: true @@ -117,8 +121,6 @@ jobs: load: true platforms: ${{ matrix.platform }} tags: ${{ env.IMAGE_NAME }}:test - build-args: | - HERMES_GIT_SHA=${{ github.sha }} cache-from: ${{ matrix.cache-from }} cache-to: ${{ (github.event_name != 'pull_request') && matrix.cache-to || '' }} @@ -199,6 +201,9 @@ jobs: - name: Checkout trusted source uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Write install stamp + run: python3 scripts/write_install_stamp.py --output install-stamp.json --distribution docker + # Retry once on transient Docker Hub / buildkit pull failures. # See build job for rationale; same pattern. - name: Set up Docker Buildx @@ -227,8 +232,6 @@ jobs: platforms: ${{ matrix.platform }} labels: | org.opencontainers.image.revision=${{ github.sha }} - build-args: | - HERMES_GIT_SHA=${{ github.sha }} outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true cache-from: ${{ matrix.cache-from }} cache-to: ${{ matrix.cache-to }} diff --git a/Dockerfile b/Dockerfile index 2de6192715ed9..25ee14837efd9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,6 +40,22 @@ RUN apt-get -o Acquire::Retries=3 update && \ make -j"$(nproc)" && \ make install +# ---------- Install stamp stages ---------- +# CI pre-builds install-stamp.json (scripts/write_install_stamp.py) with full +# git provenance before `docker build`. The stamp is COPY'd into the image +# so version_info.py can read it at runtime — .dockerignore excludes .git, +# so no commit is resolvable inside the image. +# +# The stamp arrives via the bulk `COPY . .` below as /opt/hermes/install-stamp.json. +# A late RUN moves it to the canonical +# .hermes_build_info.json path so a stamp change does not invalidate the docker cache for +# the expensive build layers above — only the final metadata layer +# changes when the stamp changes. +# +# If the file is absent (local `docker build` without CI), the mv is +# a no-op and runtime falls through to "unknown" source with no crash. + +# ---------- Base image ---------- FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source # Node 26 source stage. Debian trixie's bundled nodejs is pinned to 20.x # which reached EOL in April 2026 — we copy node + npm from the upstream @@ -300,6 +316,14 @@ RUN mkdir -p /opt/hermes/bin && \ cp /opt/hermes/docker/hermes-exec-shim.sh /opt/hermes/bin/hermes && \ chmod 0755 /opt/hermes/bin/hermes && \ printf 'docker\n' > /opt/hermes/.install_method + +# Move the pre-built install stamp (if CI provided one) to the canonical +# path. Placed late so a stamp change does not invalidate the docker cache for +# the expensive build layers above — only this final layer re-runs. +# If install-stamp.json is absent (local build without CI), this is a no-op. +RUN if [ -f /opt/hermes/install-stamp.json ] && grep -q '"commit"' /opt/hermes/install-stamp.json; then \ + mv /opt/hermes/install-stamp.json /opt/hermes/.hermes_build_info.json; \ + fi # The ``.install_method`` stamp is baked next to the running code (the install # tree), NOT into $HERMES_HOME. $HERMES_HOME (/opt/data) is a shared data # volume that is commonly bind-mounted from the host and even shared with a @@ -311,28 +335,6 @@ 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 -# banner.get_git_banner_state() 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. -ARG HERMES_GIT_SHA= -RUN if [ -n "${HERMES_GIT_SHA}" ]; then \ - printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha; \ - fi - # ---------- s6-overlay service wiring ---------- # Static services declared at build time: main-hermes + dashboard. # Per-profile gateway services are registered dynamically at runtime by diff --git a/apps/desktop/scripts/write-build-stamp.mjs b/apps/desktop/scripts/write-build-stamp.mjs deleted file mode 100644 index 076d5a893e234..0000000000000 --- a/apps/desktop/scripts/write-build-stamp.mjs +++ /dev/null @@ -1,176 +0,0 @@ -/** - * Writes apps/desktop/build/install-stamp.json with the git ref the desktop - * .exe should pin to at first-launch bootstrap time. This file ships inside - * the packaged app via electron-builder's extraResources entry and is read - * by electron/main.ts to drive the install.ps1 stage bootstrap flow. - * - * Schema (subject to bump via STAMP_SCHEMA_VERSION): - * { - * "schemaVersion": 1, - * "commit": "<40-char SHA>", - * "branch": "", - * "builtAt": "", - * "dirty": true|false, - * "source": "ci" | "local" | "fallback" - * } - * - * Source preference order: - * 1. CI env vars ($GITHUB_SHA / $GITHUB_REF_NAME) -- avoid edge cases with - * shallow clones, detached HEADs, etc. in CI. - * 2. Local `git rev-parse` against the parent repo (../..). - * 3. Fallback stamp for local/personal builds from non-git source trees - * (ZIP extract, interrupted clone with no HEAD, etc.). - * - * Dev / out-of-repo builds without git produce an explicit fallback stamp - * rather than aborting the whole build. Bootstrap treats the all-zero - * commit as unpinned and follows the branch instead of fetching a fake SHA. - */ - -import { mkdirSync, writeFileSync } from "fs" -import { resolve, join, relative } from "path" -import { execSync } from "child_process" - -import { isMain } from "./utils.mjs" - -const STAMP_SCHEMA_VERSION = 1 - -/** All-zero placeholder used when no real commit can be resolved. */ -export const FALLBACK_COMMIT = "0000000000000000000000000000000000000000" -export const FALLBACK_BRANCH = "main" - -const DESKTOP_ROOT = resolve(import.meta.dirname, "..") -const REPO_ROOT = resolve(DESKTOP_ROOT, "..", "..") -const OUT_DIR = join(DESKTOP_ROOT, "build") -const OUT_FILE = join(OUT_DIR, "install-stamp.json") - -function tryExec(cmd, opts) { - try { - return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], ...opts }).trim() - } catch { - return null - } -} - -export function fromCI(env = process.env) { - const sha = env.GITHUB_SHA - if (!sha) return null - const branch = env.GITHUB_REF_NAME || env.GITHUB_HEAD_REF || null - return { - commit: sha, - branch: branch, - dirty: false, // CI builds from a checkout-of-ref by definition - source: "ci" - } -} - -export function fromLocalGit(repoRoot = REPO_ROOT, execFn = tryExec) { - const sha = execFn("git rev-parse HEAD", { cwd: repoRoot }) - if (!sha) return null - const branch = execFn("git rev-parse --abbrev-ref HEAD", { cwd: repoRoot }) - // `git status --porcelain -uno` is empty iff tracked files match HEAD. - // We exclude untracked files (-uno) intentionally: a developer who's - // checked out an installer scratch dir alongside the repo shouldn't - // poison every local build with a [DIRTY] stamp. We DO care about - // tracked-but-modified files because those mean the .exe content - // differs from the commit being pinned. - const status = execFn("git status --porcelain -uno", { cwd: repoRoot }) - const dirty = status !== null && status.length > 0 - return { - commit: sha, - branch: branch === "HEAD" ? null : branch, // detached HEAD -> null - dirty: dirty, - source: "local" - } -} - -export function fromFallback(branch = FALLBACK_BRANCH) { - // Non-git builds (ZIP download, bootstrap installer without a resolvable - // HEAD) cannot determine a real commit. Use a placeholder so local / - // personal builds can still complete. The desktop bootstrap treats the - // all-zero commit as "unknown" and falls back to an unpinned branch - // bootstrap instead of trying to fetch a non-existent GitHub commit. - return { - commit: FALLBACK_COMMIT, - branch: branch || FALLBACK_BRANCH, - dirty: false, - source: "fallback" - } -} - -/** - * Resolve the install stamp without writing it. Pure enough for unit tests: - * inject env / execFn / repoRoot to simulate CI, local git, or no-git trees. - */ -export function resolveStamp({ - env = process.env, - repoRoot = REPO_ROOT, - execFn = tryExec, - fallbackBranch = FALLBACK_BRANCH -} = {}) { - return fromCI(env) || fromLocalGit(repoRoot, execFn) || fromFallback(fallbackBranch) -} - -export function isFallbackCommit(commit) { - return typeof commit === "string" && /^0{7,40}$/.test(commit) -} - -function main() { - const stamp = resolveStamp() - if (!stamp || !stamp.commit) { - // Should not happen — fromFallback() always provides a commit. - console.error( - "[write-build-stamp] ERROR: could not determine git commit.\n" + - " - $GITHUB_SHA not set\n" + - " - `git rev-parse HEAD` failed at " + - REPO_ROOT + - "\n" + - "Packaged builds require a git ref to pin first-launch install.ps1\n" + - "against. Run from a git checkout or set $GITHUB_SHA explicitly." - ) - process.exit(1) - } - - if (isFallbackCommit(stamp.commit)) { - console.warn( - "[write-build-stamp] WARNING: no git commit found (non-git checkout?).\n" + - " Using placeholder commit — the packaged app will fall back to the\n" + - " default branch for first-launch bootstrap. For production builds,\n" + - " run from a git checkout or set $GITHUB_SHA." - ) - } - - if (stamp.dirty) { - console.warn( - "[write-build-stamp] WARNING: working tree is dirty.\n" + - " Pinning to " + - stamp.commit.slice(0, 12) + - " but the packaged code may differ from that commit.\n" + - " Commit your changes before publishing this build." - ) - } - - const payload = { - schemaVersion: STAMP_SCHEMA_VERSION, - commit: stamp.commit, - branch: stamp.branch, - builtAt: new Date().toISOString(), - dirty: stamp.dirty, - source: stamp.source - } - - mkdirSync(OUT_DIR, { recursive: true }) - writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8") - console.log( - "[write-build-stamp] wrote " + - relative(REPO_ROOT, OUT_FILE) + - " -> " + - stamp.commit.slice(0, 12) + - (stamp.branch ? " (" + stamp.branch + ")" : "") + - (stamp.dirty ? " [DIRTY]" : "") + - (stamp.source === "fallback" ? " [FALLBACK]" : "") - ) -} - -if (isMain(import.meta.url)) { - main() -} diff --git a/apps/desktop/scripts/write-build-stamp.test.mjs b/apps/desktop/scripts/write-build-stamp.test.mjs deleted file mode 100644 index 53c88e23704c3..0000000000000 --- a/apps/desktop/scripts/write-build-stamp.test.mjs +++ /dev/null @@ -1,86 +0,0 @@ -import assert from 'node:assert/strict' -import { test } from 'vitest' - -import { - FALLBACK_BRANCH, - FALLBACK_COMMIT, - fromCI, - fromFallback, - fromLocalGit, - isFallbackCommit, - resolveStamp -} from './write-build-stamp.mjs' - -test('fromCI reads GITHUB_SHA / GITHUB_REF_NAME', () => { - assert.deepEqual( - fromCI({ GITHUB_SHA: 'a'.repeat(40), GITHUB_REF_NAME: 'release' }), - { commit: 'a'.repeat(40), branch: 'release', dirty: false, source: 'ci' } - ) - assert.equal(fromCI({}), null) -}) - -test('fromLocalGit returns null when git rev-parse fails', () => { - const stamp = fromLocalGit('/tmp/not-a-repo', () => null) - assert.equal(stamp, null) -}) - -test('fromLocalGit reads HEAD + branch + dirty status', () => { - const calls = [] - const execFn = (cmd) => { - calls.push(cmd) - if (cmd === 'git rev-parse HEAD') return 'b'.repeat(40) - if (cmd === 'git rev-parse --abbrev-ref HEAD') return 'main' - if (cmd === 'git status --porcelain -uno') return ' M apps/desktop/package.json' - return null - } - assert.deepEqual(fromLocalGit('/repo', execFn), { - commit: 'b'.repeat(40), - branch: 'main', - dirty: true, - source: 'local' - }) - assert.ok(calls.includes('git rev-parse HEAD')) -}) - -test('fromFallback uses the all-zero placeholder commit', () => { - assert.deepEqual(fromFallback(), { - commit: FALLBACK_COMMIT, - branch: FALLBACK_BRANCH, - dirty: false, - source: 'fallback' - }) - assert.equal(isFallbackCommit(FALLBACK_COMMIT), true) - assert.equal(isFallbackCommit('a'.repeat(40)), false) -}) - -test('resolveStamp prefers CI over local git over fallback', () => { - const ci = resolveStamp({ - env: { GITHUB_SHA: 'c'.repeat(40), GITHUB_REF_NAME: 'main' }, - execFn: () => 'should-not-run' - }) - assert.equal(ci.source, 'ci') - assert.equal(ci.commit, 'c'.repeat(40)) - - const local = resolveStamp({ - env: {}, - execFn: (cmd) => { - if (cmd === 'git rev-parse HEAD') return 'd'.repeat(40) - if (cmd === 'git rev-parse --abbrev-ref HEAD') return 'main' - if (cmd === 'git status --porcelain -uno') return '' - return null - } - }) - assert.equal(local.source, 'local') - assert.equal(local.commit, 'd'.repeat(40)) - assert.equal(local.dirty, false) -}) - -test('resolveStamp falls back when neither CI nor git is available', () => { - const stamp = resolveStamp({ env: {}, execFn: () => null }) - assert.deepEqual(stamp, { - commit: FALLBACK_COMMIT, - branch: FALLBACK_BRANCH, - dirty: false, - source: 'fallback' - }) -}) diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 26ccc6db6e7f1..024f30f6c3815 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -1155,7 +1155,6 @@ export interface StatusResponse { gateway_updated_at: string | null hermes_home: string latest_config_version: number - release_date: string version: string } diff --git a/cli.py b/cli.py index 8da0a2fed2058..9a64406a55b48 100644 --- a/cli.py +++ b/cli.py @@ -4020,10 +4020,9 @@ def _build_compact_banner() -> str: tiny_line = agent_name if os.environ.get("HERMES_FAST_STARTUP_BANNER") == "1": - from hermes_cli import __release_date__ as _release_date from hermes_cli import __version__ as _version - version_line = f"Hermes Agent v{_version} ({_release_date})" + version_line = f"Hermes Agent v{_version}" else: version_line = format_banner_version_label() diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 3f4f893be7561..53036fae67783 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -65,7 +65,8 @@ def _skin_color(key: str, fallback: str) -> str: # ASCII Art & Branding # ========================================================================= -from hermes_cli import __version__ as VERSION, __release_date__ as RELEASE_DATE +from hermes_cli import __version__ as VERSION +from hermes_cli.version_info import get_version_info HERMES_AGENT_LOGO = """[bold #FFD700]██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/] [bold #FFD700]██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/] @@ -276,9 +277,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 @@ -286,15 +287,28 @@ 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 uses the remote SHA + # comparison. Source installs resolve as ``git`` too, but they keep 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 as exc: + logger.debug("version_info unavailable for update check: %s", exc) + 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": @@ -323,13 +337,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) @@ -348,93 +357,12 @@ def check_for_updates() -> Optional[int]: def _resolve_repo_dir() -> Optional[Path]: """Return the active Hermes git checkout, or None if this isn't a git install. - Prefers the running code's location over the profile-scoped path - because ``$HERMES_HOME/hermes-agent/`` may be a stale copy carried - over by ``--clone-all``. + Delegates to ``version_info._resolve_repo_dir`` — one checkout-resolution + policy for the whole package. """ - repo_dir = Path(__file__).parent.parent.resolve() - if not (repo_dir / ".git").exists(): - hermes_home = get_hermes_home() - repo_dir = hermes_home / "hermes-agent" - return repo_dir if (repo_dir / ".git").exists() else None + from hermes_cli.version_info import _resolve_repo_dir as _resolve - -def _git_short_hash(repo_dir: Path, rev: str) -> Optional[str]: - """Resolve a git revision to an 8-character short hash.""" - try: - result = subprocess.run( - ["git", "rev-parse", "--short=8", rev], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=5, - cwd=str(repo_dir), - ) - except Exception: - return None - if result.returncode != 0: - return None - value = (result.stdout or "").strip() - return value or None - - -def get_git_banner_state(repo_dir: Optional[Path] = None) -> Optional[dict]: - """Return upstream/local git hashes for the startup banner. - - For source installs and dev images this runs ``git rev-parse`` against - the active checkout. When no checkout is available — the canonical case - is the published Docker image, which excludes ``.git`` from the build - context — we fall back to the baked-in build SHA (see - ``hermes_cli/build_info.py``) and return it as a frozen - ``upstream == local`` state with ``ahead=0``. A built image is by - definition pinned to one commit, so "ahead" is always zero and the - banner correctly shows ``· upstream `` with no carried-commits - annotation. - """ - repo_dir = repo_dir or _resolve_repo_dir() - if repo_dir is None: - # No git checkout — try the baked build SHA (Docker image path). - try: - from hermes_cli.build_info import get_build_sha - baked = get_build_sha(short=8) - if baked: - return {"upstream": baked, "local": baked, "ahead": 0} - except Exception: - pass - return None - - upstream = _git_short_hash(repo_dir, "origin/main") - local = _git_short_hash(repo_dir, "HEAD") - if not upstream or not local: - # Live-git lookup failed (e.g. shallow clone without origin/main). - # Fall back to the baked build SHA if available. - try: - from hermes_cli.build_info import get_build_sha - baked = get_build_sha(short=8) - if baked: - return {"upstream": baked, "local": baked, "ahead": 0} - except Exception: - pass - return None - - ahead = 0 - try: - result = subprocess.run( - ["git", "rev-list", "--count", "origin/main..HEAD"], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=5, - cwd=str(repo_dir), - ) - if result.returncode == 0: - ahead = int((result.stdout or "0").strip() or "0") - except Exception: - ahead = 0 - - return {"upstream": upstream, "local": local, "ahead": max(ahead, 0)} + return _resolve() _RELEASE_URL_BASE = "https://github.com/NousResearch/hermes-agent/releases/tag" @@ -487,20 +415,15 @@ def get_latest_release_tag(repo_dir: Optional[Path] = None) -> Optional[tuple]: def format_banner_version_label() -> str: """Return the version label shown in the startup banner title.""" - base = f"Hermes Agent v{VERSION} ({RELEASE_DATE})" - state = get_git_banner_state() - if not state: - return base - - upstream = state["upstream"] - local = state["local"] - ahead = int(state.get("ahead") or 0) - - if ahead <= 0 or upstream == local: - return f"{base} · upstream {upstream}" - - carried_word = "commit" if ahead == 1 else "commits" - return f"{base} · upstream {upstream} · local {local} (+{ahead} carried {carried_word})" + info = get_version_info() + parts = [f"Hermes Agent v{info.derived_version}"] + if info.branch: + parts.append(info.branch) + if info.commit: + parts.append(info.commit[:12]) + if info.dirty: + parts.append("dirty") + return " · ".join(parts) # ========================================================================= diff --git a/hermes_cli/build_info.py b/hermes_cli/build_info.py deleted file mode 100644 index e4cc6f09974dd..0000000000000 --- 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 72e857a3b57cb..b9c43296a74eb 100644 --- a/hermes_cli/dump.py +++ b/hermes_cli/dump.py @@ -54,61 +54,38 @@ 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)" def _get_git_commit_date(project_root: Path) -> str: """Return the date the HEAD commit was authored (YYYY-MM-DD), or ''. - Resolves live via ``git log`` on source installs. The published Docker - image excludes ``.git``, so this returns '' there — the dump line simply - drops the date suffix in that case (the baked SHA still identifies the - build). + Uses ``version_info.get_version_info()`` which carries the commit date + as a Unix timestamp from the install stamp (Docker/Nix) or live git + (source installs). Formats as YYYY-MM-DD for display. """ try: - result = subprocess.run( - ["git", "log", "-1", "--format=%cd", "--date=short", "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_date: + from datetime import datetime, timezone + + return datetime.fromtimestamp(info.commit_date, tz=timezone.utc).strftime("%Y-%m-%d") except Exception: pass - return "" diff --git a/hermes_cli/main.py b/hermes_cli/main.py index ba6dc07206d62..79a63992eb0a8 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4965,14 +4965,22 @@ def cmd_import(args): def _print_version_info(*, check_updates: bool = True) -> None: - from hermes_cli.config import detect_install_method from hermes_cli.slash_exec import CommandContext, execute_command + from hermes_cli.version_info import get_version_info # Core version line is registry-owned (shared with the gateway /version); # the install/python/SDK detail below is CLI-only decoration. print(execute_command("version", CommandContext(surface="cli")).text) + version_info = get_version_info() + if version_info.branch: + print(f"Branch: {version_info.branch}") + if version_info.commit: + print(f"Commit: {version_info.commit}") + print(f"Working tree: {'dirty' if version_info.dirty else 'clean'}") + print(f"Source: {version_info.source}") + if version_info.distribution: + print(f"Distribution: {version_info.distribution}") print(f"Install directory: {PROJECT_ROOT}") - print(f"Install method: {detect_install_method(PROJECT_ROOT)}") # Show Python version print(f"Python: {sys.version.split()[0]}") diff --git a/hermes_cli/version_info.py b/hermes_cli/version_info.py new file mode 100644 index 0000000000000..21d747b4caf0e --- /dev/null +++ b/hermes_cli/version_info.py @@ -0,0 +1,225 @@ +"""Truthful derived build-version metadata for user-facing Hermes displays. + +``__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 every packager (Docker, Nix, and + 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. The provenance is unknown. +""" + +from __future__ import annotations + +import json +import os +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, cast + +from hermes_cli import __release_date__, __version__ + + +@dataclass(frozen=True) +class VersionInfo: + base_version: str + derived_version: str + distance: int | None + commit: str | None + branch: str | None + source: Literal["build", "ci", "docker", "fallback", "git", "local", "nix", "unknown"] + dirty: bool = False + commit_date: int | None = None + distribution: Literal["docker", "nix"] | None = None + + +def _derived_version(base_version: str, distance: int | None, dirty: bool = False) -> str: + if distance and distance > 0: + return f"{base_version}+{distance}" + if dirty and distance is None: + return f"{base_version}+?" + return base_version + + +def _run_git(repo_dir: Path, *args: str) -> str | None: + try: + result = subprocess.run( + ["git", *args], capture_output=True, text=True, timeout=3, cwd=str(repo_dir) + ) + except (OSError, subprocess.SubprocessError): + return None + value = (result.stdout or "").strip() + return value if result.returncode == 0 and value else None + + +def _resolve_repo_dir() -> Path | None: + """Use the executing checkout before a profile's optional clone.""" + repo_dir = Path(__file__).parent.parent.resolve() + if (repo_dir / ".git").exists(): + return repo_dir + try: + from hermes_constants import get_hermes_home + + candidate = get_hermes_home() / "hermes-agent" + if (candidate / ".git").exists(): + return candidate + except Exception: + pass + return None + + +def _parse_nonnegative(value: str | None) -> int | None: + try: + parsed = int(value or "") + except ValueError: + return None + return parsed if parsed >= 0 else None + + +# --- 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 + 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) + + # ``source`` describes build provenance, while ``distribution`` identifies + # the package form users installed. Keep both facts intact for support. + stamp_source = str(data.get("source") or "") + source = ( + cast(Literal["build", "ci", "docker", "fallback", "git", "local", "nix", "unknown"], stamp_source) + if stamp_source in {"ci", "docker", "fallback", "local", "nix"} + else "build" + ) + distribution = data.get("distribution") + if distribution not in {"docker", "nix"}: + distribution = None + + commit_date = data.get("commitDate") + if not isinstance(commit_date, int): + commit_date = None + + return VersionInfo( + base_version, + display_version, + distance if isinstance(distance, int) else None, + commit, + data.get("branch") or None, + source, + bool(data.get("dirty")), + commit_date, + distribution, + ) + + +# --- Git provenance (source/dev installs) ----------------------------------- + + +def _git_version_info(repo_dir: Path) -> VersionInfo: + commit = _run_git(repo_dir, "rev-parse", "HEAD") + # A detached HEAD has no branch. Leave the field None: every formatter + # already prints the commit separately and handles a missing branch. + branch = _run_git(repo_dir, "branch", "--show-current") + commit_date_raw = _run_git(repo_dir, "log", "-1", "--format=%ct", "HEAD") + commit_date: int | None = None + if commit_date_raw and commit_date_raw.isdigit(): + commit_date = int(commit_date_raw) + try: + # -uno: skip the untracked-file scan. This runs on the startup-banner + # path, and a full working-tree walk costs real time on large or cold + # checkouts. Same semantics as write_install_stamp.py. + dirty_result = subprocess.run( + ["git", "status", "--porcelain", "-uno"], + capture_output=True, + text=True, + timeout=3, + cwd=str(repo_dir), + ) + dirty = dirty_result.returncode == 0 and bool((dirty_result.stdout or "").strip()) + except (OSError, subprocess.SubprocessError): + dirty = False + + # New releases are SemVer tags. The release-date fallback lets existing + # CalVer-tagged releases display a correct distance during the transition. + distance = None + for tag in (f"v{__version__}", f"v{__release_date__}"): + raw_distance = _run_git(repo_dir, "rev-list", "--count", f"{tag}..HEAD") + parsed_distance = _parse_nonnegative(raw_distance) + if parsed_distance is not None: + distance = parsed_distance + break + + return VersionInfo( + __version__, _derived_version(__version__, distance, dirty), distance, commit, branch, "git", dirty, commit_date + ) + + +# --- Cache + public API ----------------------------------------------------- + +_cached_version_info: VersionInfo | None = None + + +def _reset_version_info_cache() -> None: + """Test-only cache reset.""" + global _cached_version_info + _cached_version_info = None + + +def get_version_info() -> VersionInfo: + """Return cached provenance from install stamp, git, or unknown.""" + global _cached_version_info + if _cached_version_info is not None: + return _cached_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) + + # 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/hermes_cli/web_server.py b/hermes_cli/web_server.py index 197c7c193bce1..0806e4c3900f9 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -56,7 +56,7 @@ PROJECT_ROOT = Path(__file__).parent.parent.resolve() if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) -from hermes_cli import __version__, __release_date__ +from hermes_cli import __version__ from hermes_cli.config import ( cfg_get, DEFAULT_CONFIG, @@ -3243,7 +3243,6 @@ async def get_status(profile: Optional[str] = None): # ``PUBLIC_API_PATHS`` documents this endpoint as serving. status = { "version": __version__, - "release_date": __release_date__, "config_version": current_ver, "latest_config_version": latest_ver, "can_update_hermes": not _dashboard_local_update_managed_externally(), diff --git a/nix/desktop.nix b/nix/desktop.nix index fa76993d27c00..69a416d7dd4b9 100644 --- a/nix/desktop.nix +++ b/nix/desktop.nix @@ -15,9 +15,22 @@ electron, hermesAgent, python3, + rev ? null, + branch ? null, + dirty ? false, + distance ? null, + displayVersion ? null, ... }: let + # The Electron manifest identifies the UI project, but Hermes's version is + # owned by the root Python package. Keep the Nix derivation and the manifest + # shipped to Electron aligned with that one canonical value. + # hermes-agent.nix computes distance and displayVersion once and passes + # them in — do not re-derive them here. + version = (fromTOML (builtins.readFile ../pyproject.toml)).project.version; + stampDisplayVersion = if displayVersion != null then displayVersion else version; + electronHeaders = pkgs.fetchurl { url = "https://artifacts.electronjs.org/headers/dist/v${electron.version}/node-v${electron.version}-headers.tar.gz"; sha256 = "sha256-f8bSbLRmtbP93CJAvEBs+sHWDZ1xP2bcpLhC1EnOmZU="; @@ -57,6 +70,17 @@ let mkdir -p apps/desktop/build + # Electron reads app.getVersion() from this manifest. The source + # manifest deliberately does not own the Hermes release version, so + # stamp the canonical package version into the Nix-built copy. + node -e ' + const fs = require("fs") + const file = "apps/desktop/package.json" + const pkg = JSON.parse(fs.readFileSync(file, "utf8")) + pkg.version = process.argv[1] + fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n") + ' '${version}' + patchShebangs . pushd apps/desktop @@ -126,7 +150,11 @@ let # before the cd. cp -rn apps/desktop/dist $out/ - echo '{"schemaVersion":1,"commit":"nix-dummy-commit","branch":"nix","dirty":false,"source":"nix"}' > $out/install-stamp.json + cat > $out/install-stamp.json <<'EOF' + {"schemaVersion":2,"commit":${builtins.toJSON rev},"branch":${builtins.toJSON branch},"baseVersion":"${version}","displayVersion":"${stampDisplayVersion}","distance":${builtins.toJSON distance},"dirty":${ + if dirty then "true" else "false" + },"source":"nix","distribution":"nix"} + EOF cp -n apps/desktop/package.json $out/ runHook postInstall diff --git a/nix/hermes-agent.nix b/nix/hermes-agent.nix index 2a18df12862a2..a28bfd4d114bc 100644 --- a/nix/hermes-agent.nix +++ b/nix/hermes-agent.nix @@ -33,11 +33,28 @@ # check for updates without needing a local .git directory. Null for # impure / dirty builds where flakes can't determine a rev. rev ? null, + revCount ? null, + branch ? null, + dirty ? false, + lastModified ? null, # Overridable parameters extraPythonPackages ? [ ], 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; mkHermesVenv = extraDependencyGroups: callPackage ./python.nix { @@ -160,7 +177,7 @@ let in stdenv.mkDerivation (finalAttrs: { pname = "hermes-agent"; - version = (fromTOML (builtins.readFile ../pyproject.toml)).project.version; + inherit version; dontUnpack = true; dontBuild = true; @@ -181,6 +198,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 --distribution nix + + # Docker (no .git, commit known from CI): + python scripts/write_install_stamp.py --output install-stamp.json \\ + --source ci --distribution 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_commit_date_from_git() -> int | None: + """Return the commit timestamp (Unix epoch seconds) of HEAD, or None.""" + raw = _run_git("log", "-1", "--format=%ct", "HEAD") + if raw and raw.isdigit(): + return int(raw) + return 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, + commit_date: int | None = None, + source: str = "local", + distribution: str | None = None, +) -> dict: + """Build a stamp dict from explicit args, filling gaps from git/env. + + Args override detection — an explicit ``commit`` is 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() + + # 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) + + # Commit date: explicit > git + if commit_date is None: + commit_date = _resolve_commit_date_from_git() + + # 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}+?" + + # Bundled desktop payloads. The desktop-bundled-release workflow sets + # HERMES_DESKTOP_BUNDLED=1 and pins the release tag. A bundled build + # without a tag is a hard error: electron-updater and the offline + # re-materialization both key on the tag, so a tagless bundled artifact + # cannot update itself. + payload = os.environ.get("HERMES_DESKTOP_BUNDLED") == "1" + tag = os.environ.get("HERMES_PAYLOAD_TAG") or None + if payload and not (tag and re.match(r"^v(0|[1-9]\d{0,2})\.\d+\.\d+$", tag)): + raise SystemExit( + "write_install_stamp: HERMES_DESKTOP_BUNDLED=1 requires " + f"HERMES_PAYLOAD_TAG=vX.Y.Z (got {tag!r})" + ) + + return { + "schemaVersion": STAMP_SCHEMA_VERSION, + "commit": commit, + "commitDate": commit_date, + "branch": branch, + "builtAt": datetime.now(timezone.utc).isoformat(), + "dirty": dirty, + "source": source, + "distribution": distribution, + "baseVersion": base_version, + "displayVersion": display_version, + "distance": distance, + "payload": payload, + "tag": tag if payload else None, + } + + +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("--commit-date", type=int, default=None, help="Override commit timestamp (Unix epoch seconds)") + parser.add_argument("--source", default="local", help="Stamp source label") + parser.add_argument("--distribution", choices=("docker", "nix"), help="Package distribution") + 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, + commit_date=args.commit_date, + source=args.source, + distribution=args.distribution, + ) + + 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/docker/test_dump_build_sha.py b/tests/docker/test_dump_build_sha.py index 20e6da19c066b..ba55ee4406037 100644 --- a/tests/docker/test_dump_build_sha.py +++ b/tests/docker/test_dump_build_sha.py @@ -1,27 +1,25 @@ """Regression test: ``hermes dump`` reports a real git SHA inside the container. -Background: ``.dockerignore`` excludes ``.git``, so ``git rev-parse HEAD`` -fails inside the published image and ``hermes dump`` used to report -``version: ... [(unknown)]``. The Dockerfile now writes the build-time -``$HERMES_GIT_SHA`` build-arg to ``/opt/hermes/.hermes_build_sha`` and -``hermes_cli/build_info.py`` reads it as a fallback. +``.dockerignore`` excludes ``.git``, so ``git rev-parse HEAD`` fails inside +the published image. CI writes ``install-stamp.json`` before ``docker build`` +(scripts/write_install_stamp.py), and a late Dockerfile layer moves it to the +canonical ``/opt/hermes/.hermes_build_info.json``. ``hermes dump`` reads the +commit from that stamp through ``hermes_cli.version_info``. -CI (``.github/workflows/docker.yml``) always sets the build-arg -to ``${{ github.sha }}``. Local ``docker build`` (the ``built_image`` -fixture in ``tests/docker/conftest.py``) does NOT — so locally the file -is absent and ``hermes dump`` correctly falls back to ``(unknown)``. +A local ``docker build`` (the ``built_image`` fixture in +``tests/docker/conftest.py``) has no stamp. In that case ``hermes dump`` +falls back to ``(unknown)``. -This test handles both cases: +This test asserts both cases: -* If ``/opt/hermes/.hermes_build_sha`` exists in the image, assert that - ``hermes dump`` surfaces its content as the version SHA (not - ``(unknown)``). -* If the file is absent, assert the legacy behaviour (``(unknown)``) - still holds — defensive guard against the helper accidentally - reporting bogus data from somewhere else. +* When the stamp exists in the image, ``hermes dump`` must show the first 8 + characters of its commit, not ``(unknown)``. +* When the stamp is absent, ``hermes dump`` must show ``(unknown)`` — a guard + against the helper inventing a SHA from another source. """ from __future__ import annotations +import json import re import subprocess @@ -50,28 +48,36 @@ def _run_dump(image: str) -> str: return r.stdout -def _read_baked_sha_from_image(image: str) -> str | None: - """Return the ``/opt/hermes/.hermes_build_sha`` content, or None if absent.""" +def _read_stamp_commit_from_image(image: str) -> str | None: + """Return the stamp commit from the image, or None when absent/unusable.""" r = subprocess.run( [ "docker", "run", "--rm", "--entrypoint", "cat", image, - "/opt/hermes/.hermes_build_sha", + "/opt/hermes/.hermes_build_info.json", ], capture_output=True, text=True, timeout=30, ) if r.returncode != 0: return None - return r.stdout.strip() or None + try: + commit = json.loads(r.stdout).get("commit") or "" + except ValueError: + return None + # An all-zero commit is the writer's fallback placeholder, and + # version_info skips it the same way. + if not commit or set(commit) == {"0"}: + return None + return commit -def test_dump_reports_baked_sha_when_present(built_image: str) -> None: - """When the image was built with ``HERMES_GIT_SHA``, dump must surface it. +def test_dump_reports_stamp_commit_when_present(built_image: str) -> None: + """When the image carries an install stamp, dump must surface its commit. Together with the smoke-test action (which exercises ``--help``), this closes the regression loop for the missing-sha bug: any future change - that breaks the baked-file -> dump pipeline will fail CI here. + that breaks the stamp -> dump pipeline will fail CI here. """ - baked = _read_baked_sha_from_image(built_image) + stamped = _read_stamp_commit_from_image(built_image) stdout = _run_dump(built_image) match = _VERSION_LINE.search(stdout) @@ -82,23 +88,20 @@ def test_dump_reports_baked_sha_when_present(built_image: str) -> None: ) reported = sha_match.group("sha") - if baked is None: - # Local-build path: no build-arg was passed. Verify the legacy - # fallback ``(unknown)`` is intact — guards against the helper - # ever inventing a SHA from thin air. + if stamped is None: + # Local-build path: no stamp in the image. The fallback must stay + # '(unknown)' — a guard against the helper inventing a SHA. assert reported == "(unknown)", ( - f"expected '(unknown)' when no SHA baked, got {reported!r}" + f"expected '(unknown)' when no stamp is baked, got {reported!r}" ) return - # CI path: build-arg was set, baked file exists. ``hermes dump`` - # truncates to 8 chars via ``git rev-parse --short=8`` semantics. + # CI path: the stamp exists. ``hermes dump`` shows the first 8 chars. assert reported != "(unknown)", ( - "baked SHA file present in image but dump still reported " - f"'(unknown)' — the build-info fallback is broken. " - f"Baked file content: {baked!r}" + "install stamp present in image but dump still reported " + f"'(unknown)' — the stamp fallback is broken. Stamp commit: {stamped!r}" ) - assert reported == baked[:8], ( - f"dump reported {reported!r} but baked file contained {baked!r} " - f"(expected first 8 chars: {baked[:8]!r})" + assert reported == stamped[:8], ( + f"dump reported {reported!r} but the stamp commit is {stamped!r} " + f"(expected first 8 chars: {stamped[:8]!r})" ) diff --git a/tests/hermes_cli/test_banner_git_state.py b/tests/hermes_cli/test_banner_git_state.py index 236e6d6891c67..19a0c5373b104 100644 --- a/tests/hermes_cli/test_banner_git_state.py +++ b/tests/hermes_cli/test_banner_git_state.py @@ -1,43 +1,46 @@ from unittest.mock import MagicMock, patch +from hermes_cli.version_info import VersionInfo - -def test_format_banner_version_label_on_upstream_main(): +def test_format_banner_version_label_without_git_state(): from hermes_cli import banner with patch.object( banner, - "get_git_banner_state", - return_value={"upstream": "b2f477a3", "local": "b2f477a3", "ahead": 0}, + "get_version_info", + return_value=VersionInfo(banner.VERSION, banner.VERSION, None, None, None, "unknown"), ): value = banner.format_banner_version_label() - assert value.endswith("· upstream b2f477a3") - assert "local" not in value + assert value == f"Hermes Agent v{banner.VERSION}" -def test_get_git_banner_state_reads_origin_and_head(tmp_path): +def test_format_banner_version_label_includes_derived_version_and_provenance(): from hermes_cli import banner - repo_dir = tmp_path / "repo" - (repo_dir / ".git").mkdir(parents=True) + with patch.object( + banner, + "get_version_info", + return_value=VersionInfo("0.19.0", "0.19.0+3", 3, "b" * 40, "feature/version", "git"), + ): + value = banner.format_banner_version_label() - results = { - ("git", "rev-parse", "--short=8", "origin/main"): MagicMock(returncode=0, stdout="b2f477a3\n"), - ("git", "rev-parse", "--short=8", "HEAD"): MagicMock(returncode=0, stdout="af8aad31\n"), - ("git", "rev-list", "--count", "origin/main..HEAD"): MagicMock(returncode=0, stdout="3\n"), - } - - def fake_run(cmd, **kwargs): - key = tuple(cmd) - if key not in results: - raise AssertionError(f"unexpected command: {cmd}") - return results[key] - - with patch("hermes_cli.banner.subprocess.run", side_effect=fake_run): - state = banner.get_git_banner_state(repo_dir) - - assert state == {"upstream": "b2f477a3", "local": "af8aad31", "ahead": 3} + assert "v0.19.0+3" in value + assert "feature/version" in value + assert "b" * 12 in value +def test_format_banner_version_label_omits_zero_suffix(): + from hermes_cli import banner + + with patch.object( + banner, + "get_version_info", + return_value=VersionInfo("0.19.0", "0.19.0", 0, "a" * 40, "main", "git"), + ): + value = banner.format_banner_version_label() + + assert "v0.19.0" in value + assert "+0" not in value + assert "carried" not in value diff --git a/tests/hermes_cli/test_build_info.py b/tests/hermes_cli/test_build_info.py deleted file mode 100644 index e3bc7e3ecf812..0000000000000 --- a/tests/hermes_cli/test_build_info.py +++ /dev/null @@ -1,35 +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_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 - - diff --git a/tests/hermes_cli/test_dump_git_commit.py b/tests/hermes_cli/test_dump_git_commit.py index 47c9cd9cb9da9..76dcd6762bb7b 100644 --- a/tests/hermes_cli/test_dump_git_commit.py +++ b/tests/hermes_cli/test_dump_git_commit.py @@ -1,75 +1,123 @@ """Tests for hermes_cli.dump._get_git_commit — git SHA resolution for ``hermes dump``. ``hermes dump`` prints the running commit so support bug reports identify the -exact version. Source installs resolve it live via ``git rev-parse``; the -published Docker image excludes ``.git`` and falls back to the baked SHA -written by the Dockerfile's ``HERMES_GIT_SHA`` build-arg. +exact version. Source installs resolve it live via git; packaged builds +(Docker, Nix) use the install stamp. Both paths go through +``version_info.get_version_info()``. -These tests cover both paths plus the failure modes (no git, no baked file). +These tests cover both paths plus the failure modes (no stamp, no git). """ from unittest.mock import MagicMock, patch +from hermes_cli.version_info import VersionInfo, _reset_version_info_cache + + +def setup_function(): + _reset_version_info_cache() + def test_get_git_commit_uses_live_git_when_available(tmp_path): - """Source install: ``git rev-parse --short=8 HEAD`` wins; no fallback.""" + """Source install: version_info resolves commit from live git.""" from hermes_cli import dump repo_dir = tmp_path / "repo" repo_dir.mkdir() - git_result = MagicMock(returncode=0, stdout="deadbeef\n") - # build_info should NOT be consulted when live git succeeds. - with patch("hermes_cli.dump.subprocess.run", return_value=git_result) as mock_run, \ - patch("hermes_cli.build_info.get_build_sha") as mock_build: + with patch("hermes_cli.version_info._resolve_stamp_file", lambda: None), \ + patch("hermes_cli.version_info._resolve_repo_dir", lambda: repo_dir), \ + patch("hermes_cli.version_info._git_version_info", + return_value=VersionInfo("0.19.0", "0.19.0+3", 3, "deadbeef" * 5, "main", "git")): commit = dump._get_git_commit(repo_dir) assert commit == "deadbeef" - mock_run.assert_called_once() - mock_build.assert_not_called() + + +def test_get_git_commit_uses_stamp_when_no_git(tmp_path): + """Docker/Nix: version_info resolves commit from the install stamp.""" + from hermes_cli import dump + + repo_dir = tmp_path / "no-git-here" + repo_dir.mkdir() + + with patch("hermes_cli.version_info._resolve_stamp_file", lambda: tmp_path / "stamp.json"), \ + patch("hermes_cli.version_info._resolve_repo_dir", lambda: None), \ + patch("hermes_cli.version_info._stamp_version_info", + return_value=VersionInfo("0.19.0", "0.19.0", None, "cafef00d" * 5, None, "docker")): + commit = dump._get_git_commit(repo_dir) + + assert commit == "cafef00d" + + +def test_get_git_commit_returns_unknown_when_neither_source_available(tmp_path): + """Pip-installed wheel: no stamp, no git → '(unknown)'.""" + from hermes_cli import dump + + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + with patch("hermes_cli.version_info._resolve_stamp_file", lambda: None), \ + patch("hermes_cli.version_info._resolve_repo_dir", lambda: None): + commit = dump._get_git_commit(repo_dir) + + assert commit == "(unknown)" def test_get_git_commit_output_format_identical_between_sources(tmp_path): - """Regression guard: live-git and baked-SHA outputs share the same shape. - - Ben explicitly asked for identical output between Docker and source installs - so support tooling that parses ``hermes dump`` doesn't have to special-case - container builds. Both paths must return a bare 8-char SHA — no prefix, - no suffix, no annotation. - """ + """Regression guard: live-git and stamp outputs share the same shape.""" from hermes_cli import dump repo_dir = tmp_path / "repo" repo_dir.mkdir() # Live-git path. - git_result = MagicMock(returncode=0, stdout="b2f477a3\n") - with patch("hermes_cli.dump.subprocess.run", return_value=git_result): + with patch("hermes_cli.version_info._resolve_stamp_file", lambda: None), \ + patch("hermes_cli.version_info._resolve_repo_dir", lambda: repo_dir), \ + patch("hermes_cli.version_info._git_version_info", + return_value=VersionInfo("0.19.0", "0.19.0+3", 3, "b2f477a3" * 5, "main", "git")): + _reset_version_info_cache() live = dump._get_git_commit(repo_dir) - # Baked-SHA path. - failed = MagicMock(returncode=128, stdout="") - with patch("hermes_cli.dump.subprocess.run", return_value=failed), \ - patch("hermes_cli.build_info.get_build_sha", return_value="b2f477a3"): + # Stamp path. + with patch("hermes_cli.version_info._resolve_stamp_file", lambda: tmp_path / "stamp.json"), \ + patch("hermes_cli.version_info._resolve_repo_dir", lambda: None), \ + patch("hermes_cli.version_info._stamp_version_info", + return_value=VersionInfo("0.19.0", "0.19.0", None, "b2f477a3" * 5, None, "docker")): + _reset_version_info_cache() baked = dump._get_git_commit(repo_dir) assert live == baked == "b2f477a3" - # Same length, same charset — no decoration in either branch. assert len(live) == 8 assert all(c in "0123456789abcdef" for c in live) -def test_get_git_commit_date_empty_when_git_fails(tmp_path): - """Docker image / pip wheel: no git → '' so the dump line drops the date.""" +def test_get_git_commit_date_uses_version_info(tmp_path): + """Source install: version_info carries the commit date from live git.""" + from hermes_cli import dump + + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + + with patch("hermes_cli.version_info._resolve_stamp_file", lambda: None), \ + patch("hermes_cli.version_info._resolve_repo_dir", lambda: repo_dir), \ + patch("hermes_cli.version_info._git_version_info", + return_value=VersionInfo("0.19.0", "0.19.0+3", 3, "deadbeef" * 5, "main", "git", False, 1718662620)): + _reset_version_info_cache() + date = dump._get_git_commit_date(repo_dir) + + assert date == "2024-06-17" + + +def test_get_git_commit_date_empty_when_unknown(tmp_path): + """Docker/pip: no git, no stamp → '' so the dump line drops the date.""" from hermes_cli import dump repo_dir = tmp_path / "no-git-here" repo_dir.mkdir() - failed = MagicMock(returncode=128, stdout="") - with patch("hermes_cli.dump.subprocess.run", return_value=failed): + with patch("hermes_cli.version_info._resolve_stamp_file", lambda: None), \ + patch("hermes_cli.version_info._resolve_repo_dir", lambda: None): + _reset_version_info_cache() date = dump._get_git_commit_date(repo_dir) assert date == "" - - diff --git a/tests/hermes_cli/test_update_check.py b/tests/hermes_cli/test_update_check.py index d6af37b6b06ef..8697c9ad334d4 100644 --- a/tests/hermes_cli/test_update_check.py +++ b/tests/hermes_cli/test_update_check.py @@ -16,6 +16,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" @@ -26,6 +27,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() diff --git a/tests/hermes_cli/test_version_info.py b/tests/hermes_cli/test_version_info.py new file mode 100644 index 0000000000000..7a4c7d4b6e218 --- /dev/null +++ b/tests/hermes_cli/test_version_info.py @@ -0,0 +1,183 @@ +import json +from unittest.mock import MagicMock, patch + +from hermes_cli.version_info import ( + VersionInfo, + _derived_version, + _reset_version_info_cache, + _stamp_version_info, + get_version_info, +) + + +from hermes_cli import __release_date__ as RELEASE_DATE +from hermes_cli import __version__ as VERSION + + +def setup_function(): + _reset_version_info_cache() + + +def test_derived_version_shows_plus_question_for_dirty_unknown_distance(): + assert _derived_version("0.19.0", None, dirty=True) == "0.19.0+?" + assert _derived_version("0.19.0", None, dirty=False) == "0.19.0" + assert _derived_version("0.19.0", 5, dirty=True) == "0.19.0+5" + assert _derived_version("0.19.0", 0, dirty=True) == "0.19.0" + + +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", + "distribution": "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", distribution="nix") + + +def test_stamp_version_info_preserves_ci_provenance_and_docker_distribution(tmp_path, monkeypatch): + stamp = {"commit": "d" * 40, "source": "ci", "distribution": "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.source == "ci" + assert info.distribution == "docker" + + +def test_version_command_shows_provenance_and_distribution(monkeypatch, capsys): + import hermes_cli.main as main + + info = VersionInfo("0.19.0", "0.19.0", None, "a" * 40, "main", "ci", distribution="docker") + monkeypatch.setattr("hermes_cli.banner.format_banner_version_label", lambda: "Hermes Agent v0.19.0") + monkeypatch.setattr("hermes_cli.version_info.get_version_info", lambda: info) + + main._print_version_info(check_updates=False) + + output = capsys.readouterr().out + assert "Source: ci" in output + assert "Distribution: docker" in output + + +def test_stamp_version_info_preserves_missing_branch(tmp_path, monkeypatch): + stamp = { + "schemaVersion": 2, + "commit": "b" * 40, + "branch": None, + "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, "b" * 40, None, "docker", True) + + +def test_stamp_version_info_ignores_fallback_commit(tmp_path, monkeypatch): + """All-zero commit means the stamp has no 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.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): + output = { + ("git", "rev-parse", "HEAD"): "b" * 40, + ("git", "branch", "--show-current"): "feature/version", + ("git", "status", "--porcelain", "-uno"): "", + ("git", "rev-list", "--count", f"v{VERSION}..HEAD"): "3", + ("git", "log", "-1", "--format=%ct", "HEAD"): "1718662620", + }[tuple(command)] + return MagicMock(returncode=0, stdout=f"{output}\n") + + with patch("hermes_cli.version_info.subprocess.run", side_effect=run): + info = get_version_info() + + assert info == VersionInfo(VERSION, f"{VERSION}+3", 3, "b" * 40, "feature/version", "git", False, 1718662620) + + +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 = [] + + def run(command, **_kwargs): + calls.append(tuple(command)) + if tuple(command) == ("git", "rev-list", "--count", f"v{VERSION}..HEAD"): + return MagicMock(returncode=1, stdout="") + output = { + ("git", "rev-parse", "HEAD"): "c" * 40, + ("git", "branch", "--show-current"): "", + ("git", "status", "--porcelain", "-uno"): " M hermes_cli/version_info.py", + ("git", "rev-list", "--count", f"v{RELEASE_DATE}..HEAD"): "2", + ("git", "log", "-1", "--format=%ct", "HEAD"): "1718662620", + }[tuple(command)] + return MagicMock(returncode=0, stdout=f"{output}\n") + + with patch("hermes_cli.version_info.subprocess.run", side_effect=run): + info = get_version_info() + + assert info.derived_version == f"{VERSION}+2" + assert info.branch is None + assert info.dirty is True + assert ("git", "rev-list", "--count", f"v{RELEASE_DATE}..HEAD") in calls + + +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) + + info = get_version_info() + + assert info.base_version == VERSION + assert info.derived_version == VERSION + assert info.distance is None + assert info.commit is None + assert info.source == "unknown" diff --git a/tests/scripts/test_write_install_stamp.py b/tests/scripts/test_write_install_stamp.py new file mode 100644 index 0000000000000..b1a762e61aafd --- /dev/null +++ b/tests/scripts/test_write_install_stamp.py @@ -0,0 +1,38 @@ +import pytest + +from scripts.write_install_stamp import build_stamp + + +def test_build_stamp_keeps_provenance_separate_from_distribution(): + stamp = build_stamp(commit="a" * 40, source="ci", distribution="docker") + + assert stamp["source"] == "ci" + assert stamp["distribution"] == "docker" + + +def test_thin_build_carries_no_payload_regardless_of_tag(monkeypatch): + monkeypatch.delenv("HERMES_DESKTOP_BUNDLED", raising=False) + monkeypatch.setenv("HERMES_PAYLOAD_TAG", "v9.9.9") + + stamp = build_stamp(commit="a" * 40) + + assert stamp["payload"] is False + assert stamp["tag"] is None + + +def test_bundled_build_records_payload_and_tag(monkeypatch): + monkeypatch.setenv("HERMES_DESKTOP_BUNDLED", "1") + monkeypatch.setenv("HERMES_PAYLOAD_TAG", "v0.18.0") + + stamp = build_stamp(commit="b" * 40) + + assert stamp["payload"] is True + assert stamp["tag"] == "v0.18.0" + + +def test_bundled_build_without_tag_stops_the_build(monkeypatch): + monkeypatch.setenv("HERMES_DESKTOP_BUNDLED", "1") + monkeypatch.delenv("HERMES_PAYLOAD_TAG", raising=False) + + with pytest.raises(SystemExit, match="HERMES_PAYLOAD_TAG"): + build_stamp(commit="b" * 40) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 874e8d3188c76..80d0aaabbe1b6 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5248,7 +5248,12 @@ def _session_info(agent, session: dict | None = None) -> dict: "stored_session_id": session_key or "", "desktop_contract": DESKTOP_BACKEND_CONTRACT, "version": "", - "release_date": "", + "version_base": "", + "version_distance": None, + "version_commit": "", + "version_branch": "", + "version_source": "", + "version_dirty": False, "update_behind": None, "update_command": "", "usage": _session_usage_snapshot(session), @@ -5261,10 +5266,17 @@ def _session_info(agent, session: dict | None = None) -> dict: else _current_profile_name(), } try: - from hermes_cli import __version__, __release_date__ + from hermes_cli.version_info import get_version_info - info["version"] = __version__ - info["release_date"] = __release_date__ + version_info = get_version_info() + info["version"] = version_info.derived_version + info["version_base"] = version_info.base_version + info["version_distance"] = version_info.distance + info["version_commit"] = version_info.commit or "" + info["version_branch"] = version_info.branch or "" + info["version_source"] = version_info.source + info["version_distribution"] = version_info.distribution or "" + info["version_dirty"] = version_info.dirty except Exception: pass if agent is not None and not (session or {}).get("_compute_host_active"): diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index 18fdf3332c733..39aea3c670fe0 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -378,7 +378,9 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) { {t.brand.name} {info.version ? ` v${info.version}` : ''} - {info.release_date ? ` (${info.release_date})` : ''} + {info.version_branch ? ` · ${info.version_branch}` : ''} + {info.version_commit ? ` · ${info.version_commit.slice(0, 8)}` : ''} + {info.version_dirty ? ' · dirty' : ''} ) : ( diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 49c4cef4189a5..83a595d681a9a 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -171,7 +171,6 @@ export interface SessionInfo { profile_name?: string project?: null | ProjectInfo reasoning_effort?: string - release_date?: string service_tier?: string skills: Record system_prompt?: string @@ -180,6 +179,12 @@ export interface SessionInfo { update_command?: string usage?: Usage version?: string + version_base?: string + version_branch?: string + version_commit?: string + version_distance?: number | null + version_dirty?: boolean + version_source?: 'build' | 'ci' | 'docker' | 'fallback' | 'git' | 'local' | 'nix' | 'unknown' } export interface Usage { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 02ff011945a7c..d440635859b48 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1882,7 +1882,6 @@ export interface StatusResponse { gateway_updated_at: string | null; hermes_home: string; latest_config_version: number; - release_date: string; version: string; }