From 3135fbbf49fef6199cdf1882eb9a3141af7ff098 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Wed, 30 Sep 2026 22:57:10 -0700 Subject: [PATCH] feat(runtime): pin the Node 24.21.0 server runtime with an offline CI check (#24087) * feat(runtime): pin the Node 24.21.0 server runtime with an offline CI check Add src/shared/node-runtime-pin.ts (NODE_RUNTIME_PIN, SERVER_TARGETS, NODE_RUNTIME_ASSETS for all 8 server targets plus the headers tarball), generated by config/scripts/update-node-runtime-pin.mjs from the nodejs.org and unofficial-builds SHASUMS. check-node-runtime-pin.mjs verifies, with no network, that the pin tracks the locked Electron, matches engines.node's major, and covers exactly SERVER_TARGETS; it runs in the static analysis job. ORCAD_BUN_TARGETS consumers now read SERVER_TARGETS so there is one target list; orcad's Bun runtime and build output are unchanged. * fix(runtime): reject a pinned archive that belongs to another target --------- Co-authored-by: m4air --- .github/workflows/pr.yml | 3 + config/scripts/build-orcad-template.mjs | 2 +- config/scripts/check-node-runtime-pin.mjs | 133 +++++++ .../scripts/check-node-runtime-pin.test.mjs | 164 +++++++++ .../scripts/orcad-artifact-version.test.mjs | 4 +- .../scripts/orcad-template-test-fixture.mjs | 2 +- config/scripts/orcad-watcher-package.mjs | 4 +- config/scripts/update-node-runtime-pin.mjs | 340 ++++++++++++++++++ .../scripts/update-node-runtime-pin.test.mjs | 97 +++++ .../verify-packaged-orcad-template.cjs | 2 +- package.json | 3 +- src/main/orcad/orcad-artifact-identity.ts | 4 +- src/shared/node-runtime-pin.ts | 134 +++++++ src/shared/orcad-bun-runtime.ts | 21 +- 14 files changed, 886 insertions(+), 27 deletions(-) create mode 100644 config/scripts/check-node-runtime-pin.mjs create mode 100644 config/scripts/check-node-runtime-pin.test.mjs create mode 100644 config/scripts/update-node-runtime-pin.mjs create mode 100644 config/scripts/update-node-runtime-pin.test.mjs create mode 100644 src/shared/node-runtime-pin.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c2b35b7bce1..45d574d1b6d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -267,6 +267,9 @@ jobs: - name: Enforce runtime Electron-import ratchet run: pnpm run check:runtime-electron-ratchet + - name: Check Node runtime pin + run: pnpm run check:node-runtime-pin + # Why: extraction writes sorted evidence to an isolated temporary path, # so feature PRs need one normalized AST pass rather than a three-OS matrix. - name: Verify localization extraction diff --git a/config/scripts/build-orcad-template.mjs b/config/scripts/build-orcad-template.mjs index a69a6ee8485..c4f72c6a527 100644 --- a/config/scripts/build-orcad-template.mjs +++ b/config/scripts/build-orcad-template.mjs @@ -19,7 +19,7 @@ import { orcadTemplateCommonFilenames } from '../../src/shared/orcad-artifacts.ts' import { orcadAgentBrowserNativeName } from '../../src/shared/orcad-agent-browser-name.ts' -import { ORCAD_TEMPLATE_TARGETS } from '../../src/shared/orcad-bun-runtime.ts' +import { ORCAD_TEMPLATE_TARGETS } from '../../src/shared/node-runtime-pin.ts' import { runProcessSync } from './script-child-process.mjs' import { materializeWatcherPackage } from './orcad-watcher-package.mjs' import { verifyPackagedOrcadTemplate } from './verify-packaged-orcad-template.cjs' diff --git a/config/scripts/check-node-runtime-pin.mjs b/config/scripts/check-node-runtime-pin.mjs new file mode 100644 index 00000000000..1e1b7d6a8ee --- /dev/null +++ b/config/scripts/check-node-runtime-pin.mjs @@ -0,0 +1,133 @@ +#!/usr/bin/env node +// Static, offline consistency gate for src/shared/node-runtime-pin.ts; update-node-runtime-pin.mjs owns the network. + +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { parseAllDocuments } from 'yaml' +import { + NODE_RUNTIME_ASSETS, + NODE_RUNTIME_PIN, + SERVER_TARGETS +} from '../../src/shared/node-runtime-pin.ts' +import { nodeDistArchiveName } from './update-node-runtime-pin.mjs' + +const SHA256 = /^[0-9a-f]{64}$/ +const ASSET_SOURCES = new Set(['official', 'unofficial']) + +function majorOf(range) { + const match = /(\d+)/.exec(String(range ?? '')) + return match ? Number(match[1]) : null +} + +/** Strips pnpm's peer suffix: `43.7.5(supports-color@7.2.0)` -> `43.7.5`. */ +function lockedVersion(entry) { + const version = typeof entry === 'string' ? entry : entry?.version + return typeof version === 'string' ? version.replace(/\(.*$/, '') : null +} + +/** pnpm 12 splits the lockfile into a package-manager document and the project one; merge both. */ +export function lockfileRootImporter(contents) { + const importer = {} + for (const document of parseAllDocuments(contents)) { + if (document.errors.length) { + throw document.errors[0] + } + Object.assign(importer, document.toJS()?.importers?.['.']) + } + return importer +} + +export function findNodeRuntimePinProblems({ pin, assets, targets, packageJson, rootImporter }) { + const problems = [] + const declaredElectron = + packageJson.devDependencies?.electron ?? packageJson.dependencies?.electron + if (declaredElectron !== pin.electron) { + problems.push( + `package.json electron is ${declaredElectron}, but NODE_RUNTIME_PIN.electron is ${pin.electron}` + ) + } + const lockedElectron = lockedVersion( + rootImporter.devDependencies?.electron ?? rootImporter.dependencies?.electron + ) + if (lockedElectron !== pin.electron) { + problems.push( + `pnpm-lock.yaml resolves electron ${lockedElectron}, but NODE_RUNTIME_PIN.electron is ${pin.electron}` + ) + } + // Only the major is gated here; whether the pin may differ from Electron's Node is design D1 + // (docs/reference/node-runtime-design.html). + const engineMajor = majorOf(packageJson.engines?.node) + if (majorOf(pin.version) !== engineMajor) { + problems.push( + `NODE_RUNTIME_PIN.version ${pin.version} is not package.json engines.node major ${engineMajor}` + ) + } + if (!Number.isInteger(pin.napi) || pin.napi < 1) { + problems.push(`NODE_RUNTIME_PIN.napi must be a positive integer, got ${pin.napi}`) + } + if (!SHA256.test(pin.headers?.sha256 ?? '')) { + problems.push('NODE_RUNTIME_PIN.headers.sha256 is not a 64-character hex SHA-256') + } + if (pin.headers?.file !== `node-v${pin.version}-headers.tar.gz`) { + problems.push(`NODE_RUNTIME_PIN.headers.file ${pin.headers?.file} is not for ${pin.version}`) + } + + const expected = new Set(targets) + for (const target of targets) { + if (!Object.hasOwn(assets, target)) { + problems.push(`NODE_RUNTIME_ASSETS has no entry for ${target}`) + } + } + for (const [target, asset] of Object.entries(assets)) { + if (!expected.has(target)) { + problems.push(`NODE_RUNTIME_ASSETS has ${target}, which is not in SERVER_TARGETS`) + continue + } + if (!ASSET_SOURCES.has(asset.source)) { + problems.push(`${target}: source must be official or unofficial, got ${asset.source}`) + } + const expectedArchive = nodeDistArchiveName(pin.version, target) + if (asset.archive !== expectedArchive) { + problems.push(`${target}: archive ${asset.archive} is not ${expectedArchive}`) + } + if (!SHA256.test(asset.archiveSha256 ?? '')) { + problems.push(`${target}: archiveSha256 is not a 64-character hex SHA-256`) + } + if (!SHA256.test(asset.executableSha256 ?? '')) { + problems.push(`${target}: executableSha256 is not a 64-character hex SHA-256`) + } + if (!Number.isInteger(asset.executableSize) || asset.executableSize <= 0) { + problems.push(`${target}: executableSize must be a positive integer`) + } + } + return problems +} + +export function main(root = resolve(import.meta.dirname, '../..')) { + const problems = findNodeRuntimePinProblems({ + pin: NODE_RUNTIME_PIN, + assets: NODE_RUNTIME_ASSETS, + targets: SERVER_TARGETS, + packageJson: JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')), + rootImporter: lockfileRootImporter(readFileSync(join(root, 'pnpm-lock.yaml'), 'utf8')) + }) + if (problems.length > 0) { + console.error('Node runtime pin check failed:') + for (const problem of problems) { + console.error(`- ${problem}`) + } + console.error( + 'Regenerate with: node config/scripts/update-node-runtime-pin.mjs --version ' + ) + return 1 + } + console.log( + `Node runtime pin check passed: Node ${NODE_RUNTIME_PIN.version} for Electron ${NODE_RUNTIME_PIN.electron}.` + ) + return 0 +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exit(main()) +} diff --git a/config/scripts/check-node-runtime-pin.test.mjs b/config/scripts/check-node-runtime-pin.test.mjs new file mode 100644 index 00000000000..cf9ce889a11 --- /dev/null +++ b/config/scripts/check-node-runtime-pin.test.mjs @@ -0,0 +1,164 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' +import { + NODE_RUNTIME_ASSETS, + NODE_RUNTIME_PIN, + SERVER_TARGETS +} from '../../src/shared/node-runtime-pin.ts' +import { + findNodeRuntimePinProblems, + lockfileRootImporter, + main +} from './check-node-runtime-pin.mjs' + +const projectDir = path.resolve(import.meta.dirname, '../..') +const HASH = 'a'.repeat(64) + +function validInput() { + const pin = { + version: '24.21.0', + electron: '43.7.5', + napi: 10, + headers: { file: 'node-v24.21.0-headers.tar.gz', sha256: HASH } + } + const targets = ['linux-x64-glibc', 'win32-x64'] + const assets = { + 'linux-x64-glibc': { + source: 'official', + archive: 'node-v24.21.0-linux-x64.tar.gz', + archiveSha256: HASH, + executableSha256: HASH, + executableSize: 1 + }, + 'win32-x64': { + source: 'official', + archive: 'node-v24.21.0-win-x64.zip', + archiveSha256: HASH, + executableSha256: HASH, + executableSize: 1 + } + } + return { + pin, + assets, + targets, + packageJson: { devDependencies: { electron: '43.7.5' }, engines: { node: '24' } }, + rootImporter: { + devDependencies: { + electron: { specifier: '43.7.5', version: '43.7.5(supports-color@7.2.0)' } + } + } + } +} + +describe('findNodeRuntimePinProblems', () => { + it('accepts a consistent pin', () => { + expect(findNodeRuntimePinProblems(validInput())).toEqual([]) + }) + + it('rejects an Electron bump that the pin did not follow', () => { + const input = validInput() + input.packageJson.devDependencies.electron = '43.8.0' + input.rootImporter.devDependencies.electron.version = '43.8.0' + expect(findNodeRuntimePinProblems(input)).toEqual([ + 'package.json electron is 43.8.0, but NODE_RUNTIME_PIN.electron is 43.7.5', + 'pnpm-lock.yaml resolves electron 43.8.0, but NODE_RUNTIME_PIN.electron is 43.7.5' + ]) + }) + + it('rejects a lockfile that resolves a different Electron than package.json', () => { + const input = validInput() + input.rootImporter.devDependencies.electron.version = '43.7.6' + expect(findNodeRuntimePinProblems(input)).toEqual([ + 'pnpm-lock.yaml resolves electron 43.7.6, but NODE_RUNTIME_PIN.electron is 43.7.5' + ]) + }) + + it('rejects a pin major that differs from engines.node', () => { + const input = validInput() + input.packageJson.engines.node = '>=26' + expect(findNodeRuntimePinProblems(input)).toEqual([ + 'NODE_RUNTIME_PIN.version 24.21.0 is not package.json engines.node major 26' + ]) + }) + + it('requires exactly one asset per server target', () => { + const input = validInput() + delete input.assets['win32-x64'] + input.assets['freebsd-x64'] = input.assets['linux-x64-glibc'] + expect(findNodeRuntimePinProblems(input)).toEqual([ + 'NODE_RUNTIME_ASSETS has no entry for win32-x64', + 'NODE_RUNTIME_ASSETS has freebsd-x64, which is not in SERVER_TARGETS' + ]) + }) + + it('rejects malformed hashes, sizes, sources and stale archive names', () => { + const input = validInput() + input.pin.headers.sha256 = 'ABC' + input.assets['linux-x64-glibc'] = { + source: 'mirror', + archive: 'node-v24.20.0-linux-x64.tar.gz', + archiveSha256: HASH.toUpperCase(), + executableSha256: `${HASH}0`, + executableSize: 0 + } + expect(findNodeRuntimePinProblems(input)).toEqual([ + 'NODE_RUNTIME_PIN.headers.sha256 is not a 64-character hex SHA-256', + 'linux-x64-glibc: source must be official or unofficial, got mirror', + 'linux-x64-glibc: archive node-v24.20.0-linux-x64.tar.gz is not node-v24.21.0-linux-x64.tar.gz', + 'linux-x64-glibc: archiveSha256 is not a 64-character hex SHA-256', + 'linux-x64-glibc: executableSha256 is not a 64-character hex SHA-256', + 'linux-x64-glibc: executableSize must be a positive integer' + ]) + }) + it("rejects another target's archive", () => { + const input = validInput() + input.assets['win32-x64'].archive = 'node-v24.21.0-win-arm64.zip' + expect(findNodeRuntimePinProblems(input)).toEqual([ + 'win32-x64: archive node-v24.21.0-win-arm64.zip is not node-v24.21.0-win-x64.zip' + ]) + }) +}) + +describe('lockfileRootImporter', () => { + it('merges the root importer across pnpm 12 lockfile documents', () => { + const contents = [ + '---', + "lockfileVersion: '9.0'", + 'importers:', + ' .:', + ' packageManagerDependencies: {}', + '---', + "lockfileVersion: '9.0'", + 'importers:', + ' .:', + ' devDependencies:', + ' electron:', + ' specifier: 43.7.5', + ' version: 43.7.5(supports-color@7.2.0)', + '' + ].join('\n') + expect(lockfileRootImporter(contents).devDependencies.electron.version).toBe( + '43.7.5(supports-color@7.2.0)' + ) + }) +}) + +describe('committed pin', () => { + it('passes the repository check', () => { + expect(main(projectDir)).toBe(0) + }) + + it('covers every server target', () => { + expect(Object.keys(NODE_RUNTIME_ASSETS).sort()).toEqual([...SERVER_TARGETS].sort()) + expect(NODE_RUNTIME_PIN.headers.file).toBe(`node-v${NODE_RUNTIME_PIN.version}-headers.tar.gz`) + }) + + it('runs in the static analysis job', () => { + const workflow = parse(readFileSync(path.join(projectDir, '.github/workflows/pr.yml'), 'utf8')) + const commands = workflow.jobs.static_analysis.steps.map((step) => step.run ?? '') + expect(commands).toContain('pnpm run check:node-runtime-pin') + }) +}) diff --git a/config/scripts/orcad-artifact-version.test.mjs b/config/scripts/orcad-artifact-version.test.mjs index 01c1ae3f8ff..2b353c4299b 100644 --- a/config/scripts/orcad-artifact-version.test.mjs +++ b/config/scripts/orcad-artifact-version.test.mjs @@ -7,7 +7,7 @@ import { ORCAD_RIPGREP_ARTIFACTS, orcadArtifactFilenames } from '../../src/shared/orcad-artifacts.ts' -import { ORCAD_BUN_TARGETS } from '../../src/shared/orcad-bun-runtime.ts' +import { SERVER_TARGETS } from '../../src/shared/node-runtime-pin.ts' import { orcadAgentBrowserNativeName } from '../../src/shared/orcad-agent-browser-name.ts' import { readOrcadArtifactIdentity } from '../../src/main/orcad/orcad-artifact-identity.ts' import { computeOrcadFullVersion } from './orcad-artifact-version.mjs' @@ -42,7 +42,7 @@ describe('standalone runtime version', () => { expect(() => computeOrcadFullVersion(dir)).toThrow(ORCAD_RIPGREP_ARTIFACTS[0]) }) - it.each(ORCAD_BUN_TARGETS)( + it.each(SERVER_TARGETS)( 'matches the installed %s identity with and without its optional browser', async (target) => { const dir = createArtifactDirectory(target) diff --git a/config/scripts/orcad-template-test-fixture.mjs b/config/scripts/orcad-template-test-fixture.mjs index 2f11a32e83f..41c097c1140 100644 --- a/config/scripts/orcad-template-test-fixture.mjs +++ b/config/scripts/orcad-template-test-fixture.mjs @@ -7,7 +7,7 @@ import { ORCAD_TEMPLATE_TARGETS_DIR, orcadTemplateCommonFilenames } from '../../src/shared/orcad-artifacts.ts' -import { ORCAD_TEMPLATE_TARGETS } from '../../src/shared/orcad-bun-runtime.ts' +import { ORCAD_TEMPLATE_TARGETS } from '../../src/shared/node-runtime-pin.ts' async function write(path, contents) { await mkdir(dirname(path), { recursive: true }) diff --git a/config/scripts/orcad-watcher-package.mjs b/config/scripts/orcad-watcher-package.mjs index 5107d23fe58..e1c8625ad4e 100644 --- a/config/scripts/orcad-watcher-package.mjs +++ b/config/scripts/orcad-watcher-package.mjs @@ -4,7 +4,7 @@ import { createRequire } from 'node:module' import { join, resolve } from 'node:path' import { x as extractTar } from 'tar' import { parseAllDocuments } from 'yaml' -import { ORCAD_BUN_TARGETS } from '../../src/shared/orcad-bun-runtime.ts' +import { SERVER_TARGETS } from '../../src/shared/node-runtime-pin.ts' const root = resolve(import.meta.dirname, '../..') const require = createRequire(import.meta.url) @@ -22,7 +22,7 @@ export function parseWatcherLockfile(contents) { } export function watcherPackageIdentity(target, version, lockfile) { - if (!ORCAD_BUN_TARGETS.includes(target)) { + if (!SERVER_TARGETS.includes(target)) { throw new Error(`Unsupported watcher target: ${target}`) } const name = `@parcel/watcher-${target}` diff --git a/config/scripts/update-node-runtime-pin.mjs b/config/scripts/update-node-runtime-pin.mjs new file mode 100644 index 00000000000..d474dc082dd --- /dev/null +++ b/config/scripts/update-node-runtime-pin.mjs @@ -0,0 +1,340 @@ +#!/usr/bin/env node +// Regenerates the pinned asset table in src/shared/node-runtime-pin.ts. Needs network; CI never runs it. +// Usage: node config/scripts/update-node-runtime-pin.mjs --version 24.21.0 [--work-dir DIR] [--keyring FILE] + +import { createHash } from 'node:crypto' +import { + createReadStream, + createWriteStream, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { Readable } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import { pathToFileURL } from 'node:url' +import { + SERVER_TARGETS, + nodeRuntimeExecutablePath, + nodeRuntimeReleaseUrl +} from '../../src/shared/node-runtime-pin.ts' +import { currentTarget } from './build-orcad-bun.mjs' +import { runProcessSync } from './script-child-process.mjs' +import { getZipExtractorCommand } from './zip-extractor-command.mjs' + +const root = resolve(import.meta.dirname, '../..') +const PIN_FILE = join(root, 'src/shared/node-runtime-pin.ts') +const GENERATED_BEGIN = '// @generated-begin by config/scripts/update-node-runtime-pin.mjs' +const GENERATED_END = '// @generated-end' +const RELEASE_KEYRING_URL = + 'https://raw.githubusercontent.com/nodejs/release-keys/HEAD/gpg/pubring.kbx' + +/** Node's platform suffix for each server target; nodejs.org names Windows `win`, not `win32`. */ +export const NODE_DIST_PLATFORMS = { + 'darwin-arm64': 'darwin-arm64', + 'darwin-x64': 'darwin-x64', + 'linux-arm64-glibc': 'linux-arm64', + 'linux-x64-glibc': 'linux-x64', + 'linux-arm64-musl': 'linux-arm64-musl', + 'linux-x64-musl': 'linux-x64-musl', + 'win32-arm64': 'win-arm64', + 'win32-x64': 'win-x64' +} + +export function nodeDistArchiveName(version, target) { + // Why .tar.gz over .tar.xz: every POSIX host can extract gzip; xz is not guaranteed. + const extension = target.startsWith('win32-') ? 'zip' : 'tar.gz' + return `node-v${version}-${NODE_DIST_PLATFORMS[target]}.${extension}` +} + +export function parseShasums(text) { + const hashes = new Map() + for (const line of text.split('\n')) { + const match = /^([0-9a-f]{64}) {2}(\S+)$/.exec(line.trim()) + if (match) { + hashes.set(match[2], match[1]) + } + } + return hashes +} + +/** Official builds win over unofficial ones when both publish the same archive. */ +export function selectAssetSource(archive, officialHashes, unofficialHashes) { + if (officialHashes.has(archive)) { + return { source: 'official', archiveSha256: officialHashes.get(archive) } + } + if (unofficialHashes.has(archive)) { + return { source: 'unofficial', archiveSha256: unofficialHashes.get(archive) } + } + return null +} + +export function parseNodeApiVersion(nodeVersionHeader) { + const match = /#define NODE_API_SUPPORTED_VERSION_MAX (\d+)/.exec(nodeVersionHeader) + if (!match) { + throw new Error('node_version.h has no NODE_API_SUPPORTED_VERSION_MAX') + } + return Number(match[1]) +} + +export function renderGeneratedBlock(pin, assets) { + const lines = [ + GENERATED_BEGIN, + 'export const NODE_RUNTIME_PIN: NodeRuntimePin = {', + ` version: '${pin.version}',`, + ` electron: '${pin.electron}',`, + ` napi: ${pin.napi},`, + ' headers: {', + ` file: '${pin.headers.file}',`, + ` sha256: '${pin.headers.sha256}'`, + ' }', + '}', + '', + 'export const NODE_RUNTIME_ASSETS: Record = {' + ] + SERVER_TARGETS.forEach((target, index) => { + const asset = assets[target] + lines.push( + ` '${target}': {`, + ` source: '${asset.source}',`, + ` archive: '${asset.archive}',`, + ` archiveSha256: '${asset.archiveSha256}',`, + ` executableSha256: '${asset.executableSha256}',`, + ` executableSize: ${asset.executableSize}`, + index === SERVER_TARGETS.length - 1 ? ' }' : ' },' + ) + }) + lines.push('}', GENERATED_END) + return lines.join('\n') +} + +export function replaceGeneratedBlock(source, block) { + const begin = source.indexOf(GENERATED_BEGIN) + const end = source.indexOf(GENERATED_END) + if (begin === -1 || end === -1 || end < begin) { + throw new Error('node-runtime-pin.ts is missing its @generated markers') + } + return source.slice(0, begin) + block + source.slice(end + GENERATED_END.length) +} + +function argument(name) { + const index = process.argv.indexOf(name) + return index === -1 ? null : process.argv[index + 1] +} + +async function fetchText(url) { + const response = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(60_000) }) + if (!response.ok) { + await response.body?.cancel() + throw new Error(`GET ${url} failed: ${response.status} ${response.statusText}`) + } + return response.text() +} + +async function download(url, destination) { + const response = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(600_000) }) + if (!response.ok || !response.body) { + await response.body?.cancel() + throw new Error(`GET ${url} failed: ${response.status} ${response.statusText}`) + } + await pipeline(Readable.fromWeb(response.body), createWriteStream(destination)) +} + +async function sha256File(path) { + const hash = createHash('sha256') + await pipeline(createReadStream(path), hash) + return hash.digest('hex') +} + +function run(program, args) { + const result = runProcessSync({ program, args, timeoutMs: 300_000 }) + if (result.code !== 0) { + throw new Error( + `${program} ${args.join(' ')} exited ${result.code}: ${result.stderr || result.stdout}` + ) + } + return result.stdout +} + +function tarProgram() { + return process.platform === 'win32' + ? join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe') + : 'tar' +} + +function extract(archivePath, destination, member) { + mkdirSync(destination, { recursive: true }) + if (archivePath.endsWith('.zip')) { + const command = getZipExtractorCommand(archivePath, destination) + run(command.file, command.args) + return + } + run(tarProgram(), ['-xzf', archivePath, '-C', destination, member]) +} + +function gpgAvailable() { + try { + return runProcessSync({ program: 'gpg', args: ['--version'], timeoutMs: 10_000 }).code === 0 + } catch { + return false + } +} + +async function verifyOfficialShasums(version, workDir, shasumsPath) { + if (!gpgAvailable()) { + console.warn( + '\n!!! WARNING: gpg is not installed, so SHASUMS256.txt was NOT signature-verified.\n' + + '!!! Its hashes are trusted over TLS only. Install gpg and rerun before committing a pin.\n' + ) + return false + } + const signaturePath = join(workDir, 'SHASUMS256.txt.sig') + await download(nodeRuntimeReleaseUrl('official', 'SHASUMS256.txt.sig', version), signaturePath) + let keyring = argument('--keyring') + if (!keyring) { + keyring = join(workDir, 'nodejs-release-keys.kbx') + try { + await download(RELEASE_KEYRING_URL, keyring) + } catch (error) { + console.warn( + `\n!!! WARNING: could not fetch Node release keys (${error.message}); ` + + 'SHASUMS256.txt was NOT signature-verified.\n' + ) + return false + } + } + const gnupgHome = join(workDir, 'gnupg') + mkdirSync(gnupgHome, { recursive: true, mode: 0o700 }) + const result = runProcessSync({ + program: 'gpg', + args: [ + '--homedir', + gnupgHome, + '--no-default-keyring', + '--keyring', + resolve(keyring), + '--verify', + signaturePath, + shasumsPath + ], + timeoutMs: 60_000 + }) + if (result.code !== 0) { + throw new Error(`SHASUMS256.txt signature verification failed:\n${result.stderr}`) + } + console.log('Verified SHASUMS256.txt signature against the Node.js release keys.') + return true +} + +async function pinTarget({ version, napi, target, workDir, officialHashes, unofficialHashes }) { + const archive = nodeDistArchiveName(version, target) + const selected = selectAssetSource(archive, officialHashes, unofficialHashes) + if (!selected) { + // Why fail: a bump must not ship with a target that has no runtime (design D1 risks). + throw new Error(`No published ${archive} for ${target}; the pin cannot move to ${version}`) + } + const archivePath = join(workDir, archive) + await download(nodeRuntimeReleaseUrl(selected.source, archive, version), archivePath) + const actual = await sha256File(archivePath) + if (actual !== selected.archiveSha256) { + throw new Error(`${archive} hash ${actual} does not match SHASUMS ${selected.archiveSha256}`) + } + const member = nodeRuntimeExecutablePath(target, archive) + const extracted = join(workDir, `extract-${target}`) + extract(archivePath, extracted, member) + const executablePath = join(extracted, member) + const asset = { + source: selected.source, + archive, + archiveSha256: selected.archiveSha256, + executableSha256: await sha256File(executablePath), + executableSize: statSync(executablePath).size + } + if (target === currentTarget()) { + const reported = run(executablePath, [ + '-p', + '`${process.version} ${process.versions.napi}`' + ]).trim() + if (reported !== `v${version} ${napi}`) { + throw new Error(`${member} reports ${reported}, expected v${version} ${napi}`) + } + } + rmSync(extracted, { recursive: true, force: true }) + rmSync(archivePath, { force: true }) + console.log(`${target}: ${asset.source} ${archive} (${asset.executableSize} bytes)`) + return asset +} + +async function pinHeaders(version, workDir, officialHashes) { + const file = `node-v${version}-headers.tar.gz` + const sha256 = officialHashes.get(file) + if (!sha256) { + throw new Error(`SHASUMS256.txt lists no ${file}`) + } + const archivePath = join(workDir, file) + await download(nodeRuntimeReleaseUrl('official', file, version), archivePath) + const actual = await sha256File(archivePath) + if (actual !== sha256) { + throw new Error(`${file} hash ${actual} does not match SHASUMS ${sha256}`) + } + const member = `node-v${version}/include/node/node_version.h` + const extracted = join(workDir, 'extract-headers') + extract(archivePath, extracted, member) + const napi = parseNodeApiVersion(readFileSync(join(extracted, member), 'utf8')) + return { headers: { file, sha256 }, napi } +} + +function pinnedElectronVersion() { + const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) + const declared = pkg.devDependencies?.electron ?? pkg.dependencies?.electron + if (!/^\d+\.\d+\.\d+$/.test(declared ?? '')) { + throw new Error(`package.json must pin an exact electron version, found ${declared}`) + } + return declared +} + +async function main() { + const version = argument('--version') + if (!/^\d+\.\d+\.\d+$/.test(version ?? '')) { + throw new Error('Usage: update-node-runtime-pin.mjs --version ') + } + const workParent = argument('--work-dir') ?? tmpdir() + mkdirSync(workParent, { recursive: true }) + const workDir = mkdtempSync(join(workParent, 'orca-node-runtime-pin-')) + try { + const shasumsPath = join(workDir, 'SHASUMS256.txt') + await download(nodeRuntimeReleaseUrl('official', 'SHASUMS256.txt', version), shasumsPath) + await verifyOfficialShasums(version, workDir, shasumsPath) + const officialHashes = parseShasums(readFileSync(shasumsPath, 'utf8')) + const unofficialHashes = parseShasums( + await fetchText(nodeRuntimeReleaseUrl('unofficial', 'SHASUMS256.txt', version)) + ) + const { headers, napi } = await pinHeaders(version, workDir, officialHashes) + const assets = {} + for (const target of SERVER_TARGETS) { + assets[target] = await pinTarget({ + version, + napi, + target, + workDir, + officialHashes, + unofficialHashes + }) + } + const pin = { version, electron: pinnedElectronVersion(), napi, headers } + const source = readFileSync(PIN_FILE, 'utf8') + writeFileSync(PIN_FILE, replaceGeneratedBlock(source, renderGeneratedBlock(pin, assets))) + console.log(`Wrote ${PIN_FILE}. Run check-node-runtime-pin.mjs before committing.`) + } finally { + rmSync(workDir, { recursive: true, force: true }) + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main() +} diff --git a/config/scripts/update-node-runtime-pin.test.mjs b/config/scripts/update-node-runtime-pin.test.mjs new file mode 100644 index 00000000000..81a82e67102 --- /dev/null +++ b/config/scripts/update-node-runtime-pin.test.mjs @@ -0,0 +1,97 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { + NODE_RUNTIME_ASSETS, + NODE_RUNTIME_PIN, + SERVER_TARGETS, + nodeRuntimeExecutablePath +} from '../../src/shared/node-runtime-pin.ts' +import { + nodeDistArchiveName, + parseNodeApiVersion, + parseShasums, + renderGeneratedBlock, + replaceGeneratedBlock, + selectAssetSource +} from './update-node-runtime-pin.mjs' + +const pinFile = path.resolve(import.meta.dirname, '../../src/shared/node-runtime-pin.ts') +const HASH_A = 'a'.repeat(64) +const HASH_B = 'b'.repeat(64) + +describe('nodeDistArchiveName', () => { + it('uses nodejs.org platform names and zip only on Windows', () => { + expect(nodeDistArchiveName('24.21.0', 'linux-x64-glibc')).toBe('node-v24.21.0-linux-x64.tar.gz') + expect(nodeDistArchiveName('24.21.0', 'linux-arm64-musl')).toBe( + 'node-v24.21.0-linux-arm64-musl.tar.gz' + ) + expect(nodeDistArchiveName('24.21.0', 'win32-arm64')).toBe('node-v24.21.0-win-arm64.zip') + }) + + it('covers every server target', () => { + for (const target of SERVER_TARGETS) { + expect(nodeDistArchiveName('24.21.0', target)).not.toContain('undefined') + } + }) +}) + +describe('nodeRuntimeExecutablePath', () => { + it('points at bin/node on POSIX and node.exe on Windows', () => { + expect(nodeRuntimeExecutablePath('darwin-arm64', 'node-v24.21.0-darwin-arm64.tar.gz')).toBe( + 'node-v24.21.0-darwin-arm64/bin/node' + ) + expect(nodeRuntimeExecutablePath('win32-x64', 'node-v24.21.0-win-x64.zip')).toBe( + 'node-v24.21.0-win-x64/node.exe' + ) + }) +}) + +describe('parseShasums and selectAssetSource', () => { + const official = parseShasums( + `${HASH_A} node-v24.21.0-linux-x64-musl.tar.gz\nnot a hash line\n${HASH_A} node-v24.21.0-linux-x64.tar.gz\n` + ) + const unofficial = parseShasums( + `${HASH_B} node-v24.21.0-linux-x64-musl.tar.gz\n${HASH_B} node-v24.21.0-linux-arm64-musl.tar.gz\n` + ) + + it('prefers the official build when both publish an archive', () => { + expect(selectAssetSource('node-v24.21.0-linux-x64-musl.tar.gz', official, unofficial)).toEqual({ + source: 'official', + archiveSha256: HASH_A + }) + }) + + it('falls back to unofficial builds and reports a missing archive as null', () => { + expect( + selectAssetSource('node-v24.21.0-linux-arm64-musl.tar.gz', official, unofficial) + ).toEqual({ source: 'unofficial', archiveSha256: HASH_B }) + expect(selectAssetSource('node-v24.21.0-aix-ppc64.tar.gz', official, unofficial)).toBeNull() + }) +}) + +describe('parseNodeApiVersion', () => { + it('reads the highest supported N-API version from node_version.h', () => { + expect( + parseNodeApiVersion( + '#define NODE_API_SUPPORTED_VERSION_MAX 10\n#define NODE_API_SUPPORTED_VERSION_MIN 1\n' + ) + ).toBe(10) + expect(() => parseNodeApiVersion('#define NODE_MAJOR_VERSION 24')).toThrow() + }) +}) + +describe('generated block', () => { + it('reproduces the committed table byte for byte', () => { + const source = readFileSync(pinFile, 'utf8') + const regenerated = replaceGeneratedBlock( + source, + renderGeneratedBlock(NODE_RUNTIME_PIN, NODE_RUNTIME_ASSETS) + ) + expect(regenerated).toBe(source) + }) + + it('refuses a file without markers', () => { + expect(() => replaceGeneratedBlock('export {}\n', 'x')).toThrow(/markers/) + }) +}) diff --git a/config/scripts/verify-packaged-orcad-template.cjs b/config/scripts/verify-packaged-orcad-template.cjs index ad0e3fe485a..ae08788ab34 100644 --- a/config/scripts/verify-packaged-orcad-template.cjs +++ b/config/scripts/verify-packaged-orcad-template.cjs @@ -7,7 +7,7 @@ const { ORCAD_TEMPLATE_TARGETS_DIR, orcadTemplateCommonFilenames } = require('../../src/shared/orcad-artifacts.ts') -const { ORCAD_TEMPLATE_TARGETS } = require('../../src/shared/orcad-bun-runtime.ts') +const { ORCAD_TEMPLATE_TARGETS } = require('../../src/shared/node-runtime-pin.ts') const SHA256_PATTERN = /^[a-f0-9]{64}$/ const BROWSER_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/ diff --git a/package.json b/package.json index 7e342f2f358..6afa5141168 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "audit:perf": "oxlint --config config/oxlint-performance-audit.json --format json src", "test:perf:contracts": "vitest run --config config/vitest.performance.config.ts", "format": "oxfmt --write .", - "lint": "oxlint && pnpm run audit:anti-slop && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:readme-local-links && pnpm run verify:rpc-params-catalog && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalogs && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage", + "lint": "oxlint && pnpm run audit:anti-slop && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:readme-local-links && pnpm run check:node-runtime-pin && pnpm run verify:rpc-params-catalog && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalogs && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage", "audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor", "audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings", "audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings", @@ -39,6 +39,7 @@ "check:ts-nocheck-ratchet": "node config/scripts/check-ts-nocheck-ratchet.mjs", "check:runtime-electron-ratchet": "node config/scripts/check-runtime-electron-ratchet.mjs", "check:readme-local-links": "node config/scripts/check-readme-local-links.mjs", + "check:node-runtime-pin": "node config/scripts/check-node-runtime-pin.mjs", "build:orcad": "node config/scripts/build-orcad-bun.mjs", "build:orcad-template": "node config/scripts/build-orcad-template.mjs", "build:orcad-prebuilds": "node config/scripts/build-orcad-prebuilds.mjs", diff --git a/src/main/orcad/orcad-artifact-identity.ts b/src/main/orcad/orcad-artifact-identity.ts index 0049ef10ffa..9de0765b0cf 100644 --- a/src/main/orcad/orcad-artifact-identity.ts +++ b/src/main/orcad/orcad-artifact-identity.ts @@ -9,13 +9,13 @@ import { orcadArtifactFilenames, orcadArtifactHashPrefix } from '../../shared/orcad-artifacts' -import { ORCAD_BUN_TARGETS } from '../../shared/orcad-bun-runtime' +import { SERVER_TARGETS } from '../../shared/node-runtime-pin' import { orcadAgentBrowserNativeName } from '../../shared/orcad-agent-browser-name' /** Hash installed bytes in the build's order; a version marker is not proof of delivery. */ export async function readOrcadArtifactIdentity(directory: string): Promise { const target = z - .enum(ORCAD_BUN_TARGETS) + .enum(SERVER_TARGETS) .parse((await readFile(join(directory, ORCAD_BUILD_TARGET_FILENAME), 'utf8')).trim()) const platform = target.startsWith('win32-') ? 'win32' diff --git a/src/shared/node-runtime-pin.ts b/src/shared/node-runtime-pin.ts new file mode 100644 index 00000000000..f9f99026e4d --- /dev/null +++ b/src/shared/node-runtime-pin.ts @@ -0,0 +1,134 @@ +/** + * The one Node runtime Orca runs outside Electron (docs/reference/node-runtime-design.html, D1). + * + * Keep this file erasable-only TypeScript — build scripts import it directly under Node's + * type stripping, which rejects enums, namespaces and parameter properties. + */ + +export const SERVER_TARGETS = [ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64-glibc', + 'linux-x64-glibc', + 'linux-arm64-musl', + 'linux-x64-musl', + 'win32-arm64', + 'win32-x64' +] as const + +export type ServerTarget = (typeof SERVER_TARGETS)[number] + +// Managed SSH deployment supports POSIX hosts; Windows uses standalone builds. +export const ORCAD_TEMPLATE_TARGETS = SERVER_TARGETS.filter( + (target) => !target.startsWith('win32-') +) + +export type NodeRuntimePin = { + version: string + /** Electron whose embedded Node this pin tracks; the older/newer policy is open (design D1). */ + electron: string + /** Highest N-API version the runtime supports (NODE_API_SUPPORTED_VERSION_MAX). */ + napi: number + headers: { file: string; sha256: string } +} + +/** unofficial-builds.nodejs.org publishes no SHASUMS signature, so its hash is trusted at pin time. */ +export type NodeRuntimeAssetSource = 'official' | 'unofficial' + +export type NodeRuntimeAsset = { + source: NodeRuntimeAssetSource + archive: string + archiveSha256: string + executableSha256: string + executableSize: number +} + +// @generated-begin by config/scripts/update-node-runtime-pin.mjs +export const NODE_RUNTIME_PIN: NodeRuntimePin = { + version: '24.21.0', + electron: '43.7.5', + napi: 10, + headers: { + file: 'node-v24.21.0-headers.tar.gz', + sha256: '57c6bee2e30bbbee5bd51d6cc343eb992e174b56a2a1d0eab7a7510771c20ea2' + } +} + +export const NODE_RUNTIME_ASSETS: Record = { + 'darwin-arm64': { + source: 'official', + archive: 'node-v24.21.0-darwin-arm64.tar.gz', + archiveSha256: 'bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057', + executableSha256: 'e4b5a3af0e05c75de2eae013904145f40fe7fc2a6e6f17510128bf45cca4e79b', + executableSize: 122129232 + }, + 'darwin-x64': { + source: 'official', + archive: 'node-v24.21.0-darwin-x64.tar.gz', + archiveSha256: '1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097', + executableSha256: '7abcf39bd37ab251015337ff75304d7555f0d8e88c6e0fbf04bce8ce34636f49', + executableSize: 125270960 + }, + 'linux-arm64-glibc': { + source: 'official', + archive: 'node-v24.21.0-linux-arm64.tar.gz', + archiveSha256: '724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5', + executableSha256: '0f8949d1028f6d61506b2d5bc57e7e6fe893d7b1997509b7847294fc9c616584', + executableSize: 122893672 + }, + 'linux-x64-glibc': { + source: 'official', + archive: 'node-v24.21.0-linux-x64.tar.gz', + archiveSha256: '6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff', + executableSha256: '7fde7b8afa198da66257f42ee2001d874c7355631e6d1579a5fb5ef1f246df4c', + executableSize: 126595440 + }, + 'linux-arm64-musl': { + source: 'unofficial', + archive: 'node-v24.21.0-linux-arm64-musl.tar.gz', + archiveSha256: '3048b0811e158ca0d8672b59c839861763e144492980d8d29b369dfee45747e4', + executableSha256: 'fa2789559dbc3603794a229877c244d1c0d06625c124631611ca4e13eac765be', + executableSize: 128653768 + }, + 'linux-x64-musl': { + source: 'official', + archive: 'node-v24.21.0-linux-x64-musl.tar.gz', + archiveSha256: '3d63405fc65a0d2d2976c1f0bc2fd27bb0bd07212469e705aac3f03ae5ab4c9c', + executableSha256: '2cd83acecc7693ce96bcb4e292ff4c80461b7490028a002abe5a28ac9892bc29', + executableSize: 132204408 + }, + 'win32-arm64': { + source: 'official', + archive: 'node-v24.21.0-win-arm64.zip', + archiveSha256: '8779b1bde1d39f8d420e3b57aa657b39891af434d3de44a919044cec06785921', + executableSha256: 'dff59da18b6ffe1bf1ca99e1d2af4906080c481740619f5b5098c0fca28bd9b7', + executableSize: 81881416 + }, + 'win32-x64': { + source: 'official', + archive: 'node-v24.21.0-win-x64.zip', + archiveSha256: '158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541', + executableSha256: 'ba4e6d110e8c1592a1ecd390f6b05f3da124b13871a5be62b341a07a853c6c32', + executableSize: 93580104 + } +} +// @generated-end + +const NODE_RUNTIME_BASE_URLS: Record = { + official: 'https://nodejs.org/dist', + unofficial: 'https://unofficial-builds.nodejs.org/download/release' +} + +export function nodeRuntimeReleaseUrl( + source: NodeRuntimeAssetSource, + file: string, + version: string = NODE_RUNTIME_PIN.version +): string { + return `${NODE_RUNTIME_BASE_URLS[source]}/v${version}/${file}` +} + +/** Archive-relative path of the executable, e.g. node-v24.21.0-linux-x64/bin/node. */ +export function nodeRuntimeExecutablePath(target: ServerTarget, archive: string): string { + const topLevel = archive.replace(/\.(?:tar\.gz|tar\.xz|zip)$/, '') + return target.startsWith('win32-') ? `${topLevel}/node.exe` : `${topLevel}/bin/node` +} diff --git a/src/shared/orcad-bun-runtime.ts b/src/shared/orcad-bun-runtime.ts index 76fc03258c8..13ab6fb0fc0 100644 --- a/src/shared/orcad-bun-runtime.ts +++ b/src/shared/orcad-bun-runtime.ts @@ -1,22 +1,9 @@ +// Targets come from SERVER_TARGETS. Type-only: a value import needs a .ts suffix tsc rejects. +import type { ServerTarget } from './node-runtime-pin.ts' + export const ORCAD_BUN_VERSION = '1.4.2' -export const ORCAD_BUN_TARGETS = [ - 'darwin-arm64', - 'darwin-x64', - 'linux-arm64-glibc', - 'linux-x64-glibc', - 'linux-arm64-musl', - 'linux-x64-musl', - 'win32-arm64', - 'win32-x64' -] as const - -export type OrcadBunTarget = (typeof ORCAD_BUN_TARGETS)[number] - -// Managed SSH deployment supports POSIX hosts; Windows uses standalone builds. -export const ORCAD_TEMPLATE_TARGETS = ORCAD_BUN_TARGETS.filter( - (target) => !target.startsWith('win32-') -) +export type OrcadBunTarget = ServerTarget export type OrcadBunReleaseAsset = { filename: string