Files
orca/config/scripts/check-runtime-launcher-protocol-ratchet.test.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

182 lines
5.9 KiB
JavaScript

import { execFileSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
RUNTIME_LAUNCHER_PATHS,
RUNTIME_PROTOCOL_OVERRIDE_ENV,
assessRuntimeLauncherProtocolRatchet,
checkRuntimeLauncherProtocolRatchet,
isOverrideEnabled
} from './check-runtime-launcher-protocol-ratchet.mjs'
import { DAEMON_PROTOCOL_SOURCE_PATH } from './daemon-protocol-facts.mjs'
import { classifyPrJobs } from './pr-code-change-scope.mjs'
const repoRoot = resolve(import.meta.dirname, '..', '..')
const repos = []
const LAUNCHER = 'src/main/ssh/orcad-remote-runtime.ts'
function git(repo, args) {
return execFileSync('git', args, {
cwd: repo,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
})
}
function write(repo, path, contents) {
const file = join(repo, path)
mkdirSync(dirname(file), { recursive: true })
writeFileSync(file, contents)
}
function writeProtocol(repo, current) {
const previous = Array.from({ length: current - 1 }, (_, index) => index + 1)
write(
repo,
DAEMON_PROTOCOL_SOURCE_PATH,
`export const PROTOCOL_VERSION = ${current}\nexport const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [${previous.join(', ')}] as const\n`
)
}
function commit(repo, message) {
git(repo, ['add', '.'])
git(repo, [
'-c',
'user.name=test',
'-c',
'user.email=test@example.com',
'commit',
'-q',
'--no-gpg-sign',
'-m',
message
])
return git(repo, ['rev-parse', 'HEAD']).trim()
}
/** A base commit at protocol 3, then a candidate commit shaped by `change`. */
function repoWithCandidate(change) {
const repo = mkdtempSync(join(tmpdir(), 'runtime-launcher-ratchet-'))
repos.push(repo)
git(repo, ['init', '-q'])
writeProtocol(repo, 3)
write(repo, LAUNCHER, 'export const launcher = 1\n')
write(repo, 'src/unrelated.ts', 'export const unrelated = 1\n')
const base = commit(repo, 'base')
change(repo)
commit(repo, 'candidate')
return { repo, base }
}
afterEach(() => {
for (const repo of repos.splice(0)) {
rmSync(repo, { recursive: true, force: true })
}
})
describe('RUNTIME_LAUNCHER_PATHS', () => {
it('names only files that exist, so a rename cannot silently drop one from the gate', () => {
expect(RUNTIME_LAUNCHER_PATHS.filter((path) => !existsSync(join(repoRoot, path)))).toEqual([])
})
})
describe('PR routing', () => {
it('runs the ratchet job when its checker or the protocol changes', () => {
for (const file of [
'config/scripts/check-runtime-launcher-protocol-ratchet.mjs',
DAEMON_PROTOCOL_SOURCE_PATH
]) {
expect(classifyPrJobs([file])['cross-version-wire']).toBe(true)
}
})
})
describe('assessRuntimeLauncherProtocolRatchet', () => {
const launcherAndBump = {
changedFiles: [LAUNCHER, DAEMON_PROTOCOL_SOURCE_PATH],
baseProtocolVersion: 3,
candidateProtocolVersion: 4
}
it('fails a launcher change that also bumps the protocol', () => {
expect(
assessRuntimeLauncherProtocolRatchet({ ...launcherAndBump, overridden: false })
).toMatchObject({
ok: false,
violated: true
})
})
it('lets an explicit override through while still reporting the violation', () => {
const result = assessRuntimeLauncherProtocolRatchet({ ...launcherAndBump, overridden: true })
expect(result).toMatchObject({ ok: true, violated: true })
expect(result.lines.at(-1)).toContain('OVERRIDDEN')
})
it('passes a protocol bump without a launcher change, and a launcher change without a bump', () => {
expect(
assessRuntimeLauncherProtocolRatchet({
...launcherAndBump,
changedFiles: [DAEMON_PROTOCOL_SOURCE_PATH],
overridden: false
}).ok
).toBe(true)
expect(
assessRuntimeLauncherProtocolRatchet({
...launcherAndBump,
candidateProtocolVersion: 3,
overridden: false
}).ok
).toBe(true)
})
})
describe('isOverrideEnabled', () => {
it('accepts only an explicit true', () => {
expect(isOverrideEnabled({ [RUNTIME_PROTOCOL_OVERRIDE_ENV]: 'true' })).toBe(true)
expect(isOverrideEnabled({ [RUNTIME_PROTOCOL_OVERRIDE_ENV]: '1' })).toBe(true)
expect(isOverrideEnabled({ [RUNTIME_PROTOCOL_OVERRIDE_ENV]: 'false' })).toBe(false)
expect(isOverrideEnabled({ [RUNTIME_PROTOCOL_OVERRIDE_ENV]: '' })).toBe(false)
expect(isOverrideEnabled({})).toBe(false)
})
})
describe('checkRuntimeLauncherProtocolRatchet against git history', () => {
it('fails when the diff from the base both edits a launcher and bumps PROTOCOL_VERSION', () => {
const { repo, base } = repoWithCandidate((candidate) => {
writeProtocol(candidate, 4)
write(candidate, LAUNCHER, 'export const launcher = 2\n')
})
expect(checkRuntimeLauncherProtocolRatchet({ repoRoot: repo, base, env: {} }).ok).toBe(false)
expect(
checkRuntimeLauncherProtocolRatchet({
repoRoot: repo,
base,
env: { [RUNTIME_PROTOCOL_OVERRIDE_ENV]: 'true' }
}).ok
).toBe(true)
})
it('passes a bump next to unrelated edits', () => {
const { repo, base } = repoWithCandidate((candidate) => {
writeProtocol(candidate, 4)
write(candidate, 'src/unrelated.ts', 'export const unrelated = 2\n')
})
expect(checkRuntimeLauncherProtocolRatchet({ repoRoot: repo, base, env: {} }).ok).toBe(true)
})
it('ignores edits to the protocol file that keep PROTOCOL_VERSION', () => {
const { repo, base } = repoWithCandidate((candidate) => {
write(
candidate,
DAEMON_PROTOCOL_SOURCE_PATH,
'// comment\nexport const PROTOCOL_VERSION = 3\nexport const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2] as const\n'
)
write(candidate, LAUNCHER, 'export const launcher = 2\n')
})
expect(checkRuntimeLauncherProtocolRatchet({ repoRoot: repo, base, env: {} }).ok).toBe(true)
})
})