mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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.
246 lines
8.8 KiB
JavaScript
246 lines
8.8 KiB
JavaScript
/**
|
|
* Writes apps/desktop/build/install-stamp.json with the git ref the desktop
|
|
* .exe should pin to at first-launch bootstrap time. This file ships inside
|
|
* the packaged app via electron-builder's extraResources entry and is read
|
|
* by electron/main.ts to drive the install.ps1 stage bootstrap flow.
|
|
*
|
|
* Schema (subject to bump via STAMP_SCHEMA_VERSION):
|
|
* {
|
|
* "schemaVersion": 2,
|
|
* "commit": "<40-char SHA>",
|
|
* "branch": "<branch name>",
|
|
* "builtAt": "<ISO 8601 UTC timestamp>",
|
|
* "dirty": true|false,
|
|
* "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:
|
|
* 1. CI env vars ($GITHUB_SHA / $GITHUB_REF_NAME) -- avoid edge cases with
|
|
* shallow clones, detached HEADs, etc. in CI.
|
|
* 2. Local `git rev-parse` against the parent repo (../..).
|
|
* 3. Fallback stamp for local/personal builds from non-git source trees
|
|
* (ZIP extract, interrupted clone with no HEAD, etc.).
|
|
*
|
|
* Dev / out-of-repo builds without git produce an explicit fallback stamp
|
|
* rather than aborting the whole build. Bootstrap treats the all-zero
|
|
* commit as unpinned and follows the branch instead of fetching a fake SHA.
|
|
*/
|
|
|
|
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 = 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"
|
|
export const FALLBACK_BRANCH = "main"
|
|
|
|
const DESKTOP_ROOT = resolve(import.meta.dirname, "..")
|
|
const REPO_ROOT = resolve(DESKTOP_ROOT, "..", "..")
|
|
const OUT_DIR = join(DESKTOP_ROOT, "build")
|
|
const OUT_FILE = join(OUT_DIR, "install-stamp.json")
|
|
|
|
function tryExec(cmd, opts) {
|
|
try {
|
|
return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], ...opts }).trim()
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function fromCI(env = process.env) {
|
|
const sha = env.GITHUB_SHA
|
|
if (!sha) return null
|
|
const branch = env.GITHUB_REF_NAME || env.GITHUB_HEAD_REF || null
|
|
return {
|
|
commit: sha,
|
|
branch: branch,
|
|
dirty: false, // CI builds from a checkout-of-ref by definition
|
|
source: "ci"
|
|
}
|
|
}
|
|
|
|
export function fromLocalGit(repoRoot = REPO_ROOT, execFn = tryExec) {
|
|
const sha = execFn("git rev-parse HEAD", { cwd: repoRoot })
|
|
if (!sha) return null
|
|
const branch = execFn("git rev-parse --abbrev-ref HEAD", { cwd: repoRoot })
|
|
// `git status --porcelain -uno` is empty iff tracked files match HEAD.
|
|
// We exclude untracked files (-uno) intentionally: a developer who's
|
|
// checked out an installer scratch dir alongside the repo shouldn't
|
|
// poison every local build with a [DIRTY] stamp. We DO care about
|
|
// tracked-but-modified files because those mean the .exe content
|
|
// differs from the commit being pinned.
|
|
const status = execFn("git status --porcelain -uno", { cwd: repoRoot })
|
|
const dirty = status !== null && status.length > 0
|
|
return {
|
|
commit: sha,
|
|
branch: branch === "HEAD" ? null : branch, // detached HEAD -> null
|
|
dirty: dirty,
|
|
source: "local"
|
|
}
|
|
}
|
|
|
|
export function fromFallback(branch = FALLBACK_BRANCH) {
|
|
// Non-git builds (ZIP download, bootstrap installer without a resolvable
|
|
// HEAD) cannot determine a real commit. Use a placeholder so local /
|
|
// personal builds can still complete. The desktop bootstrap treats the
|
|
// all-zero commit as "unknown" and falls back to an unpinned branch
|
|
// bootstrap instead of trying to fetch a non-existent GitHub commit.
|
|
return {
|
|
commit: FALLBACK_COMMIT,
|
|
branch: branch || FALLBACK_BRANCH,
|
|
dirty: false,
|
|
source: "fallback"
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve the install stamp without writing it. Pure enough for unit tests:
|
|
* inject env / execFn / repoRoot to simulate CI, local git, or no-git trees.
|
|
*/
|
|
export function resolveStamp({
|
|
env = process.env,
|
|
repoRoot = REPO_ROOT,
|
|
execFn = tryExec,
|
|
fallbackBranch = FALLBACK_BRANCH
|
|
} = {}) {
|
|
return fromCI(env) || fromLocalGit(repoRoot, execFn) || fromFallback(fallbackBranch)
|
|
}
|
|
|
|
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 = deriveVersionMetadata(resolveStamp())
|
|
if (!stamp || !stamp.commit) {
|
|
// Should not happen — fromFallback() always provides a commit.
|
|
console.error(
|
|
"[write-build-stamp] ERROR: could not determine git commit.\n" +
|
|
" - $GITHUB_SHA not set\n" +
|
|
" - `git rev-parse HEAD` failed at " +
|
|
REPO_ROOT +
|
|
"\n" +
|
|
"Packaged builds require a git ref to pin first-launch install.ps1\n" +
|
|
"against. Run from a git checkout or set $GITHUB_SHA explicitly."
|
|
)
|
|
process.exit(1)
|
|
}
|
|
|
|
if (isFallbackCommit(stamp.commit)) {
|
|
console.warn(
|
|
"[write-build-stamp] WARNING: no git commit found (non-git checkout?).\n" +
|
|
" Using placeholder commit — the packaged app will fall back to the\n" +
|
|
" default branch for first-launch bootstrap. For production builds,\n" +
|
|
" run from a git checkout or set $GITHUB_SHA."
|
|
)
|
|
}
|
|
|
|
if (stamp.dirty) {
|
|
console.warn(
|
|
"[write-build-stamp] WARNING: working tree is dirty.\n" +
|
|
" Pinning to " +
|
|
stamp.commit.slice(0, 12) +
|
|
" but the packaged code may differ from that commit.\n" +
|
|
" Commit your changes before publishing this build."
|
|
)
|
|
}
|
|
|
|
const payload = {
|
|
schemaVersion: STAMP_SCHEMA_VERSION,
|
|
commit: stamp.commit,
|
|
branch: stamp.branch,
|
|
builtAt: new Date().toISOString(),
|
|
dirty: stamp.dirty,
|
|
source: stamp.source,
|
|
baseVersion: stamp.baseVersion ?? null,
|
|
displayVersion: stamp.displayVersion ?? null,
|
|
distance: stamp.distance ?? null
|
|
}
|
|
|
|
mkdirSync(OUT_DIR, { recursive: true })
|
|
writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8")
|
|
console.log(
|
|
"[write-build-stamp] wrote " +
|
|
relative(REPO_ROOT, OUT_FILE) +
|
|
" -> " +
|
|
stamp.commit.slice(0, 12) +
|
|
(stamp.branch ? " (" + stamp.branch + ")" : "") +
|
|
(stamp.dirty ? " [DIRTY]" : "") +
|
|
(stamp.source === "fallback" ? " [FALLBACK]" : "")
|
|
)
|
|
}
|
|
|
|
if (isMain(import.meta.url)) {
|
|
main()
|
|
}
|