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