Files
orca/config/scripts/update-node-runtime-pin.mjs
T
OrcaWinandm4air d2dfc79764 ci(daemon): runtime-launcher protocol ratchet and Node slot marker (#24108)
* ci(daemon): gate PRs on daemon protocol crossing from the newest release

Lands daemon-protocol-facts.mjs from the Windows update diagnostic branch with a
stricter parser, and adds check-daemon-protocol-crossing.mjs (rule R1): the working
tree must attach the newest release tag's daemon. Rollback crossing is reported only.
Runs in the cross-version-wire job, which already has full tags; tag selection moves
to config/scripts/stable-release-tags.mjs so both use one rule.

* feat(persistence): run profile backups in the worker whenever its entry is bundled

* refactor(orcad): make profile and native preflight runtime-neutral

The profile preflight parser now takes the expected runtime identity from the
caller (shipped callers pass the pinned Bun identity), and the native
preflight is renamed to orcad-runtime-native-preflight with neutral wording.

* 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.

* test(persistence): skip plain-Node backup selection tests in the Bun profile suite

* fix(runtime): reject a pinned archive that belongs to another target

* ci(daemon): fail PRs that swap a runtime launcher and bump the daemon protocol

D7.1 R3: hosting orcad or the daemon on another runtime is not a protocol change,
so one PR must not do both. The launcher file list lives in the check script; the
allow-runtime-launcher-protocol-bump label overrides it.

* feat(orcad): select pinned-Node slots by a .runtime-node marker

D7.1 R5: a Node slot names its shared runtimes/node-<sha256>/node through
.runtime-node instead of .build-target, so Bun-era clients read it as a legacy
slot rather than exiting 78 on a missing bun-runtime. Nothing builds the marker yet.

* fix(runtime): load the Node pin without the typeless-module warning

check-node-runtime-pin.mjs now requires the pin and takes nodeDistArchiveName from
its own module, so it no longer loads the update script's build graph.

* fix(orcad): resolve Node slots to the design's runtimes/node-<sha>/bin/node layout

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-10-01 00:10:57 -07:00

324 lines
11 KiB
JavaScript

#!/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 { nodeDistArchiveName } from './node-dist-archive-name.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'
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()
}