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>
This commit is contained in:
Halldrix
2026-09-27 01:32:48 -05:00
committed by brooklyn!
parent ff28c475e3
commit eec5d2af64
4 changed files with 87 additions and 24 deletions

View File

@@ -36,10 +36,14 @@ export async function bundleElectronMain({ source, out, stamp, dev = false }) {
// 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)
@@ -77,6 +81,7 @@ export async function bundleElectronMain({ source, out, stamp, dev = false }) {
format: 'cjs',
outfile: join(out, 'preview-guest-preload.js'),
})
return { stampClock: bakedStamp }
}
if (isMain(import.meta.url)) {

View File

@@ -128,10 +128,17 @@ It records product/host identity, content hashes of workspace/shared sources and
build inputs, and the exact supplied icon directory, desktop install stamp, and
native-dependency tree. The desktop stamp is hashed as its provenance identity
rather than its raw bytes: `write-build-stamp.mjs` rewrites the `builtAt` clock on
every build, so hashing it byte-for-byte let the build's own first step invalidate
the receipt it was about to write (#123308). Inputs are checked again before
publication: a concurrent input change fails the build and preserves the previous
output. Output validation
every build, so hashing it byte-for-byte let a second build racing this one kill
it with "inputs changed" for a difference the build machinery itself made
(#123308). The clock is not dropped, only excluded from that comparison: the
receipt records it as `stampClock`, the value the bake actually embedded, because
packaging copies the live stamp file into the bundle *after* the receipt is
written. A product whose packaged clock disagrees with the clock baked into its
main process must not be reported current — `detectBundleSwap` would otherwise
offer a relaunch for a bundle that was never replaced. A receipt predating the
field is compared by hash alone until the next build rewrites it. Inputs are
checked again before publication: a concurrent provenance change fails the build
and preserves the previous output. Output validation
checks renderer/main/preload/public bytes and the native file inventory; native
bytes may change through signing after compilation. Native ABI verification remains
with the native provider and desktop compiler.

View File

@@ -59,11 +59,15 @@ export async function buildDesktop({ source, out, icons, stamp, nativeDeps, type
cacheDir: join(scratch, 'vite-cache'),
build: { outDir: product, emptyOutDir: true },
})
await bundleElectronMain({ source, out: product, stamp })
// The receipt must carry the clock this output BAKED, not whatever the live
// stamp says by now: electron-builder copies that live file into the bundle
// after the receipt is written, and productCurrent must be able to tell that
// the two disagree (#123308).
const { stampClock } = await bundleElectronMain({ source, out: product, stamp })
copyNativeTree({ nativeDeps, out: join(product, 'node_modules') })
const result = checkDistBuilt(product)
if (!result.ok) throw new Error(result.error)
recordProduct({ source, product: 'desktop', out: product, inputs })
recordProduct({ source, product: 'desktop', out: product, inputs, stampClock })
}, { source })
return { out }
}

View File

@@ -65,25 +65,55 @@ function outputHash(out) {
}
// The desktop install stamp is a real prepared input — buildDesktop bakes its bytes
// into electron-main.mjs — but write-build-stamp.mjs rewrites `builtAt` on every build
// (#123308). Hashing the whole file made the build's own first step invalidate the
// receipt it was about to write, so a second build racing the first killed it on
// "inputs changed". Hash the identity the output actually depends on instead:
// the baked bytes change only when provenance does, never when the clock moves.
// into electron-main.mjs — but write-build-stamp.mjs rewrites its `builtAt` clock on
// every build (#123308). Hashing the whole file let a second build racing this one
// kill it with "inputs changed" for a difference the build machinery itself made.
// Hash the stamp's provenance identity instead: commit/payload/tag must still
// invalidate, and only the clock is ignored.
//
// The clock is NOT dropped, only excluded from this comparison. It is recorded
// separately in the receipt as `stampClock` — the value the bake actually used —
// because the output really does embed it: outputHash covers dist/ at build time,
// but electron-builder's extraResources copies the live stamp file into the bundle
// afterwards (electron-builder.config.cjs), so a restamp landing between the two
// would ship a bundle whose Resources/install-stamp.json disagrees with the baked
// main, and detectBundleSwap (bundle-swap.ts) would report a swap for a bundle
// that was never replaced. The path differs per packager (the desktop workspace
// stamp, the release bundle's own stamp, a Nix store path); the race does not.
const stampClockFields = new Set(['builtAt'])
// An unreadable/non-JSON stamp is not an identity loss: its bytes still feed the hash,
// so the file's content keeps deciding freshness and only the clock is ignored.
// Key order is not part of provenance: write-build-stamp emits one fixed literal
// order today, but a cosmetic re-serialisation must not force a rebuild.
function stampIdentity(identity) {
return Object.fromEntries(Object.entries(identity)
.filter(([key]) => !stampClockFields.has(key))
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
}
// Missing and non-JSON stamps fall back to the whole-file tree hash rather than
// failing: a missing input must keep reading as one distinct hash, never as a
// throw, so a build that has not stamped yet stays "not current" instead of
// aborting. Only a parsable stamp object can have its clock removed.
function stampContentHash(path) {
let raw = readFileSync(path)
let identity
const parsed = readStamp(path)
return parsed ? createHash('sha256').update(JSON.stringify(stampIdentity(parsed))).digest('hex')
: treeHash(resolve(path), ['.'], () => false)
}
function readStamp(path) {
let identity = null
try {
identity = JSON.parse(raw)
} catch { return treeHash(resolve(path), ['.'], () => false) }
if (!identity || typeof identity !== 'object' || Array.isArray(identity)) return treeHash(resolve(path), ['.'], () => false)
return createHash('sha256').update(JSON.stringify(
Object.fromEntries(Object.entries(identity).filter(([key]) => !stampClockFields.has(key))),
)).digest('hex')
identity = JSON.parse(readFileSync(path))
} catch { return null }
return identity && typeof identity === 'object' && !Array.isArray(identity) ? identity : null
}
/** The clock the output bakes, or null when the stamp is absent/unparsable. */
function stampClock(prepared = {}) {
const path = prepared.stamp
if (!path) return null
const identity = readStamp(resolve(path))
return identity ? (identity.builtAt ?? null) : null
}
export function buildInputs(source, product, prepared = {}) {
@@ -99,23 +129,40 @@ function preparedPaths(inputs) {
return Object.fromEntries(inputs.prepared.map(({ name, path }) => [name, path]))
}
export function recordProduct({ source, product, out, inputs }) {
// The clock the compiler BAKED, not the clock on disk now. desktop.mjs must pass
// the clock it read at bundleElectronMain: re-reading the stamp here would record
// a restamp that landed in between as the baked value, and productCurrent would
// then certify a product whose packaged clock disagrees with electron-main.mjs.
// Undefined (tui/web, which have no stamp) is dropped from the receipt, which is
// exactly the "predates this" shape productCurrent already tolerates.
export function recordProduct({ source, product, out, inputs, stampClock }) {
if (JSON.stringify(buildInputs(source, product, preparedPaths(inputs))) !== JSON.stringify(inputs)) {
throw new Error('Build inputs changed during compilation; retry the build')
}
writeFileSync(join(out, receiptName), JSON.stringify({
schema: 1, product, platform: process.platform, arch: process.arch, node: process.versions.node,
inputs, outputHash: outputHash(out),
inputs, stampClock, outputHash: outputHash(out),
}) + '\n')
}
export function productCurrent({ source, product, out, prepared }) {
try {
const saved = JSON.parse(readFileSync(join(out, receiptName), 'utf8'))
const paths = prepared ?? preparedPaths(saved.inputs)
return saved.schema === 1 && saved.product === product
&& saved.platform === process.platform && saved.arch === process.arch
&& saved.node === process.versions.node
&& JSON.stringify(saved.inputs) === JSON.stringify(buildInputs(source, product, prepared ?? preparedPaths(saved.inputs)))
// The pre-build gate, unlike recordProduct, must also see the clock. A restamp
// that lands after the build (a racing write-build-stamp, or a second builder
// that reached extraResources first) leaves dist baking one builtAt while the
// live stamp says another, and packaging would then ship a bundle that
// detectBundleSwap reports as replaced. A receipt with no recorded clock
// predates this and stays comparable by hash alone. That branch is not a
// packaging hole: the release path (scripts/bundles/desktop.py) never consults
// this gate, it always stamps and compiles, and the source launchers that do
// consult it reuse the dist already on disk rather than packaging a new bundle.
&& (saved.stampClock === undefined || saved.stampClock === stampClock(paths))
&& JSON.stringify(saved.inputs) === JSON.stringify(buildInputs(source, product, paths))
&& saved.outputHash === outputHash(out)
} catch { return false }
}