diff --git a/.github/workflows/desktop-bundled-release.yml b/.github/workflows/desktop-bundled-release.yml index 2e92fdd2ef95a..835f218db71dc 100644 --- a/.github/workflows/desktop-bundled-release.yml +++ b/.github/workflows/desktop-bundled-release.yml @@ -109,6 +109,9 @@ jobs: # ── Node ────────────────────────────────────────────────────────── - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: + # The build script embeds the EXACT host node version into the + # payload. 26 matches NODE_VERSION in scripts/install.sh, so an + # embedded install and a source install run the same node major. node-version: 26 cache: npm diff --git a/nix/packages.nix b/nix/packages.nix index 40316088c3d20..71333fae0412e 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -89,7 +89,7 @@ # nix run .#build-desktop-app-bundle -- --tag=vX.Y.Z build-desktop-app-bundle = pkgs.writeShellApplication { name = "build-desktop-app-bundle"; - runtimeInputs = [ pkgs.nodejs_22 pkgs.uv pkgs.git ]; + runtimeInputs = [ pkgs.nodejs_26 pkgs.uv pkgs.git ]; text = '' if [ ! -f scripts/build-bundled-desktop.mjs ]; then echo "error: run from a hermes-agent checkout root" >&2 diff --git a/scripts/build-bundled-desktop.mjs b/scripts/build-bundled-desktop.mjs index c86e8453c3d76..ba1450be2467d 100644 --- a/scripts/build-bundled-desktop.mjs +++ b/scripts/build-bundled-desktop.mjs @@ -1,12 +1,11 @@ #!/usr/bin/env node // build-bundled-desktop.mjs — build the fully bundled desktop installer -// locally, on any of the three platforms. This is the same sequence as -// .github/workflows/desktop-bundled-release.yml, in one runnable script: +// locally, on any of the three platforms. // // 1. preflight: uv, git, npm exist; a release tag is resolvable // 2. npm ci at the repo root // 3. build ui-tui (with hermes-ink) and the dashboard SPA -// 4. download the payload node dist for this platform (22.x) +// 4. download the payload node dist (the exact host node version) // 5. npm run build in apps/desktop with HERMES_DESKTOP_BUNDLED=1 // 6. npm run builder -- // @@ -27,7 +26,6 @@ import { fileURLToPath } from "node:url" import { hostTarBin } from "../apps/desktop/scripts/stage-agent-payloads.mjs" const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") -const PAYLOAD_NODE_MAJOR = "22" // matches NODE_VERSION in scripts/install.sh const args = process.argv.slice(2) const tagArg = args.find((a) => a.startsWith("--tag="))?.slice("--tag=".length) @@ -68,6 +66,86 @@ for (const tool of ["uv", "git", "npm", "tar"]) { } } +// Toolchain gates. The build's output depends on these tools, so a wrong +// version makes a silently different artifact (the first Windows build +// shipped a wrong-arch uv exactly this way). The rules come from ONE +// source — package.json "engines" — and the embedded runtimes are pinned +// to the EXACT host versions the gates approved: +// node — the payload node dist is downloaded at the host node version. +// uv — the staged uv IS the host binary, copied (stageUvAndPython). +// npm — ships inside the node dist; it cannot be chosen separately, +// so the host npm is gated by engines and the payload npm is +// whatever the pinned node dist bundles. +export function parseVersion(text) { + const match = String(text).match(/(\d+)\.(\d+)\.(\d+)/) + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null +} + +export function compareVersions(a, b) { + for (let i = 0; i < 3; i += 1) { + if (a[i] !== b[i]) return a[i] - b[i] + } + return 0 +} + +// The subset of semver ranges that package.json engines actually uses: +// space-separated comparators AND together, `||` separates alternatives. +// An unparseable comparator fails closed. +export function satisfiesRange(version, range) { + return String(range).split("||").some((alternative) => { + const comparators = alternative.trim().split(/\s+/).filter(Boolean) + if (comparators.length === 0) return false + return comparators.every((comparator) => { + const m = comparator.match(/^(>=|<=|>|<|=)?v?(\d+)\.(\d+)\.(\d+)$/) + if (!m) return false + const cmp = compareVersions(version, [Number(m[2]), Number(m[3]), Number(m[4])]) + switch (m[1]) { + case ">=": return cmp >= 0 + case "<=": return cmp <= 0 + case ">": return cmp > 0 + case "<": return cmp < 0 + default: return cmp === 0 + } + }) + }) +} + +export function uvBannerProblem(banner) { + return /\([a-z0-9_]+-[a-z0-9]+-[a-z]+/.test(String(banner)) + ? null + : "its --version prints no build triple; the payload arch guard needs one (official uv 0.12+, or any nix/source build)" +} + +const engines = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, "package.json"), "utf8")).engines || {} + +// The approved host toolchain. Filled by the gates below; the payload +// stages embed THESE versions, so gate == embed by construction. +const HOST_TOOLCHAIN = { node: null, npm: null, uvBanner: null } + +for (const tool of ["node", "npm"]) { + const text = tool === "node" ? process.version : capture("npm --version") + const version = parseVersion(text) + if (!version) { + fail(`${tool}: cannot parse a version from ${JSON.stringify(text)}`) + } + const range = engines[tool] + if (range && !satisfiesRange(version, range)) { + fail(`${tool} ${version.join(".")} does not satisfy package.json engines ${JSON.stringify(range)} — the build would make a different artifact`) + } + HOST_TOOLCHAIN[tool] = version + console.log(`[build-bundled] ${tool} ${version.join(".")} (engines: ${range || "unconstrained"})`) +} + +{ + const uvBanner = capture("uv --version") + const problem = uvBannerProblem(uvBanner) + if (problem) { + fail(`uv (${uvBanner}) would make a broken artifact: ${problem}`) + } + HOST_TOOLCHAIN.uvBanner = uvBanner + console.log(`[build-bundled] ${uvBanner} (staged into the payload as-is)`) +} + let tag = tagArg if (!tag) { try { @@ -112,6 +190,7 @@ console.log(`[build-bundled] tag=${tag} platform=${process.platform}-${process.a // resolution for the workspace builds below. run("npm", ["ci", "--no-audit", "--no-fund"], { env: { + ...process.env, // spawnSync env REPLACES the child environment; keep PATH etc. "CI": "true" // skip annoying unicode install banner } }) @@ -119,17 +198,22 @@ run("npm", ["run", "build", "--workspace", "ui-tui"]) run("npm", ["run", "build", "--workspace", "web"]) // ── 4. payload node dist ──────────────────────────────────────────────────── +// Pinned to the EXACT host node version: the JS surfaces were built and +// npm-installed by the host node, and the payload node runs them at +// runtime. A different version is a different artifact. This also means +// the host node must be an official nodejs.org release — a patched build +// whose version does not exist upstream fails here, loudly. const distName = { linux: "linux", darwin: "darwin", win32: "win" }[process.platform] const distArch = { x64: "x64", arm64: "arm64" }[process.arch] const distExt = process.platform === "win32" ? "zip" : process.platform === "darwin" ? "tar.gz" : "tar.xz" +const version = `v${HOST_TOOLCHAIN.node.join(".")}` const index = JSON.parse( execSync(`curl -fsSL https://nodejs.org/dist/index.json`, { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }) ) -const version = index.find((e) => e.version.startsWith(`v${PAYLOAD_NODE_MAJOR}.`))?.version -if (!version) { - fail(`no node ${PAYLOAD_NODE_MAJOR}.x release found in nodejs.org index`) +if (!index.some((e) => e.version === version)) { + fail(`host node ${version} is not an official nodejs.org release — cannot embed the exact build toolchain`) } const work = fs.mkdtempSync(path.join(os.tmpdir(), "hermes-node-payload-")) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 1a909101f9364..ed331bb8bbce5 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -381,7 +381,7 @@ $PythonVersion = "3.11" # interpreters, so this list also matches a pre-existing system Python. Single # source of truth shared by Test-Python's fallback and Resolve-AvailablePythonVersion. $PythonFallbackVersions = @("3.12", "3.13", "3.10") -$NodeVersion = "22" +$NodeVersion = "26" # The npm range the root package.json pins in `engines.npm`. A constant rather # than a manifest read like the POSIX side does: Test-Node runs BEFORE the repo # is cloned, so there is usually no package.json on disk yet (and none at all diff --git a/scripts/install.sh b/scripts/install.sh index 23bb54ca6d973..2eec7de7d02f0 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -57,7 +57,7 @@ else INSTALL_DIR_EXPLICIT=false fi PYTHON_VERSION="3.11" -NODE_VERSION="22" +NODE_VERSION="26" # FHS-style root install layout (set by resolve_install_layout when applicable): # code at /usr/local/lib/hermes-agent, command at /usr/local/bin/hermes,