Files
orca/config/scripts/check-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

135 lines
5.1 KiB
JavaScript

#!/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 { createRequire } from 'node:module'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { parseAllDocuments } from 'yaml'
import { nodeDistArchiveName } from './node-dist-archive-name.mjs'
// Why require: an ESM import of a .ts file under a typeless package.json prints MODULE_TYPELESS_PACKAGE_JSON.
const { NODE_RUNTIME_ASSETS, NODE_RUNTIME_PIN, SERVER_TARGETS } = createRequire(import.meta.url)(
'../../src/shared/node-runtime-pin.ts'
)
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())
}