mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 16:02:15 +00:00
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>
This commit is contained in:
@@ -856,6 +856,16 @@ jobs:
|
||||
- name: Daemon protocol crossing against newest release
|
||||
run: pnpm run check:daemon-protocol-crossing
|
||||
|
||||
# R3: a runtime launcher swap must not also bump the daemon protocol (D7.1).
|
||||
# The label is read from the event payload, so push again after applying it.
|
||||
- name: Runtime launcher change does not bump daemon protocol
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
ORCA_ALLOW_RUNTIME_LAUNCHER_PROTOCOL_BUMP: ${{ contains(github.event.pull_request.labels.*.name, 'allow-runtime-launcher-protocol-bump') }}
|
||||
run: |
|
||||
DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")"
|
||||
pnpm run check:runtime-launcher-protocol-ratchet -- --base "$DIFF_BASE"
|
||||
|
||||
# A path filter that matches nothing exits 1 ("No test files found"), so this
|
||||
# lane cannot report success while running zero tests.
|
||||
- name: Old/new client and server compatibility journeys
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
// 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 {
|
||||
NODE_RUNTIME_ASSETS,
|
||||
NODE_RUNTIME_PIN,
|
||||
SERVER_TARGETS
|
||||
} from '../../src/shared/node-runtime-pin.ts'
|
||||
import { nodeDistArchiveName } from './update-node-runtime-pin.mjs'
|
||||
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'])
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// R3 gate (docs/reference/node-runtime-design.html, D7.1): swapping the process that hosts orcad
|
||||
// or the daemon is not a daemon protocol change, so one PR must not do both. A bump riding along
|
||||
// with a runtime swap would strand every live terminal on upgrade and rollback at once.
|
||||
// Usage: node check-runtime-launcher-protocol-ratchet.mjs --base <ref>
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { DAEMON_PROTOCOL_SOURCE_PATH, parseDaemonProtocolFacts } from './daemon-protocol-facts.mjs'
|
||||
|
||||
/** Files that decide which runtime executable launches orcad or the terminal daemon. */
|
||||
export const RUNTIME_LAUNCHER_PATHS = [
|
||||
// orcad handoff to its bundled runtime, and the pinned runtimes it can hand off to.
|
||||
'src/main/orcad/orcad-bundled-runtime.ts',
|
||||
'src/shared/orcad-bun-runtime.ts',
|
||||
'src/shared/node-runtime-pin.ts',
|
||||
'src/main/ssh/orcad-bun-runtime-materializer.ts',
|
||||
// orcad slot layout: which runtime file a packaged slot carries.
|
||||
'src/shared/orcad-artifacts.ts',
|
||||
'config/scripts/build-orcad.mjs',
|
||||
'config/scripts/build-orcad-bun.mjs',
|
||||
'config/scripts/build-orcad-template.mjs',
|
||||
// Remote slot runtime selection.
|
||||
'src/main/ssh/orcad-remote-runtime.ts',
|
||||
// Terminal daemon host launch: which executable the daemon child is forked from.
|
||||
'src/main/daemon/daemon-launched-child.ts',
|
||||
'src/main/daemon/daemon-launched-child-spawn.ts',
|
||||
'src/main/daemon/daemon-out-of-process-launcher.ts',
|
||||
'src/main/daemon/daemon-host-relocation.ts',
|
||||
'src/main/daemon/daemon-host-manifest.ts'
|
||||
]
|
||||
|
||||
export const RUNTIME_PROTOCOL_OVERRIDE_ENV = 'ORCA_ALLOW_RUNTIME_LAUNCHER_PROTOCOL_BUMP'
|
||||
export const RUNTIME_PROTOCOL_OVERRIDE_LABEL = 'allow-runtime-launcher-protocol-bump'
|
||||
|
||||
function git(repoRoot, args) {
|
||||
return execFileSync('git', args, {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
maxBuffer: 16 * 1024 * 1024
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ changedFiles: string[], baseProtocolVersion: number, candidateProtocolVersion: number, overridden: boolean }} input
|
||||
*/
|
||||
export function assessRuntimeLauncherProtocolRatchet({
|
||||
changedFiles,
|
||||
baseProtocolVersion,
|
||||
candidateProtocolVersion,
|
||||
overridden
|
||||
}) {
|
||||
const launcherChanges = RUNTIME_LAUNCHER_PATHS.filter((path) => changedFiles.includes(path))
|
||||
const protocolChanged = baseProtocolVersion !== candidateProtocolVersion
|
||||
const violated = protocolChanged && launcherChanges.length > 0
|
||||
const lines = [
|
||||
`daemon PROTOCOL_VERSION: base ${baseProtocolVersion}, candidate ${candidateProtocolVersion}`,
|
||||
launcherChanges.length > 0
|
||||
? `runtime launcher files changed: ${launcherChanges.join(', ')}`
|
||||
: 'runtime launcher files changed: none'
|
||||
]
|
||||
if (!violated) {
|
||||
lines.push('OK: this change does not both swap a runtime launcher and bump the daemon protocol')
|
||||
} else if (overridden) {
|
||||
lines.push(
|
||||
`OVERRIDDEN: launcher change and protocol bump together, allowed by ${RUNTIME_PROTOCOL_OVERRIDE_ENV}`
|
||||
)
|
||||
} else {
|
||||
lines.push(
|
||||
'FAIL: split the protocol bump and the runtime launcher change into separate PRs (D7.1 R3), ' +
|
||||
`or apply the "${RUNTIME_PROTOCOL_OVERRIDE_LABEL}" label and re-run the workflow with a new push.`
|
||||
)
|
||||
}
|
||||
return { ok: !violated || overridden, violated, lines }
|
||||
}
|
||||
|
||||
export function isOverrideEnabled(env = process.env) {
|
||||
return ['1', 'true'].includes(env[RUNTIME_PROTOCOL_OVERRIDE_ENV]?.trim().toLowerCase() ?? '')
|
||||
}
|
||||
|
||||
export function checkRuntimeLauncherProtocolRatchet({ repoRoot, base, env = process.env }) {
|
||||
// Why diff against the working tree: CI's tree is HEAD, and a local run also sees unstaged edits.
|
||||
const changedFiles = git(repoRoot, ['diff', '--name-only', '--no-renames', base, '--'])
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
const baseFacts = parseDaemonProtocolFacts(
|
||||
git(repoRoot, ['show', `${base}:${DAEMON_PROTOCOL_SOURCE_PATH}`]),
|
||||
`${base}:${DAEMON_PROTOCOL_SOURCE_PATH}`
|
||||
)
|
||||
const candidateFacts = parseDaemonProtocolFacts(
|
||||
readFileSync(join(repoRoot, DAEMON_PROTOCOL_SOURCE_PATH), 'utf8'),
|
||||
DAEMON_PROTOCOL_SOURCE_PATH
|
||||
)
|
||||
return assessRuntimeLauncherProtocolRatchet({
|
||||
changedFiles,
|
||||
baseProtocolVersion: baseFacts.protocolVersion,
|
||||
candidateProtocolVersion: candidateFacts.protocolVersion,
|
||||
overridden: isOverrideEnabled(env)
|
||||
})
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const index = argv.indexOf('--base')
|
||||
const value = index === -1 ? undefined : argv[index + 1]
|
||||
if (!value || value.startsWith('--')) {
|
||||
throw new Error('--base <ref> is required (the pull request diff base)')
|
||||
}
|
||||
return { base: value }
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
const repoRoot = resolve(import.meta.dirname, '..', '..')
|
||||
try {
|
||||
const { base } = parseArgs(process.argv.slice(2))
|
||||
const result = checkRuntimeLauncherProtocolRatchet({ repoRoot, base })
|
||||
for (const line of result.lines) {
|
||||
console.log(line)
|
||||
}
|
||||
if (!result.ok) {
|
||||
process.exitCode = 1
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`runtime launcher protocol ratchet failed: ${error.message}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
// Kept apart from update-node-runtime-pin.mjs so the offline check does not load its build graph.
|
||||
|
||||
/** 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}`
|
||||
}
|
||||
@@ -148,6 +148,8 @@ const CROSS_VERSION_WIRE_PREFIXES = [
|
||||
// The R1 daemon protocol crossing gate runs in this job.
|
||||
'config/scripts/daemon-protocol-facts',
|
||||
'config/scripts/check-daemon-protocol-crossing',
|
||||
// R3 runtime launcher protocol ratchet; a bump always routes here via the protocol file.
|
||||
'config/scripts/check-runtime-launcher-protocol-ratchet',
|
||||
'src/main/daemon/daemon-protocol-version.ts',
|
||||
'src/shared/protocol-version',
|
||||
'src/shared/terminal-stream-protocol',
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
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'
|
||||
|
||||
@@ -34,24 +35,6 @@ 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')) {
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
SERVER_TARGETS,
|
||||
nodeRuntimeExecutablePath
|
||||
} from '../../src/shared/node-runtime-pin.ts'
|
||||
import { nodeDistArchiveName } from './node-dist-archive-name.mjs'
|
||||
import {
|
||||
nodeDistArchiveName,
|
||||
parseNodeApiVersion,
|
||||
parseShasums,
|
||||
renderGeneratedBlock,
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"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:daemon-protocol-crossing": "node config/scripts/check-daemon-protocol-crossing.mjs",
|
||||
"check:runtime-launcher-protocol-ratchet": "node config/scripts/check-runtime-launcher-protocol-ratchet.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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
@@ -6,6 +6,7 @@ import { runProcessSync } from '../../shared/child-process/run-process'
|
||||
import { getRemoteHostPlatform } from './ssh-remote-platform'
|
||||
import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime'
|
||||
import { stopOrcadCommand } from './orcad-remote-process-control'
|
||||
import { shellEscape } from './ssh-connection-utils'
|
||||
|
||||
const directories: string[] = []
|
||||
const host = getRemoteHostPlatform('linux-x64')
|
||||
@@ -22,17 +23,44 @@ function fixture(): string {
|
||||
return directory
|
||||
}
|
||||
|
||||
function launch(directory: string, nodePath: string) {
|
||||
const NODE_SHA = 'e4b5a3af0e05c75de2eae013904145f40fe7fc2a6e6f17510128bf45cca4e79b'
|
||||
|
||||
/** A slot beside the shared `runtimes/` dir, as installed under `~/.orca-remote`. */
|
||||
function nodeSlot(options: { marker?: string; runtime?: boolean } = {}): string {
|
||||
const root = fixture()
|
||||
const slot = join(root, 'orcad-0.1.0-abcdef')
|
||||
mkdirSync(slot)
|
||||
writeFileSync(join(slot, '.runtime-node'), options.marker ?? `${NODE_SHA}\n`)
|
||||
if (options.runtime !== false) {
|
||||
const binDir = join(root, 'runtimes', `node-${NODE_SHA}`, 'bin')
|
||||
mkdirSync(binDir, { recursive: true })
|
||||
symlinkSync(process.execPath, join(binDir, 'node'))
|
||||
}
|
||||
return slot
|
||||
}
|
||||
|
||||
/** Frozen copy of the Bun-era selector that shipped clients still run (design D7.1 R5). */
|
||||
function bunEraSelectorCommand(directory: string, legacyNodePath: string): string {
|
||||
const runtime = shellEscape(join(directory, 'bun-runtime'))
|
||||
const target = shellEscape(join(directory, '.build-target'))
|
||||
return (
|
||||
`if [ -e ${target} ] || [ -e ${runtime} ]; then ` +
|
||||
`[ -x ${runtime} ] || exit 78; orcad_runtime=${runtime}; ` +
|
||||
`else orcad_runtime=${shellEscape(legacyNodePath)}; fi`
|
||||
)
|
||||
}
|
||||
|
||||
function runSelected(selector: string) {
|
||||
return runProcessSync({
|
||||
program: '/bin/sh',
|
||||
args: [
|
||||
'-c',
|
||||
`${selectOrcadSlotRuntimeCommand(host, directory, nodePath)}; ` +
|
||||
'"$orcad_runtime" -e \'process.stdout.write("selected")\''
|
||||
]
|
||||
args: ['-c', `${selector}; "$orcad_runtime" -e 'process.stdout.write("selected")'`]
|
||||
})
|
||||
}
|
||||
|
||||
function launch(directory: string, nodePath: string) {
|
||||
return runSelected(selectOrcadSlotRuntimeCommand(host, directory, nodePath))
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('POSIX slot runtime selection', () => {
|
||||
it('returns an unverifiable stop result when a bundled runtime cannot execute', () => {
|
||||
const directory = fixture()
|
||||
@@ -64,4 +92,66 @@ describe.skipIf(process.platform === 'win32')('POSIX slot runtime selection', ()
|
||||
it('retains the original runtime for a legacy slot', () => {
|
||||
expect(launch(fixture(), process.execPath)).toMatchObject({ code: 0, stdout: 'selected' })
|
||||
})
|
||||
|
||||
it('launches a Node slot from its shared pinned runtime, never host Node', () => {
|
||||
expect(launch(nodeSlot(), '/missing-host-node')).toMatchObject({ code: 0, stdout: 'selected' })
|
||||
})
|
||||
|
||||
it('accepts a Node slot path with a trailing separator', () => {
|
||||
expect(launch(`${nodeSlot()}/`, '/missing-host-node')).toMatchObject({
|
||||
code: 0,
|
||||
stdout: 'selected'
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a Node slot whose pinned runtime is missing before invoking a working host Node', () => {
|
||||
expect(launch(nodeSlot({ runtime: false }), process.execPath)).toMatchObject({
|
||||
code: 78,
|
||||
stdout: ''
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['empty', ''],
|
||||
['short', 'abc123'],
|
||||
['uppercase', NODE_SHA.toUpperCase()],
|
||||
['path traversal', `../${NODE_SHA.slice(3)}`]
|
||||
])('refuses a Node slot with a %s marker', (_label, marker) => {
|
||||
expect(launch(nodeSlot({ marker }), process.execPath)).toMatchObject({ code: 78, stdout: '' })
|
||||
})
|
||||
|
||||
it('prefers the Node marker over Bun files in the same slot', () => {
|
||||
const slot = nodeSlot()
|
||||
writeFileSync(join(slot, '.build-target'), 'linux-x64-glibc')
|
||||
expect(launch(slot, '/missing-host-node')).toMatchObject({ code: 0, stdout: 'selected' })
|
||||
})
|
||||
|
||||
it('shows a Node slot to a Bun-era client as legacy, not as a broken Bun slot', () => {
|
||||
const slot = nodeSlot()
|
||||
expect(runSelected(bunEraSelectorCommand(slot, process.execPath))).toMatchObject({
|
||||
code: 0,
|
||||
stdout: 'selected'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the frozen Bun-era selector in step with the current Bun and legacy branches', () => {
|
||||
const bun = fixture()
|
||||
writeFileSync(join(bun, '.build-target'), 'linux-x64-glibc')
|
||||
expect(runSelected(bunEraSelectorCommand(bun, process.execPath)).code).toBe(78)
|
||||
expect(launch(bun, process.execPath).code).toBe(78)
|
||||
const legacy = fixture()
|
||||
expect(runSelected(bunEraSelectorCommand(legacy, process.execPath)).stdout).toBe('selected')
|
||||
expect(launch(legacy, process.execPath).stdout).toBe('selected')
|
||||
})
|
||||
|
||||
it('reads a stop verdict from a Node slot through its pinned runtime', () => {
|
||||
const slot = nodeSlot()
|
||||
writeFileSync(join(slot, '.orcad-pid'), String(process.pid))
|
||||
const result = runProcessSync({
|
||||
program: '/bin/sh',
|
||||
args: ['-c', stopOrcadCommand(host, slot, { waitSeconds: 1, nodePath: '/missing-host-node' })]
|
||||
})
|
||||
// No readiness file: the pinned runtime ran and reported no PID, so the verdict is unverifiable.
|
||||
expect(result).toMatchObject({ code: 0, stdout: 'UNKNOWN\n' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +1,49 @@
|
||||
import { ORCAD_BUILD_TARGET_FILENAME, orcadBunRuntimeFilename } from '../../shared/orcad-artifacts'
|
||||
import {
|
||||
ORCAD_BUILD_TARGET_FILENAME,
|
||||
ORCAD_NODE_RUNTIME_DIR_PREFIX,
|
||||
ORCAD_NODE_RUNTIME_MARKER_FILENAME,
|
||||
ORCAD_NODE_RUNTIME_POSIX_EXECUTABLE,
|
||||
ORCAD_RUNTIMES_DIRNAME,
|
||||
orcadBunRuntimeFilename
|
||||
} from '../../shared/orcad-artifacts'
|
||||
import { assertPosixOrcadHost } from './orcad-remote-host-support'
|
||||
import { shellEscape } from './ssh-connection-utils'
|
||||
import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform'
|
||||
import { joinRemotePath, remoteDirname, type RemoteHostPlatform } from './ssh-remote-platform'
|
||||
|
||||
/** Only legacy slots may use host Node; an incomplete Bun slot must not change runtimes. */
|
||||
/**
|
||||
* The slot's own contents pick its runtime: Node marker, then Bun, then legacy host Node.
|
||||
* Only legacy slots may use host Node; an incomplete Node or Bun slot must not change runtimes.
|
||||
*/
|
||||
export function selectOrcadSlotRuntimeCommand(
|
||||
host: RemoteHostPlatform,
|
||||
directory: string,
|
||||
legacyNodePath: string
|
||||
): string {
|
||||
assertPosixOrcadHost(host)
|
||||
const nodeMarker = shellEscape(
|
||||
joinRemotePath(host, directory, ORCAD_NODE_RUNTIME_MARKER_FILENAME)
|
||||
)
|
||||
const nodeRuntimePrefix = shellEscape(
|
||||
joinRemotePath(
|
||||
host,
|
||||
remoteDirname(directory.replace(/\/+$/, ''), host),
|
||||
ORCAD_RUNTIMES_DIRNAME,
|
||||
ORCAD_NODE_RUNTIME_DIR_PREFIX
|
||||
)
|
||||
)
|
||||
const nodeExecutable = shellEscape(`/${ORCAD_NODE_RUNTIME_POSIX_EXECUTABLE}`)
|
||||
const runtime = shellEscape(joinRemotePath(host, directory, orcadBunRuntimeFilename(host.os)))
|
||||
const target = shellEscape(joinRemotePath(host, directory, ORCAD_BUILD_TARGET_FILENAME))
|
||||
return (
|
||||
`if [ -e ${target} ] || [ -e ${runtime} ]; then ` +
|
||||
`if [ -e ${nodeMarker} ]; then ` +
|
||||
`orcad_node_sha=$(cat ${nodeMarker}) || exit 78; ` +
|
||||
// Why validate: the digest becomes a path segment, so only a bare sha256 may reach it.
|
||||
// The leading `(` keeps the pattern parseable when stop embeds this in `$(...)`.
|
||||
'case "$orcad_node_sha" in ("" | *[!0-9a-f]*) exit 78;; esac; ' +
|
||||
'[ "${#orcad_node_sha}" -eq 64 ] || exit 78; ' +
|
||||
`orcad_runtime=${nodeRuntimePrefix}"$orcad_node_sha"${nodeExecutable}; ` +
|
||||
'[ -x "$orcad_runtime" ] || exit 78; ' +
|
||||
`elif [ -e ${target} ] || [ -e ${runtime} ]; then ` +
|
||||
`[ -x ${runtime} ] || exit 78; orcad_runtime=${runtime}; ` +
|
||||
`else orcad_runtime=${shellEscape(legacyNodePath)}; fi`
|
||||
)
|
||||
|
||||
@@ -25,6 +25,17 @@ export function orcadArtifactHashPrefix(target: string): string {
|
||||
: ''
|
||||
}
|
||||
export const ORCAD_BUILD_TARGET_FILENAME = '.build-target'
|
||||
|
||||
/**
|
||||
* Marks a slot launched by the pinned Node runtime; its content is that runtime's
|
||||
* executableSha256 (node-runtime-pin.ts). A Node slot must never carry `.build-target`:
|
||||
* Bun-era selectors exit 78 on `.build-target` without `bun-runtime` (design D7.1 R5).
|
||||
*/
|
||||
export const ORCAD_NODE_RUNTIME_MARKER_FILENAME = '.runtime-node'
|
||||
/** Beside the slot dirs and shared across Orca versions (design D2): `runtimes/node-<sha256>/bin/node`. */
|
||||
export const ORCAD_RUNTIMES_DIRNAME = 'runtimes'
|
||||
export const ORCAD_NODE_RUNTIME_DIR_PREFIX = 'node-'
|
||||
export const ORCAD_NODE_RUNTIME_POSIX_EXECUTABLE = 'bin/node'
|
||||
export const ORCAD_PARCEL_WATCHER_ENTRY = 'node_modules/@parcel/watcher/index.js'
|
||||
export const ORCAD_PARCEL_WATCHER_NATIVE = 'node_modules/@parcel/watcher/watcher.node'
|
||||
export const ORCAD_EMOJI_SHORTCODE_DATASET =
|
||||
|
||||
Reference in New Issue
Block a user