From 3a3744a6b2a04c661cf04b53911437b0425e8b2c Mon Sep 17 00:00:00 2001 From: ethernet Date: Tue, 28 Jul 2026 13:58:25 -0400 Subject: [PATCH] feat(version): show provenance and distribution --- .github/workflows/docker.yml | 4 +-- apps/desktop/electron/main.ts | 6 ++-- .../src/components/version-details.test.tsx | 24 +++++++++++++++ .../src/components/version-details.tsx | 14 +++++++++ apps/desktop/src/global.d.ts | 2 +- apps/desktop/src/i18n/en.ts | 2 ++ apps/desktop/src/i18n/ja.ts | 2 ++ apps/desktop/src/i18n/types.ts | 2 ++ apps/desktop/src/i18n/zh-hant.ts | 2 ++ apps/desktop/src/i18n/zh.ts | 2 ++ hermes_cli/main.py | 5 ++-- hermes_cli/version_info.py | 29 ++++++++++--------- nix/desktop.nix | 2 +- scripts/write_install_stamp.py | 8 +++-- tests/hermes_cli/test_version_info.py | 29 ++++++++++++++++++- tests/scripts/test_write_install_stamp.py | 8 +++++ tui_gateway/server.py | 1 + 17 files changed, 117 insertions(+), 25 deletions(-) create mode 100644 tests/scripts/test_write_install_stamp.py diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 86dfaf20025..4e71df65aca 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -54,7 +54,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Write install stamp - run: python3 scripts/write_install_stamp.py --output install-stamp.json + run: python3 scripts/write_install_stamp.py --output install-stamp.json --distribution docker - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 @@ -145,7 +145,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Write install stamp - run: python3 scripts/write_install_stamp.py --output install-stamp.json + run: python3 scripts/write_install_stamp.py --output install-stamp.json --distribution docker - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index ce8aa7d8382..e763d801aa9 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -440,7 +440,7 @@ const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..') // Schema: // schema 1: { commit, branch, builtAt, dirty, source } // schema 2 adds immutable-package provenance: baseVersion, displayVersion, -// distance, and installMethod. +// distance, and distribution. const INSTALL_STAMP_SCHEMA_VERSION = 2 function loadInstallStamp() { @@ -474,7 +474,7 @@ function loadInstallStamp() { baseVersion: typeof parsed.baseVersion === 'string' ? parsed.baseVersion : null, displayVersion: typeof parsed.displayVersion === 'string' ? parsed.displayVersion : null, distance: typeof parsed.distance === 'number' && parsed.distance >= 0 ? parsed.distance : null, - installMethod: typeof parsed.installMethod === 'string' ? parsed.installMethod : null, + distribution: typeof parsed.distribution === 'string' ? parsed.distribution : null, builtAt: parsed.builtAt || null, dirty: Boolean(parsed.dirty), source: parsed.source || null, @@ -11071,7 +11071,7 @@ function resolveHermesVersionInfo() { commit: INSTALL_STAMP.commit, branch: INSTALL_STAMP.branch, source: INSTALL_STAMP.source ?? undefined, - installMethod: INSTALL_STAMP.installMethod ?? undefined, + distribution: INSTALL_STAMP.distribution ?? undefined, dirty: INSTALL_STAMP.dirty } } diff --git a/apps/desktop/src/components/version-details.test.tsx b/apps/desktop/src/components/version-details.test.tsx index a720d80d565..11ec9bfa464 100644 --- a/apps/desktop/src/components/version-details.test.tsx +++ b/apps/desktop/src/components/version-details.test.tsx @@ -38,4 +38,28 @@ describe('VersionDetails', () => { expect(screen.getByText('unknown')).toBeTruthy() expect(screen.queryByText('No branch information')).toBeNull() }) + + it('shows the Nix source and distribution from the stamp', () => { + render( + + + + ) + + expect(screen.getByText('Source')).toBeTruthy() + expect(screen.getAllByText('Nix')).toHaveLength(2) + expect(screen.getByText('Distribution')).toBeTruthy() + }) + + it('distinguishes CI provenance from the Docker distribution', () => { + render( + + + + ) + + expect(screen.getByText('CI')).toBeTruthy() + expect(screen.getByText('Distribution')).toBeTruthy() + expect(screen.getByText('Docker')).toBeTruthy() + }) }) diff --git a/apps/desktop/src/components/version-details.tsx b/apps/desktop/src/components/version-details.tsx index 498ace7ede2..f70143fe145 100644 --- a/apps/desktop/src/components/version-details.tsx +++ b/apps/desktop/src/components/version-details.tsx @@ -12,6 +12,8 @@ export function VersionDetails({ version }: { version: DesktopVersionInfo }) { const { t } = useI18n() const u = t.updates const unknownDistance = version.dirty && version.distance == null + const source = version.source === 'ci' ? 'CI' : version.source ? version.source[0].toUpperCase() + version.source.slice(1) : null + const distribution = version.distribution === 'nix' ? 'Nix' : version.distribution === 'docker' ? 'Docker' : null return (
@@ -40,6 +42,18 @@ export function VersionDetails({ version }: { version: DesktopVersionInfo }) { )} + {source && ( +
+
{u.versionDetailsSource}
+
{source}
+
+ )} + {distribution && ( +
+
{u.versionDetailsDistribution}
+
{distribution}
+
+ )} {version.dirty && (
{unknownDistance ? u.versionDetailsDirtyUnknown : u.versionDetailsDirty}
)} diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 241d558e23d..f90d6fa2828 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -328,7 +328,7 @@ export interface DesktopVersionInfo { nodeVersion: string platform: string hermesRoot: string - installMethod?: string + distribution?: 'docker' | 'nix' source?: 'build' | 'ci' | 'docker' | 'fallback' | 'git' | 'local' | 'nix' | 'unknown' } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 6746449611b..cd9b01eb956 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2134,6 +2134,8 @@ export const en: Translations = { versionDetailsBranch: 'Branch', versionDetailsNoBranchInfo: 'No branch information', versionDetailsCommit: 'Commit', + versionDetailsSource: 'Source', + versionDetailsDistribution: 'Distribution', versionDetailsDirty: 'This package was built from a dirty source tree.', versionDetailsDirtyUnknown: 'Built from a dirty source tree. The number of changes since the last release is unknown.', connectionRetry: 'Check your connection and try again.', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 6bbc48ba4cb..050c604204b 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1994,6 +1994,8 @@ export const ja = defineLocale({ versionDetailsBranch: 'ブランチ', versionDetailsNoBranchInfo: 'ブランチ情報なし', versionDetailsCommit: 'コミット', + versionDetailsSource: 'ソース', + versionDetailsDistribution: '配布形式', versionDetailsDirty: 'このパッケージは変更のあるソースツリーからビルドされました。', versionDetailsDirtyUnknown: '変更のあるソースツリーからビルドされました。最後のリリースからの変更数は不明です。', connectionRetry: '接続を確認してもう一度試してください。', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 6d7211ec1eb..139081ad2b2 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1773,6 +1773,8 @@ export interface Translations { versionDetailsBranch: string versionDetailsNoBranchInfo: string versionDetailsCommit: string + versionDetailsSource: string + versionDetailsDistribution: string versionDetailsDirty: string versionDetailsDirtyUnknown: string connectionRetry: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 5b372a113c5..9dd70526801 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1934,6 +1934,8 @@ export const zhHant = defineLocale({ versionDetailsBranch: '分支', versionDetailsNoBranchInfo: '沒有分支資訊', versionDetailsCommit: '提交', + versionDetailsSource: '來源', + versionDetailsDistribution: '發行方式', versionDetailsDirty: '此套件從有未提交變更的來源樹建置。', versionDetailsDirtyUnknown: '從有未提交變更的來源樹建置。自上次發布以來的變更數量未知。', connectionRetry: '請檢查網路連線後重試。', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index d8b05bfe5b5..d0a1a9e6c1f 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2325,6 +2325,8 @@ export const zh: Translations = { versionDetailsBranch: '分支', versionDetailsNoBranchInfo: '无分支信息', versionDetailsCommit: '提交', + versionDetailsSource: '来源', + versionDetailsDistribution: '发行方式', versionDetailsDirty: '此软件包从有未提交更改的源代码树构建。', versionDetailsDirtyUnknown: '从有未提交更改的源代码树构建。自上次发布以来的更改数量未知。', connectionRetry: '请检查网络连接后重试。', diff --git a/hermes_cli/main.py b/hermes_cli/main.py index d315040f6b0..f34b5a9e580 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4653,7 +4653,6 @@ def cmd_import(args): def _print_version_info(*, check_updates: bool = True) -> None: - from hermes_cli.config import detect_install_method from hermes_cli.banner import format_banner_version_label from hermes_cli.version_info import get_version_info @@ -4665,8 +4664,10 @@ def _print_version_info(*, check_updates: bool = True) -> None: print(f"Commit: {version_info.commit}") if version_info.dirty is not None: print(f"Working tree: {'dirty' if version_info.dirty else 'clean'}") + print(f"Source: {version_info.source}") + if version_info.distribution: + print(f"Distribution: {version_info.distribution}") print(f"Install directory: {PROJECT_ROOT}") - print(f"Install method: {detect_install_method(PROJECT_ROOT)}") # Show Python version print(f"Python: {sys.version.split()[0]}") diff --git a/hermes_cli/version_info.py b/hermes_cli/version_info.py index 32921d8c21e..f0a31100539 100644 --- a/hermes_cli/version_info.py +++ b/hermes_cli/version_info.py @@ -19,7 +19,7 @@ import os import subprocess from dataclasses import dataclass from pathlib import Path -from typing import Literal +from typing import Literal, cast from hermes_cli import __release_date__, __version__ @@ -31,9 +31,10 @@ class VersionInfo: distance: int | None commit: str | None branch: str | None - source: Literal["git", "nix", "docker", "build", "unknown"] + 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 format_display_version(info: VersionInfo | None = None) -> str: @@ -125,18 +126,17 @@ def _stamp_version_info() -> VersionInfo | None: if isinstance(distance, str): distance = _parse_nonnegative(distance) - # Normalize source labels — the stamp's "source" field describes the - # build environment (ci/local/docker/nix/fallback), not the runtime - # provenance path. Map known packaged sources to their runtime label. + # ``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 "") - if stamp_source in ("docker", "nix"): - source: Literal["git", "nix", "docker", "build", "unknown"] = stamp_source - elif stamp_source in ("ci", "local"): - # CI/local stamps are still git-based provenance — the commit was - # resolved from git at build time and baked into the stamp. - source = "git" - else: - source = "build" + 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): @@ -151,6 +151,7 @@ def _stamp_version_info() -> VersionInfo | None: source, bool(data.get("dirty")), commit_date, + distribution, ) @@ -236,4 +237,6 @@ def format_version_details(info: VersionInfo | None = None) -> str: if info.commit: values.append(f"commit {info.commit}") values.append(f"source {info.source}") + if info.distribution: + values.append(f"distribution {info.distribution}") return " · ".join(values) diff --git a/nix/desktop.nix b/nix/desktop.nix index b71a7a5aab0..bca711ca250 100644 --- a/nix/desktop.nix +++ b/nix/desktop.nix @@ -164,7 +164,7 @@ let cat > $out/install-stamp.json <<'EOF' {"schemaVersion":2,"commit":${builtins.toJSON rev},"branch":${builtins.toJSON branch},"baseVersion":"${version}","displayVersion":"${displayVersion}","distance":${builtins.toJSON distance},"dirty":${ if dirty then "true" else "false" - },"source":"nix","installMethod":"nix"} + },"source":"nix","distribution":"nix"} EOF cp -n apps/desktop/package.json $out/ diff --git a/scripts/write_install_stamp.py b/scripts/write_install_stamp.py index d450d17cd6d..2cec1c94231 100644 --- a/scripts/write_install_stamp.py +++ b/scripts/write_install_stamp.py @@ -13,11 +13,11 @@ Usage:: # Override provenance for reproducible/packaged builds: python scripts/write_install_stamp.py --output ... \\ --commit --branch --dirty \\ - --base-version 0.19.0 --distance 42 --source nix + --base-version 0.19.0 --distance 42 --source nix --distribution nix # Docker (no .git, commit known from build arg): python scripts/write_install_stamp.py --output /opt/hermes/.hermes_build_info.json \\ - --commit ${HERMES_GIT_SHA} --source docker + --commit ${HERMES_GIT_SHA} --source ci --distribution docker """ from __future__ import annotations @@ -126,6 +126,7 @@ def build_stamp( 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. @@ -181,6 +182,7 @@ def build_stamp( "builtAt": datetime.now(timezone.utc).isoformat(), "dirty": dirty, "source": source, + "distribution": distribution, "baseVersion": base_version, "displayVersion": display_version, "distance": distance, @@ -206,6 +208,7 @@ def main() -> int: 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( @@ -217,6 +220,7 @@ def main() -> int: distance=args.distance, commit_date=args.commit_date, source=args.source, + distribution=args.distribution, ) commit_short = stamp["commit"][:12] diff --git a/tests/hermes_cli/test_version_info.py b/tests/hermes_cli/test_version_info.py index 4a23f771a05..845a0ba9368 100644 --- a/tests/hermes_cli/test_version_info.py +++ b/tests/hermes_cli/test_version_info.py @@ -37,6 +37,7 @@ def test_stamp_version_info_reads_nix_stamp(tmp_path, monkeypatch): "distance": 3, "dirty": False, "source": "nix", + "distribution": "nix", } stamp_file = tmp_path / ".hermes_build_info.json" stamp_file.write_text(json.dumps(stamp)) @@ -44,7 +45,33 @@ def test_stamp_version_info_reads_nix_stamp(tmp_path, monkeypatch): info = get_version_info() - assert info == VersionInfo("0.19.0", "0.19.0+3", 3, "a" * 40, "feature/version", "nix") + 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): diff --git a/tests/scripts/test_write_install_stamp.py b/tests/scripts/test_write_install_stamp.py new file mode 100644 index 00000000000..0d7e02c3001 --- /dev/null +++ b/tests/scripts/test_write_install_stamp.py @@ -0,0 +1,8 @@ +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" \ No newline at end of file diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 36c28956db8..0b82c1d8783 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -4486,6 +4486,7 @@ def _session_info(agent, session: dict | None = None) -> dict: 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