feat(version): show provenance and distribution

This commit is contained in:
ethernet 2026-07-28 13:58:25 -04:00
parent f2aeea95e0
commit 3a3744a6b2
17 changed files with 117 additions and 25 deletions

View file

@ -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

View file

@ -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
}
}

View file

@ -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(
<I18nProvider configClient={null} initialLocale="en">
<VersionDetails version={{ ...baseVersion, source: 'nix', distribution: 'nix' }} />
</I18nProvider>
)
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(
<I18nProvider configClient={null} initialLocale="en">
<VersionDetails version={{ ...baseVersion, source: 'ci', distribution: 'docker' }} />
</I18nProvider>
)
expect(screen.getByText('CI')).toBeTruthy()
expect(screen.getByText('Distribution')).toBeTruthy()
expect(screen.getByText('Docker')).toBeTruthy()
})
})

View file

@ -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 (
<dl className="grid gap-2 rounded-lg border border-border/70 bg-muted/20 px-3 py-3 text-sm">
@ -40,6 +42,18 @@ export function VersionDetails({ version }: { version: DesktopVersionInfo }) {
</ExternalLink>
</div>
)}
{source && (
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">{u.versionDetailsSource}</dt>
<dd>{source}</dd>
</div>
)}
{distribution && (
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">{u.versionDetailsDistribution}</dt>
<dd>{distribution}</dd>
</div>
)}
{version.dirty && (
<div className="text-warning">{unknownDistance ? u.versionDetailsDirtyUnknown : u.versionDetailsDirty}</div>
)}

View file

@ -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'
}

View file

@ -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.',

View file

@ -1994,6 +1994,8 @@ export const ja = defineLocale({
versionDetailsBranch: 'ブランチ',
versionDetailsNoBranchInfo: 'ブランチ情報なし',
versionDetailsCommit: 'コミット',
versionDetailsSource: 'ソース',
versionDetailsDistribution: '配布形式',
versionDetailsDirty: 'このパッケージは変更のあるソースツリーからビルドされました。',
versionDetailsDirtyUnknown: '変更のあるソースツリーからビルドされました。最後のリリースからの変更数は不明です。',
connectionRetry: '接続を確認してもう一度試してください。',

View file

@ -1773,6 +1773,8 @@ export interface Translations {
versionDetailsBranch: string
versionDetailsNoBranchInfo: string
versionDetailsCommit: string
versionDetailsSource: string
versionDetailsDistribution: string
versionDetailsDirty: string
versionDetailsDirtyUnknown: string
connectionRetry: string

View file

@ -1934,6 +1934,8 @@ export const zhHant = defineLocale({
versionDetailsBranch: '分支',
versionDetailsNoBranchInfo: '沒有分支資訊',
versionDetailsCommit: '提交',
versionDetailsSource: '來源',
versionDetailsDistribution: '發行方式',
versionDetailsDirty: '此套件從有未提交變更的來源樹建置。',
versionDetailsDirtyUnknown: '從有未提交變更的來源樹建置。自上次發布以來的變更數量未知。',
connectionRetry: '請檢查網路連線後重試。',

View file

@ -2325,6 +2325,8 @@ export const zh: Translations = {
versionDetailsBranch: '分支',
versionDetailsNoBranchInfo: '无分支信息',
versionDetailsCommit: '提交',
versionDetailsSource: '来源',
versionDetailsDistribution: '发行方式',
versionDetailsDirty: '此软件包从有未提交更改的源代码树构建。',
versionDetailsDirtyUnknown: '从有未提交更改的源代码树构建。自上次发布以来的更改数量未知。',
connectionRetry: '请检查网络连接后重试。',

View file

@ -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]}")

View file

@ -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)

View file

@ -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/

View file

@ -13,11 +13,11 @@ Usage::
# 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
--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]

View file

@ -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):

View file

@ -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"

View file

@ -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