fix(update): reuse the packaged desktop app when no desktop build input changed

hermes update rebuilt and repackaged the Electron app (6-38 min on Windows)
on every commit move, because _packaged_desktop_current_for_head requires the
app's baked commit to be HEAD before the freshness receipt is consulted.

The receipt already content-hashes every desktop build input (sources,
lockfile, build scripts, prepared icons/native trees, renderer env, Node,
platform, arch) and the output. The baked install stamp is the one input it
cannot see. desktop_build_reuse asks whether a build at HEAD would bake a
stamp that differs in anything but commit provenance (commit, commitDate,
dirty, builtAt), using write-build-stamp.mjs's own resolver, and reuses the
packaged app when the receipt is current and it would not.

The reused app keeps naming the commit it was built from: the stamp is never
rewritten (bundle-swap would relaunch into a "swap" forever), and bundle-skew
counts desktop runtime-path commits since it, which identical inputs rule out.
This commit is contained in:
Teknium committed 2026-10-27 07:53:07 -07:00
1 parent 5c6660eaf5
commit 7a954418ce
3 files changed
+108

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
"""Reuse a packaged desktop app across commits that changed none of its build inputs.
``hermes update`` used to rebuild and repackage the Electron app (6-38 min on Windows) whenever HEAD
moved, because ``_packaged_desktop_current_for_head`` requires the app's baked commit to BE HEAD. The
freshness receipt already proves everything else the build reads is byte-identical; the baked
install stamp is the one input it cannot see, since only a build rewrites it. This module answers
the missing half: would a build at HEAD bake a stamp that differs in anything but commit provenance?
The reused app keeps naming the commit it was really built from (never rewritten: the running app
compares its boot stamp with the one on disk and would relaunch into the "swap" forever). Every
reader of that commit tolerates an older one: bundle skew counts only desktop runtime-path commits
since it, which identical inputs rule out, and bootstrap repair follows the checkout's own HEAD.
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
from typing import Optional
def _stamp_drift(project_root: Path, baked: Path, env: dict) -> Optional[list[str]]:
"""Fields a build at HEAD would bake differently, or None when that cannot be told."""
from pm import installed_package
installed = installed_package("node")
if installed is None or installed.binary is None:
return None
try:
result = subprocess.run(
[str(installed.binary), str(project_root / "scripts/build/desktop-reuse.mjs"),
"--source", str(project_root), "--baked", str(baked)],
cwd=project_root, env=env, capture_output=True, text=True, encoding="utf-8",
errors="replace", timeout=60, check=True,
)
drift = json.loads(result.stdout.strip().splitlines()[-1])
except (OSError, subprocess.SubprocessError, ValueError, IndexError):
return None
return drift if isinstance(drift, list) else None
def packaged_desktop_reusable(desktop_dir: Path, project_root: Path, env: dict) -> Optional[str]:
"""The commit the packaged app was built from, when it can stand for HEAD unchanged.
Requires a current freshness receipt for the packaged bundle (inputs and output intact, not
torn, native binary present) whose baked stamp is the prepared stamp that receipt covers, and a
stamp a build at HEAD would bake that differs only in commit provenance. Anything unreadable
means build.
"""
from hermes_cli.main_desktop import _desktop_build_needed, _packaged_resources_dir
resources = _packaged_resources_dir(desktop_dir)
if resources is None:
return None
baked_path = resources / "install-stamp.json"
try:
baked = json.loads(baked_path.read_text(encoding="utf-8-sig"))
prepared = json.loads((desktop_dir / "build/install-stamp.json").read_text(encoding="utf-8-sig"))
except (OSError, ValueError):
return None
commit = baked.get("commit") if isinstance(baked, dict) else None
if not isinstance(commit, str) or not commit or prepared != baked:
return None
if _desktop_build_needed(desktop_dir, project_root, source_mode=False):
return None
if sys.platform == "win32":
# The build resolves HEAD and branch with PM's git (build_prepared_desktop); so must this.
import pm
env = pm.ensure("git", base_env=env).env
return commit if _stamp_drift(project_root, baked_path, env) == [] else None
+3
View File
@@ -273,12 +273,15 @@ def _rebuild_served_webapp(project_root: Path, publish_stage) -> None:
def _build_desktop_product(project_root: Path, env: dict, publish_stage) -> None:
from hermes_cli.desktop_build_reuse import packaged_desktop_reusable
from hermes_cli.main_desktop import (
_packaged_desktop_current_for_head, _refresh_installed_desktop_apps, build_prepared_desktop)
desktop_dir = project_root / "apps/desktop"
if _packaged_desktop_current_for_head(desktop_dir, project_root):
print(" ✓ Desktop app is up to date")
elif built_from := packaged_desktop_reusable(desktop_dir, project_root, env):
print(f" ✓ Desktop app is up to date: no desktop build input changed since {built_from[:12]}")
else:
publish_stage("Building the desktop app")
# The desktop build mutates checkout-scoped node_modules and
+33
View File
@@ -0,0 +1,33 @@
// Can a packaged desktop app built at an earlier commit stand for HEAD?
//
// The freshness receipt already proves the bundle's inputs are byte-identical
// (sources, lockfile, build scripts, prepared icons/native trees, renderer env,
// Node/platform/arch). The one input it cannot see is the install stamp a build
// at HEAD would bake, because only a build rewrites build/install-stamp.json.
// This compares that would-be stamp with the baked one: provenance that moves
// with every commit (commit, its date, the dirty flag, the build clock) is
// expected to differ; anything else (branch, payload, update mechanism, tag,
// channel, version strings) changes the shipped app and means "build".
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { parseArgs } from 'node:util'
import { pathToFileURL } from 'node:url'
import { isMain } from './frontend-common.mjs'
const commitProvenance = new Set(['commit', 'commitDate', 'dirty', 'builtAt'])
/** Stamp fields a build at HEAD would bake differently from ``baked`` (empty = it may stand). */
export async function stampDrift({ source, baked, env = process.env, platform = process.platform }) {
const stampModule = join(resolve(source), 'apps/desktop/scripts/write-build-stamp.mjs')
const { resolveStamp, buildStampPayload } = await import(pathToFileURL(stampModule).href)
const fresh = buildStampPayload(resolveStamp({ env, repoRoot: resolve(source) }), env, platform, null)
return [...new Set([...Object.keys(baked), ...Object.keys(fresh)])].sort()
.filter(key => !commitProvenance.has(key) && JSON.stringify(baked[key]) !== JSON.stringify(fresh[key]))
}
if (isMain(import.meta.url)) {
const { values } = parseArgs({ options: { source: { type: 'string' }, baked: { type: 'string' } } })
if (!values.source || !values.baked) throw new Error('--source and --baked are required')
const baked = JSON.parse(readFileSync(values.baked, 'utf8'))
console.log(JSON.stringify(await stampDrift({ source: values.source, baked })))
}