diff --git a/apps/desktop/electron/bootstrap-runner.test.ts b/apps/desktop/electron/bootstrap-runner.test.ts index d23704cb30d7..9fe76fa69f5b 100644 --- a/apps/desktop/electron/bootstrap-runner.test.ts +++ b/apps/desktop/electron/bootstrap-runner.test.ts @@ -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() diff --git a/apps/desktop/electron/bootstrap-runner.ts b/apps/desktop/electron/bootstrap-runner.ts index 47f76774b427..2f28424c7e4d 100644 --- a/apps/desktop/electron/bootstrap-runner.ts +++ b/apps/desktop/electron/bootstrap-runner.ts @@ -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 } diff --git a/apps/desktop/scripts/write-build-stamp.mjs b/apps/desktop/scripts/write-build-stamp.mjs index 005db35d1772..076d5a893e23 100644 --- a/apps/desktop/scripts/write-build-stamp.mjs +++ b/apps/desktop/scripts/write-build-stamp.mjs @@ -11,25 +11,33 @@ * "branch": "", * "builtAt": "", * "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() +} diff --git a/apps/desktop/scripts/write-build-stamp.test.mjs b/apps/desktop/scripts/write-build-stamp.test.mjs new file mode 100644 index 000000000000..53c88e23704c --- /dev/null +++ b/apps/desktop/scripts/write-build-stamp.test.mjs @@ -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' + }) +}) diff --git a/contributors/emails/austinpickett@users.noreply.github.com b/contributors/emails/austinpickett@users.noreply.github.com new file mode 100644 index 000000000000..d30e8a7fe8a1 --- /dev/null +++ b/contributors/emails/austinpickett@users.noreply.github.com @@ -0,0 +1,2 @@ +austinpickett +# PR #67730 salvage of #67643 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 746b5090faaf..0a98ad6e457b 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -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