mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
fix(desktop): stop hard-failing pack on non-git checkouts + fix ZIP-path autocrlf (supersedes #67643) (#67730)
* fix(desktop): allow write-build-stamp from non-git checkouts Stop hard-failing npm pack when neither GITHUB_SHA nor git HEAD is available (ZIP installs / broken .git). Emit an explicit fallback stamp instead so local Windows desktop builds can finish (#50823). * fix(desktop): treat fallback stamps as unpinned; harden Windows install Keep all-zero fallback commits out of -Commit/--commit pins and fetch install.ps1 by branch instead. After bootstrap, pin the marker to the checkout HEAD so isBootstrapComplete accepts it. On Windows, force ZIP checkout, seed GITHUB_SHA (ASCII-only install.ps1), and avoid the pack stamp failure. * fix(install): pin core.autocrlf=false before ZIP-path checkout (#50823 review) The ZIP-fallback path added in #67643 runs `git checkout -f FETCH_HEAD` before core.autocrlf gets pinned (which only happened later, on the shared clone-path config). On Git for Windows -- where core.autocrlf defaults to true -- that renormalizes the repo's LF text files to CRLF in the working tree during checkout, leaving the freshly-created managed checkout dirty versus HEAD and aborting the next `hermes update`. That is the exact "dirty tree the user never touched" failure the surrounding code already guards against (install.ps1:1461-1469, 1750-1753). Move the `config core.autocrlf false` pin to run immediately after `git init`, before the fetch/checkout. The later idempotent pin on the shared clone path is retained so git-clone installs are unaffected. Addresses teknium1's review on #67643 and supersedes it, preserving the original author's two commits. Co-authored-by: HexLab98 <8422520+HexLab98@users.noreply.github.com> * chore(contributors): map austinpickett commit email for attribution The check-attribution CI gate flagged austinpickett@users.noreply.github.com as an unmapped commit-author email (introduced by the autocrlf fix commit on this PR). Add the per-email mapping file as the gate instructs (the legacy AUTHOR_MAP in scripts/release.py is frozen). --------- Co-authored-by: HexLab98 <liruixinch@outlook.com> Co-authored-by: austinpickett <austinpickett@users.noreply.github.com> Co-authored-by: HexLab98 <8422520+HexLab98@users.noreply.github.com>
This commit is contained in:
parent
8142331616
commit
bc6839aa37
6 changed files with 478 additions and 36 deletions
|
|
@ -10,12 +10,16 @@ import {
|
|||
buildPosixPinArgs,
|
||||
cachedScriptPath,
|
||||
hasExistingGitCheckout,
|
||||
installRefForStamp,
|
||||
installedAgentInstallScript,
|
||||
isPinnedCommit,
|
||||
resolveInstallScript,
|
||||
resolveMarkerPinnedCommit,
|
||||
runBootstrap
|
||||
} from './bootstrap-runner'
|
||||
|
||||
const SCRIPT_NAME = process.platform === 'win32' ? 'install.ps1' : 'install.sh'
|
||||
const ZERO_COMMIT = '0000000000000000000000000000000000000000'
|
||||
|
||||
function mkTmpHome() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-bootstrap-test-'))
|
||||
|
|
@ -106,6 +110,83 @@ test('existing-checkout bootstrap args keep branch but skip the packaged commit
|
|||
)
|
||||
})
|
||||
|
||||
test('fallback install stamps use an unpinned branch ref', () => {
|
||||
const stamp = { commit: ZERO_COMMIT, branch: 'main' }
|
||||
|
||||
assert.equal(isPinnedCommit(ZERO_COMMIT), false)
|
||||
assert.deepEqual(installRefForStamp(stamp), {
|
||||
ref: 'main',
|
||||
cacheKey: 'fallback-main',
|
||||
pinned: false
|
||||
})
|
||||
// Must NOT pass -Commit / --commit for the all-zero placeholder.
|
||||
assert.deepEqual(buildPinArgs(stamp), ['-Branch', 'main'])
|
||||
assert.deepEqual(
|
||||
buildPosixPinArgs({
|
||||
installStamp: stamp,
|
||||
activeRoot: '/tmp/hermes',
|
||||
hermesHome: '/tmp/home'
|
||||
}),
|
||||
['--dir', '/tmp/hermes', '--hermes-home', '/tmp/home', '--branch', 'main']
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveMarkerPinnedCommit prefers real HEAD over fallback stamp zeros', () => {
|
||||
const realHead = 'c'.repeat(40)
|
||||
assert.equal(
|
||||
resolveMarkerPinnedCommit({ commit: ZERO_COMMIT, branch: 'main' }, '/tmp/checkout', {
|
||||
resolveHead: () => realHead
|
||||
}),
|
||||
realHead
|
||||
)
|
||||
assert.equal(
|
||||
resolveMarkerPinnedCommit({ commit: 'd'.repeat(40), branch: 'main' }, '/tmp/checkout', {
|
||||
resolveHead: () => realHead
|
||||
}),
|
||||
'd'.repeat(40),
|
||||
'packaged real pin wins over checkout HEAD'
|
||||
)
|
||||
assert.equal(
|
||||
resolveMarkerPinnedCommit({ commit: ZERO_COMMIT, branch: 'main' }, '/tmp/missing', {
|
||||
resolveHead: () => null
|
||||
}),
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveInstallScript downloads fallback stamps by branch instead of zero commit', async () => {
|
||||
const home = mkTmpHome()
|
||||
|
||||
try {
|
||||
const logs = []
|
||||
const refs = []
|
||||
const result = await resolveInstallScript({
|
||||
installStamp: { commit: ZERO_COMMIT, branch: 'main' },
|
||||
sourceRepoRoot: null,
|
||||
hermesHome: home,
|
||||
emit: ev => logs.push(ev),
|
||||
_download: async (ref, destPath) => {
|
||||
refs.push(ref)
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
||||
fs.writeFileSync(destPath, '#!/bin/sh\necho fallback branch\n')
|
||||
|
||||
return destPath
|
||||
}
|
||||
})
|
||||
|
||||
assert.deepEqual(refs, ['main'])
|
||||
assert.equal(result.source, 'download')
|
||||
assert.equal(result.commit, null)
|
||||
assert.equal(result.path, cachedScriptPath(home, 'fallback-main'))
|
||||
assert.ok(
|
||||
logs.some(ev => /fallback, unpinned/.test(ev.line || '')),
|
||||
'emits an unpinned fallback log line'
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('resolveInstallScript prefers a cached script without touching the network', async () => {
|
||||
const home = mkTmpHome()
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
* no UI consumes them yet)
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import fsp from 'node:fs/promises'
|
||||
import https from 'node:https'
|
||||
|
|
@ -43,6 +43,117 @@ import { hiddenWindowsChildOptions } from './windows-child-options'
|
|||
const IS_WINDOWS = process.platform === 'win32'
|
||||
|
||||
const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i
|
||||
const FALLBACK_COMMIT_RE = /^0{7,40}$/
|
||||
const FALLBACK_BRANCH = 'main'
|
||||
|
||||
function isPinnedCommit(commit) {
|
||||
return typeof commit === 'string' && STAMP_COMMIT_RE.test(commit) && !FALLBACK_COMMIT_RE.test(commit)
|
||||
}
|
||||
|
||||
type ExecGitFn = (args: string[], cwd: string) => string
|
||||
type ResolveHeadFn = (activeRoot: string | null | undefined) => string | null
|
||||
|
||||
/**
|
||||
* Read HEAD from a managed checkout. Used after bootstrap so fallback
|
||||
* (all-zero) install stamps still produce a marker that
|
||||
* isBootstrapComplete() accepts (pinnedCommit length >= 7).
|
||||
*/
|
||||
function resolveCheckoutHead(
|
||||
activeRoot: string | null | undefined,
|
||||
opts: { execGit?: ExecGitFn } = {}
|
||||
): string | null {
|
||||
if (!activeRoot) {
|
||||
return null
|
||||
}
|
||||
|
||||
const run: ExecGitFn =
|
||||
opts.execGit ||
|
||||
((args, cwd) =>
|
||||
execFileSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
timeout: 15_000,
|
||||
...hiddenWindowsChildOptions()
|
||||
}).trim())
|
||||
|
||||
try {
|
||||
const sha = run(['-c', 'windows.appendAtomically=false', 'rev-parse', 'HEAD'], activeRoot)
|
||||
|
||||
return isPinnedCommit(sha) ? sha : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Prefer a real pin already written by install.ps1's bootstrap-marker stage. */
|
||||
function readExistingPinnedCommit(activeRoot: string | null | undefined): string | null {
|
||||
if (!activeRoot) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(path.join(activeRoot, '.hermes-bootstrap-complete'), 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
|
||||
return parsed && isPinnedCommit(parsed.pinnedCommit) ? parsed.pinnedCommit : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the commit to store on the bootstrap-complete marker.
|
||||
* Packaged fallback stamps must NOT win (all-zero is not a real pin); after a
|
||||
* successful install the checkout's HEAD (or install.ps1's marker) does.
|
||||
*/
|
||||
function resolveMarkerPinnedCommit(
|
||||
installStamp: { commit?: string; branch?: string | null } | null | undefined,
|
||||
activeRoot: string | null | undefined,
|
||||
opts: { resolveHead?: ResolveHeadFn } = {}
|
||||
): string | null {
|
||||
const resolveHead = opts.resolveHead || resolveCheckoutHead
|
||||
|
||||
if (installStamp && isPinnedCommit(installStamp.commit)) {
|
||||
return installStamp.commit
|
||||
}
|
||||
|
||||
const head = resolveHead(activeRoot)
|
||||
|
||||
if (head) {
|
||||
return head
|
||||
}
|
||||
|
||||
return readExistingPinnedCommit(activeRoot)
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an install stamp to the GitHub ref used to fetch install.ps1/sh.
|
||||
* Real CI/git stamps pin an immutable SHA. Non-git fallback stamps carry an
|
||||
* all-zero placeholder -- treat those as an unpinned branch ref so bootstrap
|
||||
* never asks GitHub for commit 0000000... (#50823).
|
||||
*/
|
||||
function installRefForStamp(installStamp) {
|
||||
if (installStamp && isPinnedCommit(installStamp.commit)) {
|
||||
return {
|
||||
ref: installStamp.commit,
|
||||
cacheKey: installStamp.commit,
|
||||
pinned: true
|
||||
}
|
||||
}
|
||||
|
||||
if (installStamp && typeof installStamp.commit === 'string' && FALLBACK_COMMIT_RE.test(installStamp.commit)) {
|
||||
const ref = installStamp.branch || FALLBACK_BRANCH
|
||||
|
||||
return {
|
||||
ref,
|
||||
cacheKey: `fallback-${String(ref).replace(/[^0-9A-Za-z._-]/g, '_')}`,
|
||||
pinned: false
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Stages flagged needs_user_input=true in the manifest are skipped by the
|
||||
// runner (passed -NonInteractive to install.ps1, which the install script
|
||||
|
|
@ -119,12 +230,13 @@ function cachedScriptPath(hermesHome, commit) {
|
|||
return path.join(bootstrapCacheDir(hermesHome), `install-${commit}.${process.platform === 'win32' ? 'ps1' : 'sh'}`)
|
||||
}
|
||||
|
||||
function downloadInstallScript(commit, destPath) {
|
||||
// Fetch from GitHub raw at the pinned commit. The raw URL with a SHA
|
||||
// is immutable (unlike a branch ref), so we don't need integrity
|
||||
// verification beyond "did the file we wrote pass a syntax probe."
|
||||
function downloadInstallScript(ref, destPath) {
|
||||
// Fetch from GitHub raw at the install ref. Normal production builds pass a
|
||||
// pinned SHA (immutable). Non-git fallback builds pass an unpinned branch
|
||||
// ref so local builds can still bootstrap without pretending the all-zero
|
||||
// placeholder is a real GitHub commit.
|
||||
const scriptName = installScriptName()
|
||||
const url = `https://raw.githubusercontent.com/NousResearch/hermes-agent/${commit}/scripts/${scriptName}`
|
||||
const url = `https://raw.githubusercontent.com/NousResearch/hermes-agent/${ref}/scripts/${scriptName}`
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
||||
|
|
@ -223,38 +335,45 @@ async function resolveInstallScript({
|
|||
return { path: localScript, source: 'local', kind: installScriptKind() }
|
||||
}
|
||||
|
||||
// 2. Packaged path: download from GitHub at the pinned commit (1B's stamp).
|
||||
if (!installStamp || !installStamp.commit || !STAMP_COMMIT_RE.test(installStamp.commit)) {
|
||||
// 2. Packaged path: download from GitHub at the install stamp's ref.
|
||||
// Non-git fallback builds carry an all-zero commit; treat that as an
|
||||
// unpinned branch ref instead of trying to fetch a non-existent SHA.
|
||||
const installRef = installRefForStamp(installStamp)
|
||||
|
||||
if (!installRef) {
|
||||
throw new Error(
|
||||
`Cannot resolve ${installScriptName()}: no SOURCE_REPO_ROOT and no install stamp. ` +
|
||||
'This packaged build was produced without a valid build-time stamp.'
|
||||
)
|
||||
}
|
||||
|
||||
const cached = cachedScriptPath(hermesHome, installStamp.commit)
|
||||
const cached = cachedScriptPath(hermesHome, installRef.cacheKey)
|
||||
const resolvedCommit = installRef.pinned ? installRef.ref : null
|
||||
|
||||
try {
|
||||
await fsp.access(cached, fs.constants.R_OK)
|
||||
emit({
|
||||
type: 'log',
|
||||
line: `[bootstrap] using cached ${installScriptName()} for ${installStamp.commit.slice(0, 12)}`
|
||||
line: `[bootstrap] using cached ${installScriptName()} for ${installRef.ref.slice(0, 12)}`
|
||||
})
|
||||
|
||||
return { path: cached, source: 'cache', commit: installStamp.commit, kind: installScriptKind() }
|
||||
return { path: cached, source: 'cache', commit: resolvedCommit, kind: installScriptKind() }
|
||||
} catch {
|
||||
// not cached; download
|
||||
}
|
||||
|
||||
emit({
|
||||
type: 'log',
|
||||
line: `[bootstrap] fetching ${installScriptName()} for ${installStamp.commit.slice(0, 12)} from GitHub`
|
||||
line:
|
||||
`[bootstrap] fetching ${installScriptName()} for ${installRef.ref.slice(0, 12)} from GitHub` +
|
||||
(installRef.pinned ? '' : ' (fallback, unpinned)')
|
||||
})
|
||||
|
||||
try {
|
||||
await _download(installStamp.commit, cached)
|
||||
await _download(installRef.ref, cached)
|
||||
emit({ type: 'log', line: `[bootstrap] saved to ${cached}` })
|
||||
|
||||
return { path: cached, source: 'download', commit: installStamp.commit, kind: installScriptKind() }
|
||||
return { path: cached, source: 'download', commit: resolvedCommit, kind: installScriptKind() }
|
||||
} catch (err) {
|
||||
// The pinned commit may not be fetchable from GitHub -- most commonly a
|
||||
// locally-built desktop app stamped to an unpushed HEAD (see
|
||||
|
|
@ -275,10 +394,10 @@ async function resolveInstallScript({
|
|||
fs.mkdirSync(path.dirname(cached), { recursive: true })
|
||||
fs.copyFileSync(installed, cached)
|
||||
|
||||
return { path: cached, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() }
|
||||
return { path: cached, source: 'installed-agent', commit: resolvedCommit, kind: installScriptKind() }
|
||||
} catch {
|
||||
// Cache copy failed (read-only FS, etc.) -- use the source path directly.
|
||||
return { path: installed, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() }
|
||||
return { path: installed, source: 'installed-agent', commit: resolvedCommit, kind: installScriptKind() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -544,11 +663,12 @@ function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome
|
|||
// Build the installer branch/pin args from the install stamp. The commit pin
|
||||
// is fresh-install only: once a managed checkout already exists, bootstrap is
|
||||
// a repair/update path and must not let an old packaged app detach the checkout
|
||||
// back to the commit baked into that app.
|
||||
// back to the commit baked into that app. All-zero fallback stamps are never
|
||||
// passed as -Commit/--commit — only the branch is used (#50823 / #50864 review).
|
||||
function buildPinArgs(installStamp, { pinCommit = true } = {}) {
|
||||
const args = []
|
||||
|
||||
if (pinCommit && installStamp && installStamp.commit) {
|
||||
if (pinCommit && installStamp && isPinnedCommit(installStamp.commit)) {
|
||||
args.push('-Commit', installStamp.commit)
|
||||
}
|
||||
|
||||
|
|
@ -566,7 +686,7 @@ function buildPosixPinArgs({ installStamp, activeRoot, hermesHome, pinCommit = t
|
|||
args.push('--branch', installStamp.branch)
|
||||
}
|
||||
|
||||
if (pinCommit && installStamp && installStamp.commit) {
|
||||
if (pinCommit && installStamp && isPinnedCommit(installStamp.commit)) {
|
||||
args.push('--commit', installStamp.commit)
|
||||
}
|
||||
|
||||
|
|
@ -860,9 +980,28 @@ async function runBootstrap(opts) {
|
|||
}
|
||||
}
|
||||
|
||||
// 4. Write the bootstrap-complete marker.
|
||||
// 4. Write the bootstrap-complete marker. Fallback (all-zero) stamps are
|
||||
// not real pins -- resolve HEAD from the checkout we just installed so
|
||||
// isBootstrapComplete() (pinnedCommit.length >= 7) accepts the marker
|
||||
// instead of re-running bootstrap on every launch (#50823 review).
|
||||
const pinnedCommit = resolveMarkerPinnedCommit(installStamp, activeRoot)
|
||||
|
||||
if (!pinnedCommit) {
|
||||
emit({
|
||||
type: 'log',
|
||||
line:
|
||||
'[bootstrap] WARNING: could not resolve a real pinnedCommit for the ' +
|
||||
'bootstrap-complete marker; subsequent launches may re-run bootstrap'
|
||||
})
|
||||
} else if (installStamp && !isPinnedCommit(installStamp.commit)) {
|
||||
emit({
|
||||
type: 'log',
|
||||
line: `[bootstrap] fallback stamp resolved marker pin to ${pinnedCommit.slice(0, 12)} from checkout`
|
||||
})
|
||||
}
|
||||
|
||||
const markerPayload = {
|
||||
pinnedCommit: installStamp ? installStamp.commit : null,
|
||||
pinnedCommit,
|
||||
pinnedBranch: installStamp ? installStamp.branch : null
|
||||
}
|
||||
|
||||
|
|
@ -888,10 +1027,14 @@ export {
|
|||
buildPosixPinArgs,
|
||||
cachedScriptPath,
|
||||
hasExistingGitCheckout,
|
||||
installRefForStamp,
|
||||
installedAgentInstallScript,
|
||||
isPinnedCommit,
|
||||
// Exposed for testability
|
||||
parseStageResult,
|
||||
resolveCheckoutHead,
|
||||
resolveInstallScript,
|
||||
resolveLocalInstallScript,
|
||||
resolveMarkerPinnedCommit,
|
||||
runBootstrap
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,25 +11,33 @@
|
|||
* "branch": "<branch name>",
|
||||
* "builtAt": "<ISO 8601 UTC timestamp>",
|
||||
* "dirty": true|false,
|
||||
* "source": "ci" | "local"
|
||||
* "source": "ci" | "local" | "fallback"
|
||||
* }
|
||||
*
|
||||
* 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 error rather than
|
||||
* silently writing an unstamped manifest -- the packaged app refuses to
|
||||
* bootstrap without a stamp.
|
||||
* 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, writeFileSync } from "fs"
|
||||
import { resolve, join, relative } from "path"
|
||||
import { execSync } from "child_process"
|
||||
|
||||
import { isMain } from "./utils.mjs"
|
||||
|
||||
const STAMP_SCHEMA_VERSION = 1
|
||||
|
||||
/** 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")
|
||||
|
|
@ -43,10 +51,10 @@ function tryExec(cmd, opts) {
|
|||
}
|
||||
}
|
||||
|
||||
function fromCI() {
|
||||
const sha = process.env.GITHUB_SHA
|
||||
export function fromCI(env = process.env) {
|
||||
const sha = env.GITHUB_SHA
|
||||
if (!sha) return null
|
||||
const branch = process.env.GITHUB_REF_NAME || process.env.GITHUB_HEAD_REF || null
|
||||
const branch = env.GITHUB_REF_NAME || env.GITHUB_HEAD_REF || null
|
||||
return {
|
||||
commit: sha,
|
||||
branch: branch,
|
||||
|
|
@ -55,17 +63,17 @@ function fromCI() {
|
|||
}
|
||||
}
|
||||
|
||||
function fromLocalGit() {
|
||||
const sha = tryExec("git rev-parse HEAD", { cwd: REPO_ROOT })
|
||||
export function fromLocalGit(repoRoot = REPO_ROOT, execFn = tryExec) {
|
||||
const sha = execFn("git rev-parse HEAD", { cwd: repoRoot })
|
||||
if (!sha) return null
|
||||
const branch = tryExec("git rev-parse --abbrev-ref HEAD", { cwd: REPO_ROOT })
|
||||
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 = tryExec("git status --porcelain -uno", { cwd: REPO_ROOT })
|
||||
const status = execFn("git status --porcelain -uno", { cwd: repoRoot })
|
||||
const dirty = status !== null && status.length > 0
|
||||
return {
|
||||
commit: sha,
|
||||
|
|
@ -75,9 +83,41 @@ function fromLocalGit() {
|
|||
}
|
||||
}
|
||||
|
||||
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 main() {
|
||||
const stamp = fromCI() || fromLocalGit()
|
||||
const stamp = 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" +
|
||||
|
|
@ -90,6 +130,15 @@ function main() {
|
|||
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" +
|
||||
|
|
@ -117,8 +166,11 @@ function main() {
|
|||
" -> " +
|
||||
stamp.commit.slice(0, 12) +
|
||||
(stamp.branch ? " (" + stamp.branch + ")" : "") +
|
||||
(stamp.dirty ? " [DIRTY]" : "")
|
||||
(stamp.dirty ? " [DIRTY]" : "") +
|
||||
(stamp.source === "fallback" ? " [FALLBACK]" : "")
|
||||
)
|
||||
}
|
||||
|
||||
main()
|
||||
if (isMain(import.meta.url)) {
|
||||
main()
|
||||
}
|
||||
|
|
|
|||
86
apps/desktop/scripts/write-build-stamp.test.mjs
Normal file
86
apps/desktop/scripts/write-build-stamp.test.mjs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
FALLBACK_BRANCH,
|
||||
FALLBACK_COMMIT,
|
||||
fromCI,
|
||||
fromFallback,
|
||||
fromLocalGit,
|
||||
isFallbackCommit,
|
||||
resolveStamp
|
||||
} from './write-build-stamp.mjs'
|
||||
|
||||
test('fromCI reads GITHUB_SHA / GITHUB_REF_NAME', () => {
|
||||
assert.deepEqual(
|
||||
fromCI({ GITHUB_SHA: 'a'.repeat(40), GITHUB_REF_NAME: 'release' }),
|
||||
{ commit: 'a'.repeat(40), branch: 'release', dirty: false, source: 'ci' }
|
||||
)
|
||||
assert.equal(fromCI({}), null)
|
||||
})
|
||||
|
||||
test('fromLocalGit returns null when git rev-parse fails', () => {
|
||||
const stamp = fromLocalGit('/tmp/not-a-repo', () => null)
|
||||
assert.equal(stamp, null)
|
||||
})
|
||||
|
||||
test('fromLocalGit reads HEAD + branch + dirty status', () => {
|
||||
const calls = []
|
||||
const execFn = (cmd) => {
|
||||
calls.push(cmd)
|
||||
if (cmd === 'git rev-parse HEAD') return 'b'.repeat(40)
|
||||
if (cmd === 'git rev-parse --abbrev-ref HEAD') return 'main'
|
||||
if (cmd === 'git status --porcelain -uno') return ' M apps/desktop/package.json'
|
||||
return null
|
||||
}
|
||||
assert.deepEqual(fromLocalGit('/repo', execFn), {
|
||||
commit: 'b'.repeat(40),
|
||||
branch: 'main',
|
||||
dirty: true,
|
||||
source: 'local'
|
||||
})
|
||||
assert.ok(calls.includes('git rev-parse HEAD'))
|
||||
})
|
||||
|
||||
test('fromFallback uses the all-zero placeholder commit', () => {
|
||||
assert.deepEqual(fromFallback(), {
|
||||
commit: FALLBACK_COMMIT,
|
||||
branch: FALLBACK_BRANCH,
|
||||
dirty: false,
|
||||
source: 'fallback'
|
||||
})
|
||||
assert.equal(isFallbackCommit(FALLBACK_COMMIT), true)
|
||||
assert.equal(isFallbackCommit('a'.repeat(40)), false)
|
||||
})
|
||||
|
||||
test('resolveStamp prefers CI over local git over fallback', () => {
|
||||
const ci = resolveStamp({
|
||||
env: { GITHUB_SHA: 'c'.repeat(40), GITHUB_REF_NAME: 'main' },
|
||||
execFn: () => 'should-not-run'
|
||||
})
|
||||
assert.equal(ci.source, 'ci')
|
||||
assert.equal(ci.commit, 'c'.repeat(40))
|
||||
|
||||
const local = resolveStamp({
|
||||
env: {},
|
||||
execFn: (cmd) => {
|
||||
if (cmd === 'git rev-parse HEAD') return 'd'.repeat(40)
|
||||
if (cmd === 'git rev-parse --abbrev-ref HEAD') return 'main'
|
||||
if (cmd === 'git status --porcelain -uno') return ''
|
||||
return null
|
||||
}
|
||||
})
|
||||
assert.equal(local.source, 'local')
|
||||
assert.equal(local.commit, 'd'.repeat(40))
|
||||
assert.equal(local.dirty, false)
|
||||
})
|
||||
|
||||
test('resolveStamp falls back when neither CI nor git is available', () => {
|
||||
const stamp = resolveStamp({ env: {}, execFn: () => null })
|
||||
assert.deepEqual(stamp, {
|
||||
commit: FALLBACK_COMMIT,
|
||||
branch: FALLBACK_BRANCH,
|
||||
dirty: false,
|
||||
source: 'fallback'
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
austinpickett
|
||||
# PR #67730 salvage of #67643
|
||||
|
|
@ -1686,11 +1686,54 @@ function Install-Repository {
|
|||
Move-Item $extractedDir.FullName $InstallDir -Force
|
||||
Write-Success "Downloaded and extracted"
|
||||
|
||||
# Initialize git repo so updates work later
|
||||
# Initialize git repo so updates work later. A bare
|
||||
# `git init` leaves NO HEAD -- desktop's write-build-stamp
|
||||
# then hard-fails with "could not determine git commit"
|
||||
# (#50823 / #61657). Fetch the requested ref and force-check
|
||||
# it out (-f) so untracked ZIP files cannot block checkout.
|
||||
Push-Location $InstallDir
|
||||
git -c windows.appendAtomically=false init 2>$null
|
||||
git -c windows.appendAtomically=false config windows.appendAtomically false 2>$null
|
||||
# Pin autocrlf=false BEFORE the checkout below. Git for Windows
|
||||
# defaults to core.autocrlf=true, which would renormalize the
|
||||
# repo's LF text files to CRLF in the working tree during
|
||||
# `checkout -f FETCH_HEAD` -- leaving this freshly-created
|
||||
# managed checkout dirty vs HEAD and aborting the next
|
||||
# `hermes update` (see the notes at the shared clone-path
|
||||
# config below and install.ps1:1461-1469). The later pin on
|
||||
# the shared path is idempotent and still covers git clones.
|
||||
git -c windows.appendAtomically=false config core.autocrlf false 2>$null
|
||||
git remote add origin $RepoUrlHttps 2>$null
|
||||
$fetchRef = if ($Commit) { $Commit } elseif ($Tag) { "refs/tags/$Tag" } else { $Branch }
|
||||
Write-Info "Fetching $fetchRef so the ZIP checkout has a resolvable HEAD..."
|
||||
$prevZipEAP = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
git -c windows.appendAtomically=false fetch --depth 1 origin $fetchRef 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
if ($Commit -or $Tag) {
|
||||
git -c windows.appendAtomically=false checkout -f --detach FETCH_HEAD 2>&1 | Out-Null
|
||||
} else {
|
||||
git -c windows.appendAtomically=false checkout -f -B $Branch FETCH_HEAD 2>&1 | Out-Null
|
||||
}
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Success "ZIP checkout pinned to $fetchRef"
|
||||
} else {
|
||||
# Checkout blocked, but FETCH_HEAD still has a SHA we can stamp with.
|
||||
$fetchSha = & git -c windows.appendAtomically=false rev-parse FETCH_HEAD 2>$null
|
||||
if ($LASTEXITCODE -eq 0 -and $fetchSha) {
|
||||
if (-not $env:GITHUB_SHA) { $env:GITHUB_SHA = ("$fetchSha").Trim() }
|
||||
Write-Warn "ZIP checkout failed; seeded GITHUB_SHA from FETCH_HEAD for desktop stamp"
|
||||
} else {
|
||||
Write-Warn "ZIP extract succeeded but git checkout failed -- desktop build may need `$env:GITHUB_SHA"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Warn "ZIP extract succeeded but git fetch of $fetchRef failed -- desktop build may need `$env:GITHUB_SHA"
|
||||
}
|
||||
} finally {
|
||||
$ErrorActionPreference = $prevZipEAP
|
||||
}
|
||||
Pop-Location
|
||||
Write-Success "Git repo initialized for future updates"
|
||||
|
||||
|
|
@ -2888,6 +2931,41 @@ function Install-Desktop {
|
|||
# for some other tool, electron-builder would still try to sign.
|
||||
Write-Info "Building desktop app (this takes 1-3 minutes)..."
|
||||
$buildLog = "$env:TEMP\hermes-desktop-build-$(Get-Random).log"
|
||||
# Seed GITHUB_SHA for write-build-stamp.mjs. The stamp prefers CI env vars
|
||||
# over `git rev-parse`, so this covers: (1) node can't find git.exe on PATH
|
||||
# even though this PowerShell session can, (2) ZIP/init trees that still
|
||||
# lack a HEAD after a failed post-extract fetch. Without it the desktop
|
||||
# pack dies with "could not determine git commit" (#50823).
|
||||
if (-not $env:GITHUB_SHA) {
|
||||
if ($Commit) {
|
||||
$env:GITHUB_SHA = $Commit
|
||||
} else {
|
||||
Push-Location $InstallDir
|
||||
try {
|
||||
$global:LASTEXITCODE = 0
|
||||
$resolvedSha = & git -c windows.appendAtomically=false rev-parse HEAD 2>$null
|
||||
if ($LASTEXITCODE -ne 0 -or -not $resolvedSha) {
|
||||
# ZIP path may have FETCH_HEAD after a fetch even when HEAD is unset.
|
||||
$global:LASTEXITCODE = 0
|
||||
$resolvedSha = & git -c windows.appendAtomically=false rev-parse FETCH_HEAD 2>$null
|
||||
}
|
||||
if ($LASTEXITCODE -eq 0 -and $resolvedSha) {
|
||||
$env:GITHUB_SHA = ("$resolvedSha").Trim()
|
||||
}
|
||||
} catch { } finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not $env:GITHUB_REF_NAME) {
|
||||
$env:GITHUB_REF_NAME = if ($Branch) { $Branch } else { "main" }
|
||||
}
|
||||
if ($env:GITHUB_SHA) {
|
||||
$shaPreview = if ($env:GITHUB_SHA.Length -ge 12) { $env:GITHUB_SHA.Substring(0, 12) } else { $env:GITHUB_SHA }
|
||||
Write-Info "Desktop build stamp: $shaPreview ($($env:GITHUB_REF_NAME))"
|
||||
} else {
|
||||
Write-Warn "Could not resolve a git commit for the desktop stamp -- write-build-stamp will use its non-git fallback"
|
||||
}
|
||||
Push-Location $desktopDir
|
||||
$prevEAP = $ErrorActionPreference
|
||||
$prevCSCAuto = $env:CSC_IDENTITY_AUTO_DISCOVERY
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue