feat(version): one install stamp carries build provenance
All packagers (Docker, Nix, desktop) write the same install-stamp.json with scripts/write_install_stamp.py or with equivalent inline data. The new hermes_cli/version_info.py reads the stamp first, falls back to live git for source installs, and reports "unknown" when neither exists. It caches the result per process. The stamp replaces three separate provenance paths: - the HERMES_REVISION env var from the Nix wrapper, - the .hermes_build_sha file from the Docker build arg, - live git probes in banner.py and dump.py. hermes_cli/build_info.py and the desktop's write-build-stamp.mjs are deleted with them. The desktop build calls the shared Python script. The banner, `hermes --version`, `hermes dump`, the TUI session panel, and the desktop About panel now show the same derived version: the release version, plus "+N" when the build is N commits past the release tag, or "+?" for a dirty tree with no countable tag. The release_date field is gone from every surface. The dirty probe uses `git status --porcelain -uno`: it runs on the startup-banner path, and an untracked-file scan costs real time on large checkouts.
This commit is contained in:
parent
c1af64ee99
commit
01aba5cf16
|
|
@ -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 }}
|
||||
|
|
|
|||
46
Dockerfile
46
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 <sha>` suffix.
|
||||
# That makes support triage from container bug reports impossible:
|
||||
# we can't tell which commit the user is actually running.
|
||||
#
|
||||
# Fix: write the commit SHA passed via the HERMES_GIT_SHA build-arg to
|
||||
# /opt/hermes/.hermes_build_sha at build time, and have
|
||||
# hermes_cli/build_info.py read it at runtime. Both `hermes dump` and
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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": "<branch name>",
|
||||
* "builtAt": "<ISO 8601 UTC timestamp>",
|
||||
* "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()
|
||||
}
|
||||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
|
|
@ -1155,7 +1155,6 @@ export interface StatusResponse {
|
|||
gateway_updated_at: string | null
|
||||
hermes_home: string
|
||||
latest_config_version: number
|
||||
release_date: string
|
||||
version: string
|
||||
}
|
||||
|
||||
|
|
|
|||
3
cli.py
3
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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <sha>`` 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)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
"""
|
||||
Baked-in build metadata for Hermes Agent.
|
||||
|
||||
Source installs report their git revision live via ``git rev-parse`` (see
|
||||
``hermes_cli/dump.py`` and ``hermes_cli/banner.py``). That doesn't work inside
|
||||
the published Docker image because ``.dockerignore`` excludes ``.git``, so
|
||||
those callsites fall back to ``"(unknown)"`` / drop the banner suffix entirely.
|
||||
|
||||
To make ``hermes dump`` and the startup banner identify the exact commit the
|
||||
image was built from, the Docker build writes the build-time ``$HERMES_GIT_SHA``
|
||||
arg into ``<project_root>/.hermes_build_sha``. This module is the single
|
||||
read-side helper consumed by both callsites — keeping the lookup in one place
|
||||
so the file path and missing-file behaviour stay consistent.
|
||||
|
||||
Behaviour:
|
||||
|
||||
- Returns ``None`` when the file is absent. Source installs and dev images
|
||||
built without the ``HERMES_GIT_SHA`` build-arg fall through to live-git
|
||||
resolution in the caller, so non-Docker installs are unaffected.
|
||||
- Returns ``None`` on any IO / decoding error. The build-sha is a nice-to-have
|
||||
for support triage; nothing in the CLI is allowed to crash because of it.
|
||||
- Truncates to ``short`` characters (default 8) to match the format used by
|
||||
``git rev-parse --short=8`` throughout the codebase.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Path is resolved relative to this module so it works regardless of cwd —
|
||||
# matches the pattern used by ``banner._resolve_repo_dir``.
|
||||
_BUILD_SHA_FILE = Path(__file__).parent.parent / ".hermes_build_sha"
|
||||
|
||||
|
||||
def get_build_sha(short: int = 8) -> Optional[str]:
|
||||
"""Return the baked-in build SHA, truncated to ``short`` chars, or None.
|
||||
|
||||
Reads ``<project_root>/.hermes_build_sha`` if present. The file is
|
||||
written by the Dockerfile's ``HERMES_GIT_SHA`` build-arg and contains
|
||||
the full 40-character commit hash on a single line.
|
||||
"""
|
||||
try:
|
||||
if not _BUILD_SHA_FILE.is_file():
|
||||
return None
|
||||
sha = _BUILD_SHA_FILE.read_text(encoding="utf-8").strip()
|
||||
except Exception:
|
||||
return None
|
||||
if not sha:
|
||||
return None
|
||||
return sha[:short] if short and short > 0 else sha
|
||||
|
|
@ -54,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 ``<project_root>/.hermes_build_sha`` by the Dockerfile's
|
||||
``HERMES_GIT_SHA`` build-arg (see ``hermes_cli/build_info.py``).
|
||||
The output format is identical regardless of source.
|
||||
Uses ``version_info.get_version_info()`` which reads the install stamp
|
||||
first (Docker/Nix), then falls back to live ``git rev-parse`` for source
|
||||
installs.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--short=8", "HEAD"],
|
||||
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5,
|
||||
cwd=str(project_root),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
value = result.stdout.strip()
|
||||
if value:
|
||||
return value
|
||||
from hermes_cli.version_info import get_version_info
|
||||
|
||||
info = get_version_info()
|
||||
if info.commit:
|
||||
return info.commit[:8]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fall back to the build-time baked SHA (populated in published Docker
|
||||
# images, absent otherwise). Defers the import so the dump module
|
||||
# stays cheap on non-dump code paths.
|
||||
try:
|
||||
from hermes_cli.build_info import get_build_sha
|
||||
baked = get_build_sha(short=8)
|
||||
if baked:
|
||||
return baked
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "(unknown)"
|
||||
|
||||
|
||||
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 ""
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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]}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <<STAMP
|
||||
{"schemaVersion":2,"commit":${builtins.toJSON rev},"commitDate":${builtins.toJSON lastModified},"branch":${builtins.toJSON branch},"baseVersion":"${version}","displayVersion":"${stampDisplayVersion}","distance":${builtins.toJSON stampDistance},"dirty":${if dirty then "true" else "false"},"source":"nix"}
|
||||
STAMP
|
||||
|
||||
${lib.concatMapStringsSep "\n"
|
||||
(name: ''
|
||||
makeWrapper ${hermesVenv}/bin/${name} $out/bin/${name} \
|
||||
|
|
@ -193,18 +217,9 @@ stdenv.mkDerivation (finalAttrs: {
|
|||
--set HERMES_WEB_DIST $out/share/hermes-agent/web_dist \
|
||||
--set HERMES_TUI_DIR $out/ui-tui \
|
||||
--set HERMES_PYTHON ${hermesVenv}/bin/python3 \
|
||||
--set HERMES_NODE ${lib.getExe hermesNpmLib.nodejs}${
|
||||
# Fold the line continuation INTO the optionalString: a bare
|
||||
# `\` on the line above an empty expansion would dangle onto a
|
||||
# blank line, ending the makeWrapper command early and running
|
||||
# the next flag as its own shell command (`--suffix: command
|
||||
# not found`). Only reproduces when rev == null (dirty trees).
|
||||
lib.optionalString (rev != null) " \\\n --set HERMES_REVISION ${rev}"
|
||||
}${
|
||||
lib.optionalString (
|
||||
extraPythonPackages != [ ]
|
||||
) " \\\n --suffix PYTHONPATH : \"${pythonPath}\""
|
||||
}
|
||||
--set HERMES_NODE ${lib.getExe hermesNpmLib.nodejs} \
|
||||
--set HERMES_BUILD_INFO $out/share/hermes-agent/.hermes_build_info.json${lib.optionalString (extraPythonPackages != [ ]) " \\
|
||||
--suffix PYTHONPATH : \"${pythonPath}\""}
|
||||
'')
|
||||
[
|
||||
"hermes"
|
||||
|
|
@ -244,6 +259,9 @@ stdenv.mkDerivation (finalAttrs: {
|
|||
hermesDesktop = callPackage ./desktop.nix {
|
||||
inherit hermesNpmLib electron;
|
||||
hermesAgent = finalAttrs.finalPackage;
|
||||
inherit rev branch dirty;
|
||||
distance = stampDistance;
|
||||
displayVersion = stampDisplayVersion;
|
||||
};
|
||||
|
||||
devShellHook = ''
|
||||
|
|
|
|||
|
|
@ -11,13 +11,23 @@
|
|||
let
|
||||
|
||||
sandbox = pkgs.callPackage ./sandbox.nix { };
|
||||
|
||||
dirtyRevision = inputs.self.dirtyRev or null;
|
||||
# inputs.self.sourceInfo is a string (store path) in current Nix, not a
|
||||
# record. The metadata fields are available as top-level attributes on
|
||||
# inputs.self directly. A flake URL's requested ref is intentionally not
|
||||
# exposed as self.ref after Nix resolves it to an immutable source, so
|
||||
# branch is normally null for remote flakes. Preserve null rather than
|
||||
# inventing a sentinel that could be a real branch name.
|
||||
rev = inputs.self.rev or (if dirtyRevision != null then builtins.substring 0 40 dirtyRevision else null);
|
||||
revCount = inputs.self.revCount or null;
|
||||
rawRef = inputs.self.ref or null;
|
||||
branch = if rawRef != null then builtins.replaceStrings ["refs/heads/"] [""] rawRef else null;
|
||||
dirty = dirtyRevision != null;
|
||||
lastModified = inputs.self.lastModified or null;
|
||||
minimal = pkgs.callPackage ./hermes-agent.nix {
|
||||
inherit (inputs) uv2nix pyproject-nix pyproject-build-systems;
|
||||
npm-lockfile-fix = inputs'.npm-lockfile-fix.packages.default;
|
||||
# Only embed clean revs — dirtyRev doesn't represent any upstream
|
||||
# commit, so comparing it would always claim "update available".
|
||||
rev = inputs.self.rev or null;
|
||||
inherit rev revCount branch dirty lastModified;
|
||||
};
|
||||
|
||||
# All platform-portable optional integrations pre-built.
|
||||
|
|
|
|||
|
|
@ -2145,7 +2145,7 @@ function Install-Repository {
|
|||
Write-Success "Downloaded and extracted"
|
||||
|
||||
# Initialize git repo so updates work later. A bare
|
||||
# `git init` leaves NO HEAD -- desktop's write-build-stamp
|
||||
# `git init` leaves NO HEAD -- desktop's stamp step
|
||||
# then hard-fails with "could not determine git commit"
|
||||
# (#50823 / #61657). Fetch the requested ref and force-check
|
||||
# it out (-f) so untracked ZIP files cannot block checkout.
|
||||
|
|
@ -3421,7 +3421,7 @@ function Install-Desktop {
|
|||
Pop-Location
|
||||
|
||||
# 2. Build apps/desktop. `npm run pack` runs:
|
||||
# assert-root-install + write-build-stamp + stage-native-deps +
|
||||
# assert-root-install + write_install_stamp + stage-native-deps +
|
||||
# tsc -b + vite build + electron-builder --dir
|
||||
# The --dir mode produces an unpacked Hermes.exe in
|
||||
# apps/desktop/release/win-unpacked/ without bundling NSIS/MSI;
|
||||
|
|
@ -3442,7 +3442,7 @@ function Install-Desktop {
|
|||
# for some other tool, electron-builder would still try to sign.
|
||||
Write-Info "Building desktop app (this takes 1-3 minutes)..."
|
||||
$buildLog = "$env:TEMP\hermes-desktop-build-$(Get-Random).log"
|
||||
# Seed GITHUB_SHA for write-build-stamp.mjs. The stamp prefers CI env vars
|
||||
# Seed GITHUB_SHA for scripts/write_install_stamp.py. The stamp prefers CI env vars
|
||||
# over `git rev-parse`, so this covers: (1) node can't find git.exe on PATH
|
||||
# even though this PowerShell session can, (2) ZIP/init trees that still
|
||||
# lack a HEAD after a failed post-extract fetch. Without it the desktop
|
||||
|
|
@ -3475,7 +3475,7 @@ function Install-Desktop {
|
|||
$shaPreview = if ($env:GITHUB_SHA.Length -ge 12) { $env:GITHUB_SHA.Substring(0, 12) } else { $env:GITHUB_SHA }
|
||||
Write-Info "Desktop build stamp: $shaPreview ($($env:GITHUB_REF_NAME))"
|
||||
} else {
|
||||
Write-Warn "Could not resolve a git commit for the desktop stamp -- write-build-stamp will use its non-git fallback"
|
||||
Write-Warn "Could not resolve a git commit for the desktop stamp -- write_install_stamp will use its non-git fallback"
|
||||
}
|
||||
Push-Location $desktopDir
|
||||
$prevEAP = $ErrorActionPreference
|
||||
|
|
|
|||
|
|
@ -0,0 +1,250 @@
|
|||
"""Generate canonical install-stamp.json for packaged Hermes builds.
|
||||
|
||||
All packagers (Docker, Nix, desktop) call this script to produce the same
|
||||
``install-stamp.json`` file. Runtime surfaces (CLI, TUI, desktop) read the
|
||||
stamp through ``hermes_cli.version_info`` — no env vars, no separate
|
||||
docker/nix code paths.
|
||||
|
||||
Usage::
|
||||
|
||||
# From a repo root with .git available (dev/CI builds):
|
||||
python scripts/write_install_stamp.py --output /path/to/install-stamp.json
|
||||
|
||||
# Override provenance for reproducible/packaged builds:
|
||||
python scripts/write_install_stamp.py --output ... \\
|
||||
--commit <sha> --branch <name> --dirty \\
|
||||
--base-version 0.19.0 --distance 42 --source nix --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())
|
||||
|
|
@ -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})"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ``<project_root>/.hermes_build_sha``. These tests cover the read-side
|
||||
helper: missing file, malformed file, truncation, and error tolerance.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def test_get_build_sha_returns_none_when_file_absent(tmp_path):
|
||||
"""Source installs: no file present → None, callers fall back to git."""
|
||||
from hermes_cli import build_info
|
||||
|
||||
missing = tmp_path / ".hermes_build_sha" # never created
|
||||
|
||||
with patch.object(build_info, "_BUILD_SHA_FILE", missing):
|
||||
assert build_info.get_build_sha() is None
|
||||
|
||||
|
||||
def test_get_build_sha_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
|
||||
|
||||
|
||||
|
|
@ -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 == ""
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -378,7 +378,9 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) {
|
|||
<Text bold color={t.color.primary}>
|
||||
{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' : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -171,7 +171,6 @@ export interface SessionInfo {
|
|||
profile_name?: string
|
||||
project?: null | ProjectInfo
|
||||
reasoning_effort?: string
|
||||
release_date?: string
|
||||
service_tier?: string
|
||||
skills: Record<string, string[]>
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -1882,7 +1882,6 @@ export interface StatusResponse {
|
|||
gateway_updated_at: string | null;
|
||||
hermes_home: string;
|
||||
latest_config_version: number;
|
||||
release_date: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue