mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
ad6df5eb95
commit
1a4c4eca0d
35 changed files with 827 additions and 293 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
||||
|
|
|
|||
50
apps/desktop/src/components/version-details.tsx
Normal file
50
apps/desktop/src/components/version-details.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
7
apps/desktop/src/global.d.ts
vendored
7
apps/desktop/src/global.d.ts
vendored
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -598,7 +598,6 @@ export const ar = defineLocale({
|
|||
justNowSuffix: 'الآن',
|
||||
automaticUpdates: 'التحديثات التلقائية',
|
||||
automaticUpdatesDesc: 'اسمح لـ Hermes بالتحقق من التحديثات وتثبيتها.',
|
||||
branchCommit: (branch, commit) => `${branch} عند ${commit}`,
|
||||
never: 'أبدا',
|
||||
justNow: 'الآن',
|
||||
minAgo: count => `قبل ${count} دقيقة`,
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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: 'バックエンドは最新バージョンを実行しています。',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: '後端正在執行最新版本。',
|
||||
|
|
|
|||
|
|
@ -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: '后端正在运行最新版本。',
|
||||
|
|
|
|||
|
|
@ -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
3
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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -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
174
hermes_cli/version_info.py
Normal 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)
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = ''
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
109
tests/hermes_cli/test_version_info.py
Normal file
109
tests/hermes_cli/test_version_info.py
Normal 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"
|
||||
31
tests/scripts/test_release_tags.py
Normal file
31
tests/scripts/test_release_tags.py
Normal 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"
|
||||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1825,7 +1825,6 @@ export interface StatusResponse {
|
|||
gateway_updated_at: string | null;
|
||||
hermes_home: string;
|
||||
latest_config_version: number;
|
||||
release_date: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue