From 1a4c4eca0d9d998aa855f283fa3f6d2feef94d17 Mon Sep 17 00:00:00 2001 From: ethernet Date: Fri, 24 Jul 2026 16:14:56 -0400 Subject: [PATCH] feat(version): derive display versions from release tags Resolve user-facing versions from SemVer release tags and available local history while retaining raw package metadata for API identity. Stamp Desktop provenance at build time, and expose generic version details for every Desktop install method. --- Dockerfile | 2 +- apps/desktop/electron/main.ts | 68 +++---- apps/desktop/scripts/write-build-stamp.mjs | 82 ++++++++- .../scripts/write-build-stamp.test.mjs | 68 +++++++ .../src/app/settings/about-settings.tsx | 4 +- .../app/shell/hooks/use-statusbar-items.tsx | 9 +- apps/desktop/src/app/updates-overlay.tsx | 64 +++++-- .../src/components/version-details.tsx | 50 +++++ apps/desktop/src/global.d.ts | 7 + apps/desktop/src/i18n/ar.ts | 1 - apps/desktop/src/i18n/en.ts | 10 +- apps/desktop/src/i18n/ja.ts | 10 +- apps/desktop/src/i18n/types.ts | 9 +- apps/desktop/src/i18n/zh-hant.ts | 9 +- apps/desktop/src/i18n/zh.ts | 9 +- apps/desktop/src/types/hermes.ts | 1 - cli.py | 3 +- hermes_cli/__init__.py | 5 + hermes_cli/banner.py | 104 ++--------- hermes_cli/main.py | 12 +- hermes_cli/version_info.py | 174 ++++++++++++++++++ hermes_cli/web_server.py | 3 +- nix/desktop.nix | 41 ++++- nix/hermes-agent.nix | 20 +- nix/packages.nix | 11 +- scripts/release.py | 59 +++--- tests/hermes_cli/test_banner_git_state.py | 110 ++--------- .../test_dashboard_auth_status_endpoint.py | 2 +- tests/hermes_cli/test_version_info.py | 109 +++++++++++ tests/scripts/test_release_tags.py | 31 ++++ tui_gateway/server.py | 19 +- ui-tui/src/components/branding.tsx | 4 +- ui-tui/src/types.ts | 7 +- web/src/lib/api.ts | 1 - website/docs/getting-started/updating.md | 2 +- 35 files changed, 827 insertions(+), 293 deletions(-) create mode 100644 apps/desktop/src/components/version-details.tsx create mode 100644 hermes_cli/version_info.py create mode 100644 tests/hermes_cli/test_version_info.py create mode 100644 tests/scripts/test_release_tags.py diff --git a/Dockerfile b/Dockerfile index 42870d73776..de0bea6e7fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -298,7 +298,7 @@ RUN mkdir -p /opt/hermes/bin && \ # 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 +# version_info.get_version_info() try the baked SHA first, then fall back # to live `git rev-parse` for source installs (unchanged behaviour). # # The arg is optional — local `docker build` without --build-arg simply diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index dfb4747f039..4a2c1ce6c8c 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -438,8 +438,10 @@ const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..') // build hasn't been invoked, or schema mismatch). Callers must handle null. // // Schema: -// { schemaVersion: 1, commit, branch, builtAt, dirty, source } -const INSTALL_STAMP_SCHEMA_VERSION = 1 +// schema 1: { commit, branch, builtAt, dirty, source } +// schema 2 adds immutable-package provenance: baseVersion, displayVersion, +// distance, and installMethod. +const INSTALL_STAMP_SCHEMA_VERSION = 2 function loadInstallStamp() { // Try packaged location first (resources/install-stamp.json), then the @@ -457,7 +459,7 @@ function loadInstallStamp() { const parsed = JSON.parse(raw) if (parsed && typeof parsed === 'object' && typeof parsed.commit === 'string' && parsed.commit.length >= 7) { - if (parsed.schemaVersion !== INSTALL_STAMP_SCHEMA_VERSION) { + if (parsed.schemaVersion !== 1 && parsed.schemaVersion !== INSTALL_STAMP_SCHEMA_VERSION) { console.warn( `[hermes] install-stamp.json schemaVersion ${parsed.schemaVersion} != expected ${INSTALL_STAMP_SCHEMA_VERSION}; ignoring` ) @@ -469,6 +471,10 @@ function loadInstallStamp() { schemaVersion: parsed.schemaVersion, commit: parsed.commit, branch: parsed.branch || null, + 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, builtAt: parsed.builtAt || null, dirty: Boolean(parsed.dirty), source: parsed.source || null, @@ -11037,35 +11043,16 @@ ipcMain.handle('hermes:updates:branch:set', async (_event, name) => { return { branch } }) -// Resolve the canonical Hermes version (the one `release.py` bumps in -// hermes_cli/__init__.py + pyproject.toml) so the desktop About panel shows the -// real Hermes version instead of the Electron app's own package.json version, -// which historically drifted (stuck at 0.0.2). Falls back to app.getVersion() -// when the source tree can't be read (e.g. a packaged build without the repo). +// Resolve the canonical Hermes version from the build stamp rather than the +// Electron app's own package.json version, which historically drifted (stuck +// at 0.0.2). An unstamped legacy build falls back to app.getVersion(). function resolveHermesVersion() { - try { - const root = resolveUpdateRoot() - const initPath = path.join(root, 'hermes_cli', '__init__.py') - - if (fileExists(initPath)) { - const raw = fs.readFileSync(initPath, 'utf8') - const match = raw.match(/__version__\s*=\s*["']([^"']+)["']/) - - if (match) { - return match[1] - } - } - } catch { - // Fall through to the Electron app version below. - } - - return app.getVersion() + return INSTALL_STAMP?.displayVersion ?? INSTALL_STAMP?.baseVersion ?? app.getVersion() } -// Re-resolve the live Hermes version and push it into the native About panel -// just before showing it, so an in-place `hermes update` is reflected without -// an app restart. macOS only — `showAboutPanel()` is a no-op elsewhere, and the -// other platforms don't use this menu item. +// The stamp is generated alongside the renderer, so the native About panel and +// the renderer report the same build identity. macOS only — `showAboutPanel()` +// is a no-op elsewhere, and the other platforms don't use this menu item. function showAboutPanelFresh() { app.setAboutPanelOptions({ applicationName: APP_NAME, @@ -11075,8 +11062,27 @@ function showAboutPanelFresh() { app.showAboutPanel() } -ipcMain.handle('hermes:version', async () => ({ - appVersion: resolveHermesVersion(), +function resolveHermesVersionInfo() { + if (INSTALL_STAMP) { + return { + appVersion: resolveHermesVersion(), + baseVersion: INSTALL_STAMP.baseVersion ?? undefined, + distance: INSTALL_STAMP.distance ?? undefined, + commit: INSTALL_STAMP.commit, + branch: INSTALL_STAMP.branch ?? undefined, + source: INSTALL_STAMP.source ?? undefined, + installMethod: INSTALL_STAMP.installMethod ?? undefined, + dirty: INSTALL_STAMP.dirty + } + } + + const appVersion = app.getVersion() + + return { appVersion, baseVersion: appVersion } +} + +ipcMain.handle('hermes:version', () => ({ + ...resolveHermesVersionInfo(), electronVersion: process.versions.electron, nodeVersion: process.versions.node, platform: process.platform, diff --git a/apps/desktop/scripts/write-build-stamp.mjs b/apps/desktop/scripts/write-build-stamp.mjs index 076d5a893e2..50a4c435b68 100644 --- a/apps/desktop/scripts/write-build-stamp.mjs +++ b/apps/desktop/scripts/write-build-stamp.mjs @@ -6,12 +6,15 @@ * * Schema (subject to bump via STAMP_SCHEMA_VERSION): * { - * "schemaVersion": 1, + * "schemaVersion": 2, * "commit": "<40-char SHA>", * "branch": "", * "builtAt": "", * "dirty": true|false, - * "source": "ci" | "local" | "fallback" + * "source": "ci" | "local" | "fallback", + * "baseVersion": "" | null, + * "displayVersion":"" | null, + * "distance": | null * } * * Source preference order: @@ -26,13 +29,17 @@ * commit as unpinned and follows the branch instead of fetching a fake SHA. */ -import { mkdirSync, writeFileSync } from "fs" +import { mkdirSync, readFileSync, writeFileSync } from "fs" import { resolve, join, relative } from "path" import { execSync } from "child_process" import { isMain } from "./utils.mjs" -const STAMP_SCHEMA_VERSION = 1 +const STAMP_SCHEMA_VERSION = 2 +// 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. +const SEMVER_TAG = /^v(0|[1-9]\d{0,2})\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/ /** All-zero placeholder used when no real commit can be resolved. */ export const FALLBACK_COMMIT = "0000000000000000000000000000000000000000" @@ -114,8 +121,68 @@ export function isFallbackCommit(commit) { return typeof commit === "string" && /^0{7,40}$/.test(commit) } +function parseReleaseMetadata(repoRoot, readFile = readFileSync) { + try { + const init = readFile(join(repoRoot, 'hermes_cli', '__init__.py'), 'utf8') + const baseVersion = init.match(/__version__\s*=\s*["']([^"']+)["']/)?.[1] + const releaseDate = init.match(/__release_date__\s*=\s*["']([^"']+)["']/)?.[1] + + return { baseVersion: baseVersion || null, releaseDate: releaseDate || null } + } catch { + return { baseVersion: null, releaseDate: null } + } +} + +function splitLines(value) { + return value ? value.split(/\r?\n/).map(line => line.trim()).filter(Boolean) : [] +} + +/** + * Add user-facing release topology to a build stamp. This runs while Git is + * available to the build, so Electron never needs to inspect a live checkout. + */ +export function deriveVersionMetadata(stamp, { + repoRoot = REPO_ROOT, + execFn = tryExec, + readFile = readFileSync +} = {}) { + const { baseVersion, releaseDate } = parseReleaseMetadata(repoRoot, readFile) + if (!baseVersion) { + return stamp + } + + const mergedTags = splitLines(execFn('git tag --merged HEAD --format=%(refname:short)', { cwd: repoRoot })) + const candidates = mergedTags.filter(tag => SEMVER_TAG.test(tag)) + // Hermes has historical CalVer tags. Keep the release-date tag as a + // temporary fallback until the first v0.x.y tag exists; never parse a + // broad v[0-9]* match as SemVer. + if (releaseDate) { + candidates.push(`v${releaseDate}`) + } + + let distance = null + let releaseTag = null + for (const tag of candidates) { + const raw = execFn(`git rev-list --count ${tag}..HEAD`, { cwd: repoRoot }) + const value = raw === null ? Number.NaN : Number(raw) + if (Number.isInteger(value) && value >= 0 && (distance === null || value < distance)) { + distance = value + releaseTag = tag + } + } + + const semver = releaseTag?.match(SEMVER_TAG) + const taggedVersion = semver ? `${semver[1]}.${semver[2]}.${semver[3]}` : baseVersion + const displayVersion = + distance !== null && distance > 0 ? `${taggedVersion}+${distance}` + : stamp.dirty && distance === null ? `${taggedVersion}+?` + : taggedVersion + + return { ...stamp, baseVersion: taggedVersion, displayVersion, distance } +} + function main() { - const stamp = resolveStamp() + const stamp = deriveVersionMetadata(resolveStamp()) if (!stamp || !stamp.commit) { // Should not happen — fromFallback() always provides a commit. console.error( @@ -155,7 +222,10 @@ function main() { branch: stamp.branch, builtAt: new Date().toISOString(), dirty: stamp.dirty, - source: stamp.source + source: stamp.source, + baseVersion: stamp.baseVersion ?? null, + displayVersion: stamp.displayVersion ?? null, + distance: stamp.distance ?? null } mkdirSync(OUT_DIR, { recursive: true }) diff --git a/apps/desktop/scripts/write-build-stamp.test.mjs b/apps/desktop/scripts/write-build-stamp.test.mjs index 53c88e23704..7f34ad9d973 100644 --- a/apps/desktop/scripts/write-build-stamp.test.mjs +++ b/apps/desktop/scripts/write-build-stamp.test.mjs @@ -5,6 +5,7 @@ import { FALLBACK_BRANCH, FALLBACK_COMMIT, fromCI, + deriveVersionMetadata, fromFallback, fromLocalGit, isFallbackCommit, @@ -84,3 +85,70 @@ test('resolveStamp falls back when neither CI nor git is available', () => { source: 'fallback' }) }) + +test('deriveVersionMetadata prefers a strict SemVer tag over historical CalVer tags', () => { + const stamp = deriveVersionMetadata( + { commit: 'a'.repeat(40), branch: 'feature', dirty: true, source: 'local' }, + { + readFile: () => '__version__ = "0.20.0"\n__release_date__ = "2026.7.20"\n', + execFn: command => { + if (command.startsWith('git tag --merged')) return 'v2026.7.20\nv0.19.0\n' + if (command === 'git rev-list --count v0.19.0..HEAD') return '7' + if (command === 'git rev-list --count v2026.7.20..HEAD') return '20' + return null + } + } + ) + + assert.deepEqual(stamp, { + commit: 'a'.repeat(40), branch: 'feature', dirty: true, source: 'local', + baseVersion: '0.19.0', displayVersion: '0.19.0+7', distance: 7 + }) +}) + +test('deriveVersionMetadata uses the historical release tag only as a transition fallback', () => { + const stamp = deriveVersionMetadata( + { commit: 'a'.repeat(40), branch: 'feature', dirty: false, source: 'local' }, + { + readFile: () => '__version__ = "0.19.0"\n__release_date__ = "2026.7.20"\n', + execFn: command => command === 'git rev-list --count v2026.7.20..HEAD' ? '3' : '' + } + ) + + assert.equal(stamp.baseVersion, '0.19.0') + assert.equal(stamp.displayVersion, '0.19.0+3') + assert.equal(stamp.distance, 3) +}) + +test('deriveVersionMetadata accepts three-digit SemVer majors but rejects four-digit CalVer years', () => { + const stamp = deriveVersionMetadata( + { commit: 'a'.repeat(40), branch: 'feature', dirty: false, source: 'local' }, + { + readFile: () => '__version__ = "999.1.2"\n__release_date__ = "2026.7.20"\n', + execFn: command => { + if (command.startsWith('git tag --merged')) return 'v2026.7.20\nv999.1.2\n' + if (command === 'git rev-list --count v999.1.2..HEAD') return '4' + if (command === 'git rev-list --count v2026.7.20..HEAD') return '8' + return null + } + } + ) + + assert.equal(stamp.baseVersion, '999.1.2') + assert.equal(stamp.displayVersion, '999.1.2+4') + assert.equal(stamp.distance, 4) +}) + +test('deriveVersionMetadata shows +? for dirty builds with unknown distance', () => { + const stamp = deriveVersionMetadata( + { commit: 'a'.repeat(40), branch: 'feature', dirty: true, source: 'local' }, + { + readFile: () => '__version__ = "0.19.0"\n__release_date__ = "2026.7.20"\n', + execFn: () => null + } + ) + + assert.equal(stamp.baseVersion, '0.19.0') + assert.equal(stamp.displayVersion, '0.19.0+?') + assert.equal(stamp.distance, null) +}) diff --git a/apps/desktop/src/app/settings/about-settings.tsx b/apps/desktop/src/app/settings/about-settings.tsx index fa0ca76a2c3..a3846ca422a 100644 --- a/apps/desktop/src/app/settings/about-settings.tsx +++ b/apps/desktop/src/app/settings/about-settings.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react' import { BrandMark } from '@/components/brand-mark' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' +import { VersionDetails } from '@/components/version-details' import { type Translations, useI18n } from '@/i18n' import { CheckCircle2, ExternalLink, Loader2, RefreshCw } from '@/lib/icons' import { cn } from '@/lib/utils' @@ -170,9 +171,10 @@ export function AboutSettings() { + {version && } + diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index fb2aac6d2da..4967e0aad43 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -219,7 +219,8 @@ export function useStatusbarItems({ const clientVersionItem = useMemo(() => { const appVersion = desktopVersion?.appVersion - const sha = updateStatus?.currentSha?.slice(0, 7) ?? null + const sha = desktopVersion?.commit?.slice(0, 7) ?? updateStatus?.currentSha?.slice(0, 7) ?? null + const branch = desktopVersion?.branch ?? updateStatus?.branch ?? null const behind = updateStatus?.behind ?? 0 const applying = updateApply.applying || updateApply.stage === 'restart' const remote = connection?.mode === 'remote' @@ -234,10 +235,10 @@ export function useStatusbarItems({ const tooltip = [ applying ? updateApply.message || copy.updateInProgress : null, - !applying && behind > 0 && copy.commitsBehind(behind, updateStatus?.branch ?? '...'), + !applying && behind > 0 && copy.commitsBehind(behind, branch ?? '...'), appVersion && copy.desktopVersion(appVersion), sha && copy.commit(sha), - updateStatus?.branch && copy.branch(updateStatus.branch) + branch && copy.branch(branch) ] .filter(Boolean) .join(' · ') @@ -259,6 +260,8 @@ export function useStatusbarItems({ } }, [ desktopVersion?.appVersion, + desktopVersion?.commit, + desktopVersion?.branch, connection?.mode, copy, updateApply.applying, diff --git a/apps/desktop/src/app/updates-overlay.tsx b/apps/desktop/src/app/updates-overlay.tsx index 2ad94f8ab38..9f2030dc43a 100644 --- a/apps/desktop/src/app/updates-overlay.tsx +++ b/apps/desktop/src/app/updates-overlay.tsx @@ -14,7 +14,8 @@ import { import { ErrorIcon, ErrorState } from '@/components/ui/error-state' import { Loader } from '@/components/ui/loader' import { Progress } from '@/components/ui/progress' -import type { DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global' +import { VersionDetails } from '@/components/version-details' +import type { DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus, DesktopVersionInfo } from '@/global' import { useI18n } from '@/i18n' import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog' import { AlertCircle, Check, Copy, Terminal } from '@/lib/icons' @@ -24,6 +25,7 @@ import { $backendUpdateApply, $backendUpdateChecking, $backendUpdateStatus, + $desktopVersion, $updateApply, $updateChecking, $updateOverlayOpen, @@ -52,6 +54,7 @@ export function UpdatesOverlay() { const backendStatus = useStore($backendUpdateStatus) const backendChecking = useStore($backendUpdateChecking) const backendApply = useStore($backendUpdateApply) + const desktopVersion = useStore($desktopVersion) const isBackend = target === 'backend' const status = isBackend ? backendStatus : clientStatus @@ -120,7 +123,9 @@ export function UpdatesOverlay() { handleClose(false)} onRetry={handleInstall} /> )} - {phase === 'idle' && ( + {phase === 'idle' && !isBackend && desktopVersion && status?.supported === false ? ( + handleClose(false)} version={desktopVersion} /> + ) : phase === 'idle' ? ( - )} + ) : null} ) } +function ManagedInstallDetailsView({ onDone, version }: { onDone: () => void; version: DesktopVersionInfo }) { + const { t } = useI18n() + const u = t.updates + + return ( +
+
+ + {u.versionDetailsTitle} + {u.versionDetailsBody} +
+ + +
+ ) +} + function IdleView({ behind, checking, @@ -147,7 +172,8 @@ function IdleView({ onRetryCheck, status, target, - updateAvailable + updateAvailable, + version }: { behind: number checking: boolean @@ -158,6 +184,7 @@ function IdleView({ status: DesktopUpdateStatus | null target: UpdateTarget updateAvailable: boolean + version: DesktopVersionInfo | null }) { const { t } = useI18n() const u = t.updates @@ -185,13 +212,18 @@ function IdleView({ ) } + const details = version ? : null + if (!status.supported) { return ( - } - title={u.notAvailableTitle} - /> +
+ } + title={u.notAvailableTitle} + /> + {details} +
) } @@ -212,11 +244,14 @@ function IdleView({ if (!updateAvailable) { return ( - } - title={u.allSetTitle} - /> +
+ } + title={u.allSetTitle} + /> + {details} +
) } @@ -232,6 +267,7 @@ function IdleView({ return (
+ {details}
diff --git a/apps/desktop/src/components/version-details.tsx b/apps/desktop/src/components/version-details.tsx new file mode 100644 index 00000000000..2e6f24e76bc --- /dev/null +++ b/apps/desktop/src/components/version-details.tsx @@ -0,0 +1,50 @@ +import type { DesktopVersionInfo } from '@/global' +import { useI18n } from '@/i18n' +import { ExternalLink } from '@/lib/external-link' + +/** + * Shared build-provenance display. Reads from `$desktopVersion` + * (populated from the build stamp / `hermes:version` IPC), so every + * surface — the About settings page, the updates overlay — shows the + * same version, branch, commit, and dirty flag from one source of truth. + */ +export function VersionDetails({ version }: { version: DesktopVersionInfo }) { + const { t } = useI18n() + const u = t.updates + const unknownDistance = version.dirty && version.distance == null + + return ( +
+
+
{u.versionDetailsVersion}
+
v{version.appVersion}
+
+ {version.baseVersion && ( +
+
{u.versionDetailsBaseVersion}
+
{version.baseVersion}
+
+ )} + {version.branch && ( +
+
{u.versionDetailsBranch}
+
{version.branch}
+
+ )} + {version.commit && ( +
+
{u.versionDetailsCommit}
+ + {version.commit.slice(0, 14)} + +
+ )} + {version.dirty && ( +
{unknownDistance ? u.versionDetailsDirtyUnknown : u.versionDetailsDirty}
+ )} +
+ ) +} diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 80c8104376e..3b4f4f1c305 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -319,10 +319,17 @@ export interface HermesTerminalExit { export interface DesktopVersionInfo { appVersion: string + baseVersion?: string + branch?: string + commit?: string + distance?: number + dirty?: boolean electronVersion: string nodeVersion: string platform: string hermesRoot: string + installMethod?: string + source?: 'build' | 'ci' | 'docker' | 'fallback' | 'git' | 'local' | 'nix' | 'unknown' } export type DesktopUninstallMode = 'full' | 'gui' | 'lite' diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 0f087a9dd72..093767f7e46 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -598,7 +598,6 @@ export const ar = defineLocale({ justNowSuffix: 'الآن', automaticUpdates: 'التحديثات التلقائية', automaticUpdatesDesc: 'اسمح لـ Hermes بالتحقق من التحديثات وتثبيتها.', - branchCommit: (branch, commit) => `${branch} عند ${commit}`, never: 'أبدا', justNow: 'الآن', minAgo: count => `قبل ${count} دقيقة`, diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 7e973fcc1b8..f33335a990e 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -525,7 +525,6 @@ export const en: Translations = { automaticUpdates: 'Automatic updates', automaticUpdatesDesc: 'Hermes checks for updates automatically in the background and lets you know when one is ready.', - branchCommit: (branch, commit) => `Branch ${branch} · Commit ${commit}`, never: 'never', justNow: 'just now', minAgo: count => `${count} min ago`, @@ -2127,6 +2126,15 @@ export const en: Translations = { tryAgain: 'Try again', notAvailableTitle: 'Update not available', unsupportedMessage: 'This version of Hermes can’t update itself from inside the app.', + versionDetailsTitle: 'Version details', + versionDetailsBody: + 'This Hermes installation is managed outside the app. Update it with the same method you used to install it.', + versionDetailsVersion: 'Version', + versionDetailsBaseVersion: 'Release version', + versionDetailsBranch: 'Branch', + versionDetailsCommit: 'Commit', + 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.', latestBody: 'You’re running the latest version.', latestBodyBackend: 'The backend is running the latest version.', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 6ec6c487200..2d16d792ec5 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -626,7 +626,6 @@ export const ja = defineLocale({ justNowSuffix: ' · たった今', automaticUpdates: '自動更新', automaticUpdatesDesc: 'Hermes はバックグラウンドで自動的に更新を確認し、利用可能になったら通知します。', - branchCommit: (branch, commit) => `ブランチ ${branch} · コミット ${commit}`, never: '未確認', justNow: 'たった今', minAgo: count => `${count} 分前`, @@ -1987,6 +1986,15 @@ export const ja = defineLocale({ tryAgain: '再試行', notAvailableTitle: '更新は利用できません', unsupportedMessage: 'このバージョンの Hermes はアプリ内から自分を更新できません。', + versionDetailsTitle: 'バージョンの詳細', + versionDetailsBody: + 'この Hermes インストールはアプリの外部で管理されています。インストール時と同じ方法で更新してください。', + versionDetailsVersion: 'バージョン', + versionDetailsBaseVersion: 'リリースバージョン', + versionDetailsBranch: 'ブランチ', + versionDetailsCommit: 'コミット', + versionDetailsDirty: 'このパッケージは変更のあるソースツリーからビルドされました。', + versionDetailsDirtyUnknown: '変更のあるソースツリーからビルドされました。最後のリリースからの変更数は不明です。', connectionRetry: '接続を確認してもう一度試してください。', latestBody: '最新バージョンを実行しています。', latestBodyBackend: 'バックエンドは最新バージョンを実行しています。', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 56ff50769e9..8039339c24d 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -432,7 +432,6 @@ export interface Translations { justNowSuffix: string automaticUpdates: string automaticUpdatesDesc: string - branchCommit: (branch: string, commit: string) => string never: string justNow: string minAgo: (count: number) => string @@ -1767,6 +1766,14 @@ export interface Translations { tryAgain: string notAvailableTitle: string unsupportedMessage: string + versionDetailsTitle: string + versionDetailsBody: string + versionDetailsVersion: string + versionDetailsBaseVersion: string + versionDetailsBranch: string + versionDetailsCommit: string + versionDetailsDirty: string + versionDetailsDirtyUnknown: string connectionRetry: string latestBody: string latestBodyBackend: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 10f85b9940e..d55d46c2559 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -614,7 +614,6 @@ export const zhHant = defineLocale({ justNowSuffix: ' · 剛剛', automaticUpdates: '自動更新', automaticUpdatesDesc: 'Hermes 會在背景自動檢查更新,並在有可用更新時通知你。', - branchCommit: (branch, commit) => `分支 ${branch} · 提交 ${commit}`, never: '從未', justNow: '剛剛', minAgo: count => `${count} 分鐘前`, @@ -1928,6 +1927,14 @@ export const zhHant = defineLocale({ tryAgain: '重試', notAvailableTitle: '更新不可用', unsupportedMessage: '此版本的 Hermes 無法在應用程式內自行更新。', + versionDetailsTitle: '版本詳細資料', + versionDetailsBody: '此 Hermes 安裝由應用程式外部管理。請使用安裝時採用的方法更新它。', + versionDetailsVersion: '版本', + versionDetailsBaseVersion: '發行版本', + versionDetailsBranch: '分支', + versionDetailsCommit: '提交', + versionDetailsDirty: '此套件從有未提交變更的來源樹建置。', + versionDetailsDirtyUnknown: '從有未提交變更的來源樹建置。自上次發布以來的變更數量未知。', connectionRetry: '請檢查網路連線後重試。', latestBody: '您正在執行最新版本。', latestBodyBackend: '後端正在執行最新版本。', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 1b692d40b1d..fd116f3f247 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -735,7 +735,6 @@ export const zh: Translations = { justNowSuffix: ' · 刚刚', automaticUpdates: '自动更新', automaticUpdatesDesc: 'Hermes 会在后台自动检查更新,并在有可用更新时通知你。', - branchCommit: (branch, commit) => `分支 ${branch} · 提交 ${commit}`, never: '从未', justNow: '刚刚', minAgo: count => `${count} 分钟前`, @@ -2319,6 +2318,14 @@ export const zh: Translations = { tryAgain: '重试', notAvailableTitle: '更新不可用', unsupportedMessage: '此版本的 Hermes 无法在应用内自行更新。', + versionDetailsTitle: '版本详情', + versionDetailsBody: '此 Hermes 安装由应用外部管理。请使用安装时采用的方法更新它。', + versionDetailsVersion: '版本', + versionDetailsBaseVersion: '发布版本', + versionDetailsBranch: '分支', + versionDetailsCommit: '提交', + versionDetailsDirty: '此软件包从有未提交更改的源代码树构建。', + versionDetailsDirtyUnknown: '从有未提交更改的源代码树构建。自上次发布以来的更改数量未知。', connectionRetry: '请检查网络连接后重试。', latestBody: '你正在运行最新版本。', latestBodyBackend: '后端正在运行最新版本。', diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index accbe39d999..fab54d5ba0f 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -1060,7 +1060,6 @@ export interface StatusResponse { gateway_updated_at: string | null hermes_home: string latest_config_version: number - release_date: string version: string } diff --git a/cli.py b/cli.py index af020b9692b..97731a564af 100644 --- a/cli.py +++ b/cli.py @@ -3867,10 +3867,9 @@ def _build_compact_banner() -> str: tiny_line = agent_name if os.environ.get("HERMES_FAST_STARTUP_BANNER") == "1": - from hermes_cli import __release_date__ as _release_date from hermes_cli import __version__ as _version - version_line = f"Hermes Agent v{_version} ({_release_date})" + version_line = f"Hermes Agent v{_version}" else: version_line = format_banner_version_label() diff --git a/hermes_cli/__init__.py b/hermes_cli/__init__.py index a10935a68f0..a863b066345 100644 --- a/hermes_cli/__init__.py +++ b/hermes_cli/__init__.py @@ -16,6 +16,11 @@ import sys __version__ = "0.19.0" __release_date__ = "2026.7.20" +# ``v0.19.0`` will exist for future releases. This is the commit count of the +# transitional CalVer release tag, used only by immutable Nix builds that do +# not carry git history. `scripts/release.py` updates it for each new SemVer +# release tag before the release bump commit is made. +__release_rev_count__ = 16544 def _ensure_utf8(): diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 08e2ceeb1fa..4c99816259e 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -58,7 +58,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]██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/] @@ -344,84 +345,6 @@ def _resolve_repo_dir() -> Optional[Path]: return repo_dir if (repo_dir / ".git").exists() else None -def _git_short_hash(repo_dir: Path, rev: str) -> Optional[str]: - """Resolve a git revision to an 8-character short hash.""" - try: - result = subprocess.run( - ["git", "rev-parse", "--short=8", rev], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=5, - cwd=str(repo_dir), - ) - except Exception: - return None - if result.returncode != 0: - return None - value = (result.stdout or "").strip() - return value or None - - -def get_git_banner_state(repo_dir: Optional[Path] = None) -> Optional[dict]: - """Return upstream/local git hashes for the startup banner. - - For source installs and dev images this runs ``git rev-parse`` against - the active checkout. When no checkout is available — the canonical case - is the published Docker image, which excludes ``.git`` from the build - context — we fall back to the baked-in build SHA (see - ``hermes_cli/build_info.py``) and return it as a frozen - ``upstream == local`` state with ``ahead=0``. A built image is by - definition pinned to one commit, so "ahead" is always zero and the - banner correctly shows ``· upstream `` with no carried-commits - annotation. - """ - repo_dir = repo_dir or _resolve_repo_dir() - if repo_dir is None: - # No git checkout — try the baked build SHA (Docker image path). - try: - from hermes_cli.build_info import get_build_sha - baked = get_build_sha(short=8) - if baked: - return {"upstream": baked, "local": baked, "ahead": 0} - except Exception: - pass - return None - - upstream = _git_short_hash(repo_dir, "origin/main") - local = _git_short_hash(repo_dir, "HEAD") - if not upstream or not local: - # Live-git lookup failed (e.g. shallow clone without origin/main). - # Fall back to the baked build SHA if available. - try: - from hermes_cli.build_info import get_build_sha - baked = get_build_sha(short=8) - if baked: - return {"upstream": baked, "local": baked, "ahead": 0} - except Exception: - pass - return None - - ahead = 0 - try: - result = subprocess.run( - ["git", "rev-list", "--count", "origin/main..HEAD"], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=5, - cwd=str(repo_dir), - ) - if result.returncode == 0: - ahead = int((result.stdout or "0").strip() or "0") - except Exception: - ahead = 0 - - return {"upstream": upstream, "local": local, "ahead": max(ahead, 0)} - - _RELEASE_URL_BASE = "https://github.com/NousResearch/hermes-agent/releases/tag" _latest_release_cache: Optional[tuple] = None # (tag, url) once resolved @@ -472,20 +395,15 @@ def get_latest_release_tag(repo_dir: Optional[Path] = None) -> Optional[tuple]: def format_banner_version_label() -> str: """Return the version label shown in the startup banner title.""" - base = f"Hermes Agent v{VERSION} ({RELEASE_DATE})" - state = get_git_banner_state() - if not state: - return base - - upstream = state["upstream"] - local = state["local"] - ahead = int(state.get("ahead") or 0) - - if ahead <= 0 or upstream == local: - return f"{base} · upstream {upstream}" - - carried_word = "commit" if ahead == 1 else "commits" - return f"{base} · upstream {upstream} · local {local} (+{ahead} carried {carried_word})" + info = get_version_info() + parts = [f"Hermes Agent v{info.derived_version}"] + if info.branch: + parts.append(info.branch) + if info.commit: + parts.append(info.commit[:12]) + if info.dirty: + parts.append("dirty") + return " · ".join(parts) # ========================================================================= diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 2ccfe808d4f..d315040f6b0 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -389,9 +389,9 @@ def _read_openai_version_fast() -> str | None: def _print_fast_version_info() -> None: - from hermes_cli import __release_date__, __version__ + from hermes_cli import __version__ - print(f"Hermes Agent v{__version__} ({__release_date__})") + print(f"Hermes Agent v{__version__}") print(f"Install directory: {PROJECT_ROOT}") print(f"Python: {sys.version.split()[0]}") @@ -4655,8 +4655,16 @@ 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 print(format_banner_version_label()) + 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}") + if version_info.dirty is not None: + print(f"Working tree: {'dirty' if version_info.dirty else 'clean'}") print(f"Install directory: {PROJECT_ROOT}") print(f"Install method: {detect_install_method(PROJECT_ROOT)}") diff --git a/hermes_cli/version_info.py b/hermes_cli/version_info.py new file mode 100644 index 00000000000..0fb96f4f6c7 --- /dev/null +++ b/hermes_cli/version_info.py @@ -0,0 +1,174 @@ +"""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. +""" + +from __future__ import annotations + +import os +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +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["git", "nix", "build", "unknown"] + dirty: bool = False + + +def format_display_version(info: VersionInfo | None = None) -> str: + """Return ``0.x.y`` or ``0.x.y+N`` without exposing unknown distance.""" + info = info or get_version_info() + return info.derived_version + + +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 + + +def _nix_version_info() -> VersionInfo | None: + commit = os.environ.get("HERMES_REVISION") or None + current_count = _parse_nonnegative(os.environ.get("HERMES_REVISION_COUNT")) + release_count = _parse_nonnegative(os.environ.get("HERMES_RELEASE_REV_COUNT")) + if not commit: + return None + distance = ( + max(0, current_count - release_count) + if current_count is not None and release_count is not None + else None + ) + return VersionInfo( + __version__, + _derived_version(__version__, distance, os.environ.get("HERMES_REVISION_DIRTY") == "1"), + distance, + commit, + os.environ.get("HERMES_REVISION_BRANCH") or None, + "nix", + os.environ.get("HERMES_REVISION_DIRTY") == "1", + ) + + +def _git_version_info(repo_dir: Path) -> VersionInfo: + commit = _run_git(repo_dir, "rev-parse", "HEAD") + branch = _run_git(repo_dir, "branch", "--show-current") + if not branch and commit: + branch = commit[:8] + try: + dirty_result = subprocess.run( + ["git", "status", "--porcelain"], + 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 + ) + + +_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 Nix metadata, git, or a baked SHA.""" + global _cached_version_info + if _cached_version_info is not None: + return _cached_version_info + + info = _nix_version_info() + if info is None: + repo_dir = _resolve_repo_dir() + if repo_dir is not None: + info = _git_version_info(repo_dir) + else: + try: + from hermes_cli.build_info import get_build_sha + + commit = get_build_sha(short=0) + except Exception: + commit = None + info = VersionInfo(__version__, __version__, None, commit, None, "build" if commit else "unknown") + + _cached_version_info = info + return info + + +def format_version_details(info: VersionInfo | None = None) -> str: + """Format verbose, support-friendly provenance without pretending certainty.""" + info = info or get_version_info() + values = [f"version {info.derived_version}"] + if info.branch: + values.append(f"branch {info.branch}") + if info.commit: + values.append(f"commit {info.commit}") + values.append(f"source {info.source}") + return " · ".join(values) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 497d740e1c1..5188eace2bf 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -56,7 +56,7 @@ PROJECT_ROOT = Path(__file__).parent.parent.resolve() if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) -from hermes_cli import __version__, __release_date__ +from hermes_cli import __version__ from hermes_cli.config import ( cfg_get, DEFAULT_CONFIG, @@ -3282,7 +3282,6 @@ async def get_status(profile: Optional[str] = None): # ``PUBLIC_API_PATHS`` documents this endpoint as serving. status = { "version": __version__, - "release_date": __release_date__, "config_version": current_ver, "latest_config_version": latest_ver, "can_update_hermes": not _dashboard_local_update_managed_externally(), diff --git a/nix/desktop.nix b/nix/desktop.nix index cb18a4f0491..430e3860ba1 100644 --- a/nix/desktop.nix +++ b/nix/desktop.nix @@ -14,6 +14,11 @@ hermesNpmLib, electron, hermesAgent, + rev ? null, + revCount ? null, + branch ? null, + dirty ? false, + releaseRevCount ? null, ... }: let @@ -26,8 +31,23 @@ let ]; }; - packageJson = builtins.fromJSON (builtins.readFile (npm.src + "/apps/desktop/package.json")); - version = packageJson.version; + # 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. + version = (fromTOML (builtins.readFile ../pyproject.toml)).project.version; + distance = + if revCount != null && releaseRevCount != null then + lib.trivial.max 0 (revCount - releaseRevCount) + else + null; + displayVersion = + if distance != null && distance > 0 then "${version}+${toString distance}" + else if dirty && distance == null then "${version}+?" + else version; + # On a dirty tree, sourceInfo.ref is null (flakes can't determine the ref). + # Show "unknown" rather than null so the desktop doesn't fall back to its + # self-update default branch ("main") when the real branch is unavailable. + branchLabel = if branch != null then branch else "unknown"; electronHeaders = pkgs.fetchurl { url = "https://artifacts.electronjs.org/headers/dist/v${electron.version}/node-v${electron.version}-headers.tar.gz"; @@ -66,6 +86,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 @@ -134,7 +165,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 branchLabel},"baseVersion":"${version}","displayVersion":"${displayVersion}","distance":${builtins.toJSON distance},"dirty":${ + if dirty then "true" else "false" + },"source":"nix","installMethod":"nix"} + EOF cp -n apps/desktop/package.json $out/ runHook postInstall diff --git a/nix/hermes-agent.nix b/nix/hermes-agent.nix index 90cbdbeb69d..4e23bcc3fa8 100644 --- a/nix/hermes-agent.nix +++ b/nix/hermes-agent.nix @@ -34,11 +34,18 @@ # 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, # Overridable parameters extraPythonPackages ? [ ], extraDependencyGroups ? [ ], }: let + 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); nodejs = nodejs_22; mkHermesVenv = extraDependencyGroups: @@ -201,7 +208,17 @@ stdenv.mkDerivation (finalAttrs: { # 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 (rev != null) " \\ + --set HERMES_REVISION ${rev}" + + lib.optionalString (revCount != null && releaseRevCount != null) " \\ + --set HERMES_REVISION_COUNT ${toString revCount} \\ + --set HERMES_RELEASE_REV_COUNT ${toString releaseRevCount}" + + # Always set the branch: on a dirty tree flakes can't determine + # sourceInfo.ref, so fall back to "unknown" rather than letting + # the runtime pick up its self-update default ("main"). + " \\ + --set HERMES_REVISION_BRANCH ${if branch != null then branch else "unknown"}" + + lib.optionalString dirty " \\\n --set HERMES_REVISION_DIRTY 1" }${ lib.optionalString ( extraPythonPackages != [ ] @@ -246,6 +263,7 @@ stdenv.mkDerivation (finalAttrs: { hermesDesktop = callPackage ./desktop.nix { inherit hermesNpmLib electron; hermesAgent = finalAttrs.finalPackage; + inherit rev revCount branch dirty releaseRevCount; }; devShellHook = '' diff --git a/nix/packages.nix b/nix/packages.nix index 0dad8e3673a..34621ddab70 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -9,12 +9,17 @@ ... }: let + dirtyRevision = inputs.self.dirtyRev 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; + # dirtyRev is the actual base commit with a ``-dirty`` suffix. Keep + # its SHA for provenance but carry the separate dirty bit so update + # comparison never mistakes a local tree for a clean upstream commit. + rev = inputs.self.rev or (if dirtyRevision != null then builtins.substring 0 40 dirtyRevision else null); + revCount = sourceInfo.revCount or null; + branch = sourceInfo.ref or null; + dirty = dirtyRevision != null; }; # All platform-portable optional integrations pre-built. diff --git a/scripts/release.py b/scripts/release.py index 0df1a1b70d5..1fab8393875 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Hermes Agent Release Script -Generates changelogs and creates GitHub releases with CalVer tags. +Generates changelogs and creates GitHub releases with SemVer tags. Usage: # Preview changelog (dry run) @@ -16,7 +16,7 @@ Usage: # First release (no previous tag) python scripts/release.py --bump minor --publish --first-release - # Override CalVer date (e.g. for a belated release) + # Override release-date metadata (e.g. for a belated release) python scripts/release.py --bump minor --publish --date 2026.3.15 """ @@ -2116,26 +2116,30 @@ def git_result(*args, cwd=None): ) +_SEMVER_TAG_RE = re.compile(r"v(?:0|[1-9]\d*)\.\d+\.\d+$") +_LEGACY_CALVER_TAG_RE = re.compile(r"v20\d{2}\.\d+\.\d+(?:\.\d+)?$") + + +def release_tag_for_version(semver: str) -> str: + """Return the canonical Git tag for a Hermes package version.""" + return f"v{semver}" + + def get_last_tag(): - """Get the most recent CalVer tag.""" - tags = git("tag", "--list", "v20*", "--sort=-v:refname") + """Get the latest SemVer tag, falling back to legacy CalVer history.""" + tags = git("tag", "--list", "v[0-9]*", "--sort=-v:refname") if tags: - return tags.split("\n")[0] + tag_list = tags.split("\n") + for tag in tag_list: + if _SEMVER_TAG_RE.fullmatch(tag) and not _LEGACY_CALVER_TAG_RE.fullmatch(tag): + return tag + + legacy_tags = git("tag", "--list", "v20*", "--sort=-v:refname") + if legacy_tags: + return legacy_tags.split("\n")[0] return None -def next_available_tag(base_tag: str) -> tuple[str, str]: - """Return a tag/calver pair, suffixing same-day releases when needed.""" - if not git("tag", "--list", base_tag): - return base_tag, base_tag.removeprefix("v") - - suffix = 2 - while git("tag", "--list", f"{base_tag}.{suffix}"): - suffix += 1 - tag_name = f"{base_tag}.{suffix}" - return tag_name, tag_name.removeprefix("v") - - def get_current_version(): """Read current semver from __init__.py.""" content = VERSION_FILE.read_text(encoding="utf-8") @@ -2179,6 +2183,15 @@ def update_version_files(semver: str, calver_date: str): f'__release_date__ = "{calver_date}"', content, ) + # This function runs before the release-bump commit is created. Record the + # count that commit will have so Nix store builds can derive ``+N`` without + # a .git directory. The corresponding SemVer tag is made at that commit. + parent_count = int(git("rev-list", "--count", "HEAD") or "0") + content = re.sub( + r'__release_rev_count__\s*=\s*\d+', + f'__release_rev_count__ = {parent_count + 1}', + content, + ) VERSION_FILE.write_text(content, encoding="utf-8") # Update pyproject.toml @@ -2467,31 +2480,27 @@ def main(): parser.add_argument("--publish", action="store_true", help="Actually create the tag and GitHub release (otherwise dry run)") parser.add_argument("--date", type=str, - help="Override CalVer date (format: YYYY.M.D)") + help="Override release date metadata (format: YYYY.M.D)") parser.add_argument("--first-release", action="store_true", help="Mark as first release (no previous tag expected)") parser.add_argument("--output", type=str, help="Write changelog to file instead of stdout") args = parser.parse_args() - # Determine CalVer date + # Determine release-date metadata. if args.date: calver_date = args.date else: now = datetime.now() calver_date = f"{now.year}.{now.month}.{now.day}" - base_tag = f"v{calver_date}" - tag_name, calver_date = next_available_tag(base_tag) - if tag_name != base_tag: - print(f"Note: Tag {base_tag} already exists, using {tag_name}") - # Determine semver current_version = get_current_version() if args.bump: new_version = bump_version(current_version, args.bump) else: new_version = current_version + tag_name = release_tag_for_version(new_version) # Get previous tag prev_tag = get_last_tag() @@ -2511,7 +2520,7 @@ def main(): print(f"{'='*60}") print(" Hermes Agent Release Preview") print(f"{'='*60}") - print(f" CalVer tag: {tag_name}") + print(f" Release tag: {tag_name}") print(f" SemVer: v{current_version} → v{new_version}") print(f" Previous tag: {prev_tag or '(none — first release)'}") print(f" Commits: {len(commits)}") diff --git a/tests/hermes_cli/test_banner_git_state.py b/tests/hermes_cli/test_banner_git_state.py index 17e9aea7f71..caab373d065 100644 --- a/tests/hermes_cli/test_banner_git_state.py +++ b/tests/hermes_cli/test_banner_git_state.py @@ -1,116 +1,46 @@ from unittest.mock import MagicMock, patch +from hermes_cli.version_info import VersionInfo + def test_format_banner_version_label_without_git_state(): from hermes_cli import banner - with patch.object(banner, "get_git_banner_state", return_value=None): + with patch.object( + banner, + "get_version_info", + return_value=VersionInfo(banner.VERSION, banner.VERSION, None, None, None, "unknown"), + ): value = banner.format_banner_version_label() - assert value == f"Hermes Agent v{banner.VERSION} ({banner.RELEASE_DATE})" + assert value == f"Hermes Agent v{banner.VERSION}" -def test_format_banner_version_label_on_upstream_main(): +def test_format_banner_version_label_includes_derived_version_and_provenance(): 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("0.19.0", "0.19.0+3", 3, "b" * 40, "feature/version", "git"), ): value = banner.format_banner_version_label() - assert value.endswith("· upstream b2f477a3") - assert "local" not in value + assert "v0.19.0+3" in value + assert "feature/version" in value + assert "b" * 12 in value -def test_format_banner_version_label_with_carried_commits(): +def test_format_banner_version_label_omits_zero_suffix(): from hermes_cli import banner with patch.object( banner, - "get_git_banner_state", - return_value={"upstream": "b2f477a3", "local": "af8aad31", "ahead": 3}, + "get_version_info", + return_value=VersionInfo("0.19.0", "0.19.0", 0, "a" * 40, "main", "git"), ): value = banner.format_banner_version_label() - assert "upstream b2f477a3" in value - assert "local af8aad31" in value - assert "+3 carried commits" in value - - -def test_get_git_banner_state_reads_origin_and_head(tmp_path): - from hermes_cli import banner - - repo_dir = tmp_path / "repo" - (repo_dir / ".git").mkdir(parents=True) - - 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} - - -def test_get_git_banner_state_falls_back_to_build_sha_when_no_repo(): - """Docker image case: no .git checkout — baked build SHA fills the gap. - - ``_resolve_repo_dir`` returns None when neither the running code's - parent nor ``$HERMES_HOME/hermes-agent/`` is a git repo (the canonical - case inside the published container, where .git is dockerignored). - The banner should still report the build SHA so support bug reports - can identify the running commit. - """ - from hermes_cli import banner - - with patch.object(banner, "_resolve_repo_dir", return_value=None), \ - patch("hermes_cli.build_info.get_build_sha", return_value="abcdef12"): - state = banner.get_git_banner_state() - - assert state == {"upstream": "abcdef12", "local": "abcdef12", "ahead": 0} - - -def test_get_git_banner_state_returns_none_when_no_repo_and_no_build_sha(): - """Pip-installed wheel with neither git checkout nor baked SHA → None. - - Banner correctly omits the upstream/local suffix in this case. - """ - from hermes_cli import banner - - with patch.object(banner, "_resolve_repo_dir", return_value=None), \ - patch("hermes_cli.build_info.get_build_sha", return_value=None): - state = banner.get_git_banner_state() - - assert state is None - - -def test_get_git_banner_state_falls_back_when_live_git_returns_nothing(tmp_path): - """Shallow clone without origin/main → still surface build SHA if baked. - - Some install paths (e.g. ``git clone --depth 1`` without a remote) have - a ``.git`` directory but ``git rev-parse origin/main`` fails. When that - happens AND a baked SHA exists, return the baked one instead of None. - """ - from hermes_cli import banner - - repo_dir = tmp_path / "repo" - (repo_dir / ".git").mkdir(parents=True) - - # All git invocations fail (returncode=1, empty stdout). - failed = MagicMock(returncode=1, stdout="") - with patch("hermes_cli.banner.subprocess.run", return_value=failed), \ - patch("hermes_cli.build_info.get_build_sha", return_value="cafef00d"): - state = banner.get_git_banner_state(repo_dir) - - assert state == {"upstream": "cafef00d", "local": "cafef00d", "ahead": 0} + assert "v0.19.0" in value + assert "+0" not in value + assert "carried" not in value \ No newline at end of file diff --git a/tests/hermes_cli/test_dashboard_auth_status_endpoint.py b/tests/hermes_cli/test_dashboard_auth_status_endpoint.py index 0d00b20ed23..89589faaa94 100644 --- a/tests/hermes_cli/test_dashboard_auth_status_endpoint.py +++ b/tests/hermes_cli/test_dashboard_auth_status_endpoint.py @@ -100,7 +100,7 @@ def test_status_preserves_existing_fields(loopback_client): r = loopback_client.get("/api/status") body = r.json() expected_keys = { - "version", "release_date", "hermes_home", "config_path", "env_path", + "version", "hermes_home", "config_path", "env_path", "config_version", "latest_config_version", "gateway_running", "gateway_pid", "gateway_health_url", "gateway_state", "gateway_platforms", "gateway_exit_reason", "gateway_updated_at", diff --git a/tests/hermes_cli/test_version_info.py b/tests/hermes_cli/test_version_info.py new file mode 100644 index 00000000000..e09d9dc3ab6 --- /dev/null +++ b/tests/hermes_cli/test_version_info.py @@ -0,0 +1,109 @@ +from unittest.mock import MagicMock, patch + +from hermes_cli.version_info import ( + VersionInfo, + _derived_version, + _reset_version_info_cache, + format_display_version, + get_version_info, +) + + +def setup_function(): + _reset_version_info_cache() + + +def test_format_display_version_omits_zero_distance(): + assert format_display_version(VersionInfo("0.20.0", "0.20.0", 0, None, None, "git")) == "0.20.0" + assert format_display_version(VersionInfo("0.20.0", "0.20.0+3", 3, None, None, "git")) == "0.20.0+3" + + +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_get_version_info_uses_nix_revision_metadata(monkeypatch): + monkeypatch.setenv("HERMES_REVISION", "a" * 40) + monkeypatch.setenv("HERMES_REVISION_COUNT", "123") + monkeypatch.setenv("HERMES_RELEASE_REV_COUNT", "120") + monkeypatch.setenv("HERMES_REVISION_BRANCH", "feature/version") + + info = get_version_info() + + assert info == VersionInfo("0.19.0", "0.19.0+3", 3, "a" * 40, "feature/version", "nix") + + +def test_get_version_info_shows_plus_question_for_dirty_nix_without_counts(monkeypatch): + monkeypatch.setenv("HERMES_REVISION", "a" * 40) + monkeypatch.setenv("HERMES_REVISION_DIRTY", "1") + monkeypatch.delenv("HERMES_REVISION_COUNT", raising=False) + monkeypatch.delenv("HERMES_RELEASE_REV_COUNT", raising=False) + + info = get_version_info() + + assert info == VersionInfo("0.19.0", "0.19.0+?", None, "a" * 40, None, "nix", True) + + +def test_get_version_info_counts_commits_after_semver_tag(tmp_path, monkeypatch): + repo = tmp_path / "repo" + (repo / ".git").mkdir(parents=True) + monkeypatch.delenv("HERMES_REVISION", raising=False) + monkeypatch.setattr("hermes_cli.version_info._resolve_repo_dir", lambda: repo) + + def run(command, **_kwargs): + output = { + ("git", "rev-parse", "HEAD"): "b" * 40, + ("git", "branch", "--show-current"): "feature/version", + ("git", "status", "--porcelain"): "", + ("git", "rev-list", "--count", "v0.19.0..HEAD"): "3", + }[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("0.19.0", "0.19.0+3", 3, "b" * 40, "feature/version", "git") + + +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_repo_dir", lambda: repo) + + calls = [] + + def run(command, **_kwargs): + calls.append(tuple(command)) + if tuple(command) == ("git", "rev-list", "--count", "v0.19.0..HEAD"): + return MagicMock(returncode=1, stdout="") + output = { + ("git", "rev-parse", "HEAD"): "c" * 40, + ("git", "branch", "--show-current"): "", + ("git", "status", "--porcelain"): " M hermes_cli/version_info.py", + ("git", "rev-list", "--count", "v2026.7.20..HEAD"): "2", + }[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 == "0.19.0+2" + assert info.branch == "cccccccc" + assert info.dirty is True + assert ("git", "rev-list", "--count", "v2026.7.20..HEAD") in calls + + +def test_get_version_info_keeps_base_version_when_provenance_is_unavailable(monkeypatch): + monkeypatch.setattr("hermes_cli.version_info._resolve_repo_dir", lambda: None) + monkeypatch.setattr("hermes_cli.build_info.get_build_sha", lambda short=0: "deadbeef" if short == 0 else "deadbeef") + + info = get_version_info() + + assert info.base_version == "0.19.0" + assert info.derived_version == "0.19.0" + assert info.distance is None + assert info.commit == "deadbeef" + assert info.source == "build" diff --git a/tests/scripts/test_release_tags.py b/tests/scripts/test_release_tags.py new file mode 100644 index 00000000000..a686f17ecf9 --- /dev/null +++ b/tests/scripts/test_release_tags.py @@ -0,0 +1,31 @@ +"""Release-tag policy: new releases use semver, old CalVer tags remain readable.""" + +import importlib.util +from pathlib import Path + + +_RELEASE_PATH = Path(__file__).resolve().parents[2] / "scripts" / "release.py" +_SPEC = importlib.util.spec_from_file_location("hermes_release", _RELEASE_PATH) +assert _SPEC and _SPEC.loader +release = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(release) + + +def test_release_tag_uses_the_semver_version(): + assert release.release_tag_for_version("0.20.0") == "v0.20.0" + + +def test_last_tag_prefers_semver_over_newer_looking_legacy_calver(monkeypatch): + monkeypatch.setattr( + release, + "git", + lambda *_args: "v2026.7.20\nv0.20.0\nv0.19.0", + ) + + assert release.get_last_tag() == "v0.20.0" + + +def test_last_tag_falls_back_to_legacy_calver_history(monkeypatch): + monkeypatch.setattr(release, "git", lambda *_args: "v2026.7.20\nv2026.7.7") + + assert release.get_last_tag() == "v2026.7.20" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 1619181086e..36c28956db8 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -4459,7 +4459,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), @@ -4472,10 +4477,16 @@ 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_dirty"] = version_info.dirty except Exception: pass if agent is not None and not (session or {}).get("_compute_host_active"): diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index 18fdf3332c7..39aea3c670f 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -378,7 +378,9 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) { {t.brand.name} {info.version ? ` v${info.version}` : ''} - {info.release_date ? ` (${info.release_date})` : ''} + {info.version_branch ? ` · ${info.version_branch}` : ''} + {info.version_commit ? ` · ${info.version_commit.slice(0, 8)}` : ''} + {info.version_dirty ? ' · dirty' : ''} ) : ( diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 49c4cef4189..5b325e50653 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -171,7 +171,6 @@ export interface SessionInfo { profile_name?: string project?: null | ProjectInfo reasoning_effort?: string - release_date?: string service_tier?: string skills: Record system_prompt?: string @@ -180,6 +179,12 @@ export interface SessionInfo { update_command?: string usage?: Usage version?: string + version_base?: string + version_branch?: string + version_commit?: string + version_distance?: number | null + version_dirty?: boolean + version_source?: 'build' | 'docker' | 'git' | 'nix' | 'unknown' } export interface Usage { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 06a919826fa..e21ca9bccff 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1825,7 +1825,6 @@ export interface StatusResponse { gateway_updated_at: string | null; hermes_home: string; latest_config_version: number; - release_date: string; version: string; } diff --git a/website/docs/getting-started/updating.md b/website/docs/getting-started/updating.md index 996dba189f3..67ad4a9c4b6 100644 --- a/website/docs/getting-started/updating.md +++ b/website/docs/getting-started/updating.md @@ -206,7 +206,7 @@ uv pip install -e ".[all]" hermes gateway restart ``` -To roll back to a specific release tag (substitute your previous tag — e.g. a recent release like `v2026.5.16`, or any earlier tag from `git tag --sort=-version:refname`): +To roll back to a specific release tag (substitute your previous tag — e.g. a recent release like `v0.19.0`, or any earlier tag from `git tag --sort=-version:refname`): ```bash git checkout vX.Y.Z