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.
This commit is contained in:
ethernet 2026-07-24 16:14:56 -04:00
parent ad6df5eb95
commit 1a4c4eca0d
35 changed files with 827 additions and 293 deletions

View file

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

View file

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

View file

@ -6,12 +6,15 @@
*
* Schema (subject to bump via STAMP_SCHEMA_VERSION):
* {
* "schemaVersion": 1,
* "schemaVersion": 2,
* "commit": "<40-char SHA>",
* "branch": "<branch name>",
* "builtAt": "<ISO 8601 UTC timestamp>",
* "dirty": true|false,
* "source": "ci" | "local" | "fallback"
* "source": "ci" | "local" | "fallback",
* "baseVersion": "<SemVer release version, e.g. 0.19.0>" | null,
* "displayVersion":"<baseVersion, or baseVersion+distance>" | null,
* "distance": <commits since baseVersion's release tag> | 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 })

View file

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

View file

@ -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() {
</div>
</div>
{version && <VersionDetails version={version} />}
<ListRow
description={a.automaticUpdatesDesc}
hint={a.branchCommit(status?.branch ?? 'unknown', status?.currentSha?.slice(0, 7) ?? 'unknown')}
title={a.automaticUpdates}
/>

View file

@ -219,7 +219,8 @@ export function useStatusbarItems({
const clientVersionItem = useMemo<StatusbarItem>(() => {
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,

View file

@ -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() {
<ErrorView message={apply.message} onDismiss={() => handleClose(false)} onRetry={handleInstall} />
)}
{phase === 'idle' && (
{phase === 'idle' && !isBackend && desktopVersion && status?.supported === false ? (
<ManagedInstallDetailsView onDone={() => handleClose(false)} version={desktopVersion} />
) : phase === 'idle' ? (
<IdleView
behind={behind}
checking={checking}
@ -131,13 +136,33 @@ export function UpdatesOverlay() {
status={status}
target={target}
updateAvailable={updateAvailable}
version={desktopVersion}
/>
)}
) : null}
</DialogContent>
</Dialog>
)
}
function ManagedInstallDetailsView({ onDone, version }: { onDone: () => void; version: DesktopVersionInfo }) {
const { t } = useI18n()
const u = t.updates
return (
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
<div className="flex flex-col items-center gap-3 text-center">
<BrandMark className="size-16" />
<DialogTitle className="text-center text-xl">{u.versionDetailsTitle}</DialogTitle>
<DialogDescription className="text-center text-sm">{u.versionDetailsBody}</DialogDescription>
</div>
<VersionDetails version={version} />
<Button className="font-medium" onClick={onDone} type="button" variant="text">
{u.done}
</Button>
</div>
)
}
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 ? <VersionDetails version={version} /> : null
if (!status.supported) {
return (
<CenteredStatus
body={status.message ?? u.unsupportedMessage}
icon={<AlertCircle className="size-6 text-muted-foreground" />}
title={u.notAvailableTitle}
/>
<div className="grid gap-4 px-6 pb-6 pt-7 pr-8">
<CenteredStatus
body={status.message ?? u.unsupportedMessage}
icon={<AlertCircle className="size-6 text-muted-foreground" />}
title={u.notAvailableTitle}
/>
{details}
</div>
)
}
@ -212,11 +244,14 @@ function IdleView({
if (!updateAvailable) {
return (
<CenteredStatus
body={target === 'backend' ? u.latestBodyBackend : u.latestBody}
icon={<BrandMark className="size-12" />}
title={u.allSetTitle}
/>
<div className="grid gap-4 px-6 pb-6 pt-7 pr-8">
<CenteredStatus
body={target === 'backend' ? u.latestBodyBackend : u.latestBody}
icon={<BrandMark className="size-12" />}
title={u.allSetTitle}
/>
{details}
</div>
)
}
@ -232,6 +267,7 @@ function IdleView({
return (
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
{details}
<div className="flex flex-col items-center gap-3 text-center">
<BrandMark className="size-16" />

View file

@ -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 (
<dl className="grid gap-2 rounded-lg border border-border/70 bg-muted/20 px-3 py-3 text-sm">
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">{u.versionDetailsVersion}</dt>
<dd>v{version.appVersion}</dd>
</div>
{version.baseVersion && (
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">{u.versionDetailsBaseVersion}</dt>
<dd>{version.baseVersion}</dd>
</div>
)}
{version.branch && (
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">{u.versionDetailsBranch}</dt>
<dd className="break-all text-right">{version.branch}</dd>
</div>
)}
{version.commit && (
<div className="flex justify-between gap-4">
<dt className="text-muted-foreground">{u.versionDetailsCommit}</dt>
<ExternalLink
className="break-all font-mono text-xs"
href={`https://github.com/NousResearch/hermes-agent/commit/${version.commit}`}
>
{version.commit.slice(0, 14)}
</ExternalLink>
</div>
)}
{version.dirty && (
<div className="text-warning">{unknownDistance ? u.versionDetailsDirtyUnknown : u.versionDetailsDirty}</div>
)}
</dl>
)
}

View file

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

View file

@ -598,7 +598,6 @@ export const ar = defineLocale({
justNowSuffix: 'الآن',
automaticUpdates: 'التحديثات التلقائية',
automaticUpdatesDesc: 'اسمح لـ Hermes بالتحقق من التحديثات وتثبيتها.',
branchCommit: (branch, commit) => `${branch} عند ${commit}`,
never: 'أبدا',
justNow: 'الآن',
minAgo: count => `قبل ${count} دقيقة`,

View file

@ -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 cant 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: 'Youre running the latest version.',
latestBodyBackend: 'The backend is running the latest version.',

View file

@ -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: 'バックエンドは最新バージョンを実行しています。',

View file

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

View file

@ -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: '後端正在執行最新版本。',

View file

@ -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: '后端正在运行最新版本。',

View file

@ -1060,7 +1060,6 @@ export interface StatusResponse {
gateway_updated_at: string | null
hermes_home: string
latest_config_version: number
release_date: string
version: string
}

3
cli.py
View file

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

View file

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

View file

@ -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 <sha>`` with no carried-commits
annotation.
"""
repo_dir = repo_dir or _resolve_repo_dir()
if repo_dir is None:
# No git checkout — try the baked build SHA (Docker image path).
try:
from hermes_cli.build_info import get_build_sha
baked = get_build_sha(short=8)
if baked:
return {"upstream": baked, "local": baked, "ahead": 0}
except Exception:
pass
return None
upstream = _git_short_hash(repo_dir, "origin/main")
local = _git_short_hash(repo_dir, "HEAD")
if not upstream or not local:
# Live-git lookup failed (e.g. shallow clone without origin/main).
# Fall back to the baked build SHA if available.
try:
from hermes_cli.build_info import get_build_sha
baked = get_build_sha(short=8)
if baked:
return {"upstream": baked, "local": baked, "ahead": 0}
except Exception:
pass
return None
ahead = 0
try:
result = subprocess.run(
["git", "rev-list", "--count", "origin/main..HEAD"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=5,
cwd=str(repo_dir),
)
if result.returncode == 0:
ahead = int((result.stdout or "0").strip() or "0")
except Exception:
ahead = 0
return {"upstream": upstream, "local": local, "ahead": max(ahead, 0)}
_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)
# =========================================================================

View file

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

174
hermes_cli/version_info.py Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -378,7 +378,9 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) {
<Text bold color={t.color.primary}>
{t.brand.name}
{info.version ? ` v${info.version}` : ''}
{info.release_date ? ` (${info.release_date})` : ''}
{info.version_branch ? ` · ${info.version_branch}` : ''}
{info.version_commit ? ` · ${info.version_commit.slice(0, 8)}` : ''}
{info.version_dirty ? ' · dirty' : ''}
</Text>
</Box>
) : (

View file

@ -171,7 +171,6 @@ export interface SessionInfo {
profile_name?: string
project?: null | ProjectInfo
reasoning_effort?: string
release_date?: string
service_tier?: string
skills: Record<string, string[]>
system_prompt?: string
@ -180,6 +179,12 @@ export interface SessionInfo {
update_command?: string
usage?: Usage
version?: string
version_base?: string
version_branch?: string
version_commit?: string
version_distance?: number | null
version_dirty?: boolean
version_source?: 'build' | 'docker' | 'git' | 'nix' | 'unknown'
}
export interface Usage {

View file

@ -1825,7 +1825,6 @@ export interface StatusResponse {
gateway_updated_at: string | null;
hermes_home: string;
latest_config_version: number;
release_date: string;
version: string;
}

View file

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