Files
hermes-agent/scripts/build/freshness.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

177 lines
8.8 KiB
JavaScript

// Product receipts belong to the compilers, not PM dependency receipts or profiles.
import { createHash } from 'node:crypto'
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { parseArgs } from 'node:util'
import { isMain } from './frontend-common.mjs'
const receiptName = 'hermes-build.json'
const workspaces = { tui: 'ui-tui', web: 'web', desktop: 'apps/desktop' }
const generated = new Set(['node_modules', 'dist', 'build', 'release', '.cache', '.git', 'coverage', 'test-results', 'playwright-report'])
// buildTui bundles these source roots (including the Ink source alias), not
// the workspaces' documentation, test runners or other product recipes.
const tuiInputs = [
...['ui-tui', 'ui-tui/packages/hermes-ink', 'apps/shared'].flatMap(root => [
`${root}/src`, `${root}/package.json`, `${root}/tsconfig.json`,
]),
'tsconfig.json', 'package.json', 'package-lock.json', '.npmrc', 'pm/lock.json',
'scripts/build/tui.mjs', 'scripts/build/frontend-common.mjs', 'scripts/build/freshness.mjs',
]
function treeHash(root, inputs, skip, contents = () => true) {
const hash = createHash('sha256')
function visit(name) {
if (skip(name)) return
const file = join(root, name)
hash.update(name.replaceAll('\\', '/')).update('\0')
if (!existsSync(file)) { hash.update('missing\0'); return }
if (statSync(file).isDirectory()) {
hash.update('directory\0')
for (const child of readdirSync(file).sort()) visit(`${name}/${child}`)
} else {
hash.update(contents(name) ? readFileSync(file) : 'file').update('\0')
}
}
for (const input of inputs) visit(input)
return hash.digest('hex')
}
export function sourceHash(source, product) {
const workspace = workspaces[product]
if (!workspace) throw new Error(`Unknown frontend product: ${product}`)
return treeHash(source, product === 'tui' ? tuiInputs : [
workspace, 'apps/shared', 'package.json', 'package-lock.json', '.npmrc', 'pm/lock.json',
'scripts/build',
'scripts/generate-icons.mjs', 'scripts/generate_icons.py',
// The root install-stamp.json is runtime identity rewritten after every install; desktop's
// baked stamp is a prepared input.
'assets', 'pyproject.toml', 'uv.lock',
], name => {
// Build scripts are inputs; workspace build directories are outputs.
const parts = name.split('/')
return (!name.startsWith('scripts/') && parts.some(part => generated.has(part)))
|| (product === 'tui' && (parts.includes('__tests__') || /\.(test|spec)(-d)?\.[cm]?[jt]sx?$/.test(name)))
|| parts.some(part => part.startsWith('.dist-') || part.startsWith('.staging-') || part === '__pycache__')
|| name.endsWith('.tsbuildinfo') || name.endsWith('.pyc')
})
}
function outputHash(out) {
// Native binaries can be signed after compilation. Their ABI validation is
// owned by native preparation; renderer/main/preload bytes must stay intact.
return treeHash(out, readdirSync(out).sort(), name => name === receiptName || name === '.hermes-product',
name => !name.split('/').includes('node_modules') && !name.startsWith('native/'))
}
// The desktop install stamp is a real prepared input — buildDesktop bakes its bytes
// 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'])
// 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) {
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(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 = {}) {
return {
sourceHash: sourceHash(source, product),
prepared: Object.entries(prepared).sort().map(([name, path]) => ({
name, path: resolve(path), hash: name === 'stamp' ? stampContentHash(resolve(path)) : treeHash(resolve(path), ['.'], () => false),
})),
}
}
function preparedPaths(inputs) {
return Object.fromEntries(inputs.prepared.map(({ name, path }) => [name, path]))
}
// 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, 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
// 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 }
}
if (isMain(import.meta.url)) {
const { values } = parseArgs({ options: {
source: { type: 'string' }, product: { type: 'string' }, out: { type: 'string' },
} })
if (!values.source || !values.product || !values.out) throw new Error('--source, --product and --out are required')
console.log(JSON.stringify(productCurrent(values)))
}