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 <m4air@m4airs-Air.localdomain>
This commit is contained in:
OrcaWin
2026-09-30 22:57:10 -07:00
committed by GitHub
co-authored by m4air
parent 7e5950c1c3
commit 3135fbbf49
14 changed files with 886 additions and 27 deletions
+3
View File
@@ -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
+1 -1
View File
@@ -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'
+133
View File
@@ -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 <x.y.z>'
)
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())
}
@@ -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')
})
})
@@ -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)
@@ -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 })
+2 -2
View File
@@ -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}`
+340
View File
@@ -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<ServerTarget, NodeRuntimeAsset> = {'
]
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 <major.minor.patch>')
}
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()
}
@@ -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/)
})
})
@@ -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._-]*$/
+2 -1
View File
@@ -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",
+2 -2
View File
@@ -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<string> {
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'
+134
View File
@@ -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<ServerTarget, NodeRuntimeAsset> = {
'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<NodeRuntimeAssetSource, string> = {
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`
}
+4 -17
View File
@@ -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