Files
hermes-agent/apps/desktop/scripts/bundle-electron-main.mjs
Halldrix eec5d2af64 fix(build): record the baked stamp clock, not the live one (#123308)
write-build-stamp.mjs rewrites the stamp's `builtAt` clock on every build, so the
whole-file hash moved under a receipt that had just recorded it. Two builds
overlapping on one tree failed with "Build inputs changed during compilation" for
a difference the build machinery itself made, and the caller turned that into
SystemExit(1).

Hash the stamp's provenance identity instead, key-sorted so a cosmetic
re-serialisation is not a change. The clock is excluded from that comparison but
recorded as `stampClock` — the value the bake actually embedded, captured from
the same read that produces __HERMES_INSTALL_STAMP__ — because packaging copies
the live stamp into the bundle after the receipt is written. Re-reading the stamp
at record time would certify a clock the output never contained, and
productCurrent would then report a bundle current whose packaged clock disagrees
with its baked main, which is exactly what detectBundleSwap must never do.

recordProduct still compares the prepared inputs, so a clock-only restamp cannot
throw. productCurrent additionally requires the recorded clock to match, so a
restamp between bake and packaging is a rebuild rather than a silent divergence.
A receipt predating the field compares by hash alone until the next build
rewrites it; the release packaging path never consults productCurrent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-01 00:15:05 -05:00

98 lines
4.2 KiB
JavaScript

#!/usr/bin/env node
import { execFileSync } from 'node:child_process'
import { mkdirSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { parseArgs } from 'node:util'
import { isMain, workspaceTool } from '../../../scripts/build/frontend-common.mjs'
import { environmentDefaultsBanner } from './bundle-env.mjs'
const repoRoot = resolve(import.meta.dirname, '../../..')
// Evaluate the one identity module in a fresh process. Its CJS cache and the
// caller's environment must not carry a previous variant into this artifact.
function productIdentity(source, stamp) {
const variant = stamp.updateMechanism === 'microsoft-store' ? 'store'
: stamp.payload === 'bootstrap' ? '' : stamp.payload
if (!['', 'bundled', 'light', 'store'].includes(variant)) {
throw new Error(`Invalid desktop stamp payload: ${stamp.payload}`)
}
return execFileSync(process.execPath, ['-e', 'console.log(JSON.stringify(require(process.argv[1])))',
join(source, 'apps/desktop/product-identity.cjs')], {
env: { ...process.env, HERMES_DESKTOP_VARIANT: variant, HERMES_PAYLOAD_TAG: stamp.tag || '',
// Commit builds stamp source='commit-build'; the display name carries
// the short SHA (see product-identity.cjs). Tagged builds pass ''.
HERMES_BUILD_COMMIT: stamp.source === 'commit-build' ? (stamp.commit || '') : '' },
encoding: 'utf8',
}).trim()
}
/** Bundle main/preload using only the prepared workspace's compiler. */
export async function bundleElectronMain({ source, out, stamp, dev = false }) {
source = resolve(source)
out = resolve(out)
const { build } = await import(pathToFileURL(workspaceTool(source, 'apps/desktop', 'esbuild')).href)
// Defaults must run before bundled modules resolve paths or onboarding flags.
// Dev bundles leave the environment alone so source-tree resolution keeps working.
const envBanner = dev ? '' : environmentDefaultsBanner(process.env.HERMES_BUNDLE_ENV_JSON || '{}')
const define = {}
let bakedStamp = null
if (!dev) {
if (!stamp) throw new Error('A prepared install stamp is required')
const raw = readFileSync(stamp, 'utf8')
const metadata = JSON.parse(raw)
// The baked bytes, and the clock the caller must record: re-reading the stamp
// after this point would report a restamp the output never saw.
bakedStamp = metadata.builtAt ?? null
define['process.env.HERMES_DESKTOP_IS_PACKAGED'] = JSON.stringify(true)
define.__HERMES_INSTALL_STAMP__ = raw
define.__HERMES_PRODUCT_IDENTITY__ = productIdentity(source, metadata)
}
mkdirSync(out, { recursive: true })
const common = {
absWorkingDir: join(source, 'apps/desktop'),
bundle: true,
platform: 'node',
target: 'node20',
external: ['electron', 'node-pty', 'get-windows', 'fs'],
define,
logLevel: 'info',
}
await build({
...common,
// entry.ts picks the Linux ozone platform (native Wayland/WSLg) and
// relaunches before main.ts loads; Electron reads it only from argv.
entryPoints: [join(source, 'apps/desktop/electron/entry.ts')],
format: 'esm',
outfile: join(out, 'electron-main.mjs'),
banner: { js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);" + envBanner },
})
await build({
...common,
entryPoints: [join(source, 'apps/desktop/electron/preload.ts')],
format: 'cjs',
outfile: join(out, 'electron-preload.js'),
})
// Preview-pane <webview> guest preload; main.ts hands this path to the
// preview webview via will-attach-webview.
await build({
...common,
entryPoints: [join(source, 'apps/desktop/electron/preview-guest-preload-entry.ts')],
format: 'cjs',
outfile: join(out, 'preview-guest-preload.js'),
})
return { stampClock: bakedStamp }
}
if (isMain(import.meta.url)) {
const { values } = parseArgs({ options: {
source: { type: 'string', default: repoRoot },
out: { type: 'string' }, stamp: { type: 'string' }, dev: { type: 'boolean' },
} })
await bundleElectronMain({
...values,
out: values.out || join(values.source, 'apps/desktop/dist'),
stamp: values.stamp || join(values.source, 'apps/desktop/build/install-stamp.json'),
})
}