247 lines
8.8 KiB
JavaScript
247 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()
|
|
}
|