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:
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user