hermes-agent/apps/desktop/scripts/write-build-stamp.mjs
ethernet 1a4c4eca0d 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.
2026-07-28 17:22:59 -04:00

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