Files
open-science/scripts/linux-package-smoke.mjs
Ewen 052d83d092 feat(linux): add native ARM64 distribution support (#3106)
* feat(linux): add native ARM64 distribution support

* fix(linux): complete ARM64 feed promotion and web packaging

* test(linux): cover ARM64 channel promotion fixtures

* fix(release): bootstrap the first ARM64 update channel

* test(release): include ARM64 bootstrap in workflow contract

* ci(runtime): gate PRs on ARM64 bundle readiness
2026-09-28 05:41:05 -07:00

434 lines
15 KiB
JavaScript

/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { spawn } from 'node:child_process'
import { access, chmod, mkdir, mkdtemp, readFile, readdir, realpath, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, dirname, join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import {
parsePackagedSqliteVersion,
seedLegacyDatabase,
verifyDatabaseMigrationLedger,
verifyLegacyProjectPreserved,
writeDatabaseMigrationCertification
} from './database-migration-ledger-smoke.mjs'
import { authenticatePackagedAppEndpoint } from './packaged-web-service-auth.mjs'
const APPIMAGE_PATTERN = /^aipoch-open-science-(.+)-linux-(?:x64|x86_64|arm64)\.AppImage$/
const SMOKE_ROOT_PREFIX = 'open-science-linux-package-smoke-'
const STARTUP_TIMEOUT_MS = 60_000
const REQUIRED_LINUX_PRISMA_ENGINES = {
x64: [
'libquery_engine-debian-openssl-3.0.x.so.node',
'libquery_engine-rhel-openssl-3.0.x.so.node'
],
arm64: ['libquery_engine-linux-arm64-openssl-3.0.x.so.node']
}
const delay = (milliseconds) =>
new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds))
const findOne = async (directory, pattern, description) => {
const matches = (await readdir(directory, { withFileTypes: true }))
.filter((entry) => entry.isFile() && pattern.test(entry.name))
.map((entry) => join(directory, entry.name))
if (matches.length !== 1) {
throw new Error(`Expected exactly one ${description} in ${directory}; found ${matches.length}.`)
}
return matches[0]
}
const appImageVersion = (path) => {
const match = basename(path).match(APPIMAGE_PATTERN)
if (!match) throw new Error(`Cannot derive the app version from AppImage: ${path}`)
return match[1]
}
const parsePackagedAppEndpoint = (output) => {
const match = output.match(
/Open-Science Web:\s+(http:\/\/127\.0\.0\.1:\d+\/(?:\?token=[A-Za-z0-9_-]+)?)/
)
if (!match) return undefined
const url = new URL(match[1])
const token = url.searchParams.get('token')
return {
endpoint: url.origin,
...(token ? { auth: `token=${encodeURIComponent(token)}` } : {})
}
}
const pathExists = async (path) => {
try {
await access(path)
return true
} catch {
return false
}
}
const waitFor = async (description, check, timeoutMs = STARTUP_TIMEOUT_MS) => {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const value = await check().catch(() => undefined)
if (value !== undefined && value !== false) return value
await delay(250)
}
throw new Error(`Timed out waiting for ${description}.`)
}
const runProcess = (executable, args, options = {}) =>
new Promise((resolveProcess, rejectProcess) => {
const child = spawn(executable, args, {
cwd: options.cwd,
env: options.env,
stdio: options.stdio ?? 'pipe'
})
let stdout = ''
let stderr = ''
child.stdout?.setEncoding('utf8')
child.stderr?.setEncoding('utf8')
child.stdout?.on('data', (chunk) => (stdout += chunk))
child.stderr?.on('data', (chunk) => (stderr += chunk))
child.once('error', rejectProcess)
child.once('exit', (code) => {
if (code === 0) resolveProcess({ stdout, stderr })
else
rejectProcess(new Error(`${basename(executable)} exited with ${code}.\n${stdout}${stderr}`))
})
})
const packagedResourcePaths = (
executable,
resourceRoot = join(dirname(executable), 'resources')
) => {
return [executable, join(resourceRoot, 'app.asar'), join(resourceRoot, 'micromamba')]
}
const findResourceRoot = async (executable, resolvedExecutable = executable) => {
const bundleRoot = dirname(executable)
const candidates = [
join(dirname(resolvedExecutable), 'resources'),
join(bundleRoot, 'resources'),
join(bundleRoot, 'usr', 'lib', 'open-science', 'resources'),
join(bundleRoot, 'usr', 'lib', 'Open-Science', 'resources')
]
for (const candidate of [...new Set(candidates)]) {
if (await pathExists(join(candidate, 'app.asar'))) return candidate
}
throw new Error(`Packaged Linux app.asar was not found for ${executable}.`)
}
const assertPackagedResources = async (
executable,
resourceRoot = join(dirname(executable), 'resources'),
arch = process.arch
) => {
const requiredEngines = REQUIRED_LINUX_PRISMA_ENGINES[arch]
if (!requiredEngines) throw new Error(`Unsupported Linux package architecture: ${arch}`)
for (const path of packagedResourcePaths(executable, resourceRoot)) {
if (!(await pathExists(path))) throw new Error(`Packaged Linux resource is missing: ${path}`)
}
const prismaRoot = join(resourceRoot, 'node_modules', '.prisma', 'client')
const engines = await readdir(prismaRoot).catch(() => [])
const nativeEngines = engines.filter(
(name) => name.includes('query_engine-') && name.endsWith('.node')
)
const missingEngines = requiredEngines.filter((name) => !nativeEngines.includes(name))
const unexpectedEngines = nativeEngines.filter((name) => !requiredEngines.includes(name))
if (missingEngines.length > 0 || unexpectedEngines.length > 0) {
throw new Error(
`Packaged Linux must contain Prisma engines ${requiredEngines.join(', ')}; ` +
`found ${nativeEngines.join(', ') || 'none'} in ${prismaRoot}.`
)
}
}
const launchAndProbe = async ({ executable, expectedVersion, env }) => {
// Headless packaged launches have no Secret Service desktop, so OS credential mode fails closed;
// the file backend is the supported headless mode on Linux (matching the CLI smoke launch).
const child = spawn(
executable,
['--open-science-headless', '--serve=0', '--no-sandbox', '--credential-store=file'],
{
env,
stdio: ['ignore', 'pipe', 'pipe']
}
)
let output = ''
child.stdout?.setEncoding('utf8')
child.stderr?.setEncoding('utf8')
child.stdout?.on('data', (chunk) => (output += chunk))
child.stderr?.on('data', (chunk) => (output += `\n${chunk}`))
const exit = new Promise((resolveExit, rejectExit) => {
child.once('error', rejectExit)
child.once('exit', resolveExit)
})
try {
const service = await Promise.race([
waitFor('the packaged Linux web service', async () =>
authenticatePackagedAppEndpoint(output, [env.OPEN_SCIENCE_E2E_STORAGE_ROOT])
),
exit.then((code) => {
throw new Error(`Packaged Linux app exited before becoming healthy (${code}).\n${output}`)
})
])
const response = await fetch(`${service.endpoint}/api/bootstrap?${service.auth}`, {
signal: AbortSignal.timeout(15_000)
})
if (!response.ok) throw new Error(`Packaged Linux bootstrap returned HTTP ${response.status}.`)
const bootstrap = await response.json()
if (
bootstrap.appName !== 'Open-Science' ||
bootstrap.appVersion !== expectedVersion ||
bootstrap.platform !== 'linux'
) {
throw new Error(`Unexpected packaged Linux bootstrap: ${JSON.stringify(bootstrap)}`)
}
const shutdown = await fetch(`${service.endpoint}/api/shutdown?${service.auth}`, {
method: 'POST',
signal: AbortSignal.timeout(15_000)
})
await shutdown.text()
if (shutdown.status !== 202)
throw new Error(`Packaged Linux shutdown returned ${shutdown.status}.`)
const exitCode = await Promise.race([
exit,
delay(60_000).then(() => {
throw new Error('Packaged Linux app did not exit after shutdown.')
})
])
if (exitCode !== 0) throw new Error(`Packaged Linux app exited with ${exitCode}.\n${output}`)
return parsePackagedSqliteVersion(output)
} catch (error) {
child.kill('SIGKILL')
throw error
}
}
const smokeExecutable = async ({
executable,
expectedVersion,
env,
storageRoot,
expectLegacyProject = false
}) => {
const resolvedExecutable = await realpath(executable)
const resourceRoot = await findResourceRoot(executable, resolvedExecutable)
await assertPackagedResources(resolvedExecutable, resourceRoot)
await runProcess(join(resourceRoot, 'micromamba'), ['--version'], { env })
const sqliteVersions = [
await launchAndProbe({ executable, expectedVersion, env }),
await launchAndProbe({ executable, expectedVersion, env })
]
await verifyDatabaseMigrationLedger(storageRoot)
if (expectLegacyProject) await verifyLegacyProjectPreserved(storageRoot)
return sqliteVersions
}
// Exercise the installed public command with an empty user profile and no display server.
// Tokens are inspected in memory only; certification output must never print them.
const smokeInstalledCli = async ({ executable, expectedVersion, root, env }) => {
const cliEnv = {
...env,
HOME: join(root, 'cli home 数据'),
XDG_CONFIG_HOME: join(root, 'cli config 数据')
}
for (const name of [
'DISPLAY',
'WAYLAND_DISPLAY',
'OPEN_SCIENCE_APP_PATH',
'ELECTRON_RUN_AS_NODE',
'OPEN_SCIENCE_CONFIG_ROOT',
'OPEN_SCIENCE_STORAGE_ROOT',
'OPEN_SCIENCE_E2E_STORAGE_ROOT'
]) {
delete cliEnv[name]
}
await mkdir(cliEnv.HOME, { recursive: true })
const invoke = (args) => runProcess(executable, args, { env: cliEnv })
const initialized = JSON.parse((await invoke(['init', '--json'])).stdout)
if (initialized.configRoot !== join(cliEnv.HOME, '.open-science')) {
throw new Error('Installed CLI did not initialize the isolated production profile.')
}
let failure
try {
const started = await invoke([
'start',
'--no-open',
'--port',
'44109',
'--credential-store=file'
])
const statePath = join(initialized.configRoot, 'web-service.json')
const firstState = JSON.parse(await readFile(statePath, 'utf8'))
const reused = await invoke(['start', '--no-open'])
const secondState = JSON.parse(await readFile(statePath, 'utf8'))
if (firstState.pid !== secondState.pid || firstState.port !== 44109) {
throw new Error('Installed CLI failed daemon reuse or custom-port selection.')
}
const browserUrl = new URL((await invoke(['url'])).stdout.trim())
const token = browserUrl.searchParams.get('token')
if (browserUrl.hostname !== '127.0.0.1' || browserUrl.port !== '44109' || !token) {
throw new Error('Installed CLI did not return an authenticated loopback URL.')
}
const response = await fetch(new URL('/api/bootstrap', browserUrl), {
headers: { authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(15_000)
})
const bootstrap = await response.json()
if (
!response.ok ||
bootstrap.appVersion !== expectedVersion ||
bootstrap.platform !== 'linux'
) {
throw new Error('Installed CLI started an unexpected or unhealthy application.')
}
const logs = await readFile(join(initialized.configRoot, 'cli-daemon.log'), 'utf8')
if (
[started.stdout, started.stderr, reused.stdout, reused.stderr, logs].some((text) =>
text.includes(token)
)
) {
throw new Error('A CLI startup output or daemon log disclosed the browser token.')
}
console.log(
'Installed CLI: init, headless start, custom port, daemon reuse and authenticated URL passed (token redacted).'
)
} catch (error) {
failure = error
} finally {
try {
await invoke(['stop'])
} catch (error) {
failure ??= error
}
}
if (failure) {
const token = await readFile(join(initialized.configRoot, 'web-token'), 'utf8')
.then((text) => text.trim())
.catch(() => '')
const logs = await readFile(join(initialized.configRoot, 'cli-daemon.log'), 'utf8').catch(
() => '(daemon log unavailable)'
)
const diagnostic = `${failure.message}\n${logs}`
throw new Error(token ? diagnostic.replaceAll(token, '<REDACTED>') : diagnostic)
}
const stoppedUrl = await invoke(['url']).then(
() => false,
() => true
)
if (!stoppedUrl) throw new Error('The URL command succeeded after the daemon was stopped.')
console.log(
'Installed CLI: stop and unavailable URL passed; no desktop or display server was used.'
)
}
const parseArguments = (argv) => {
const valueFor = (name) => {
const index = argv.indexOf(name)
return index === -1 ? undefined : argv[index + 1]
}
const artifactDirectory = valueFor('--artifact-dir')
const installedExecutable = valueFor('--installed-executable')
const installedCli = valueFor('--installed-cli')
if (!artifactDirectory || !installedExecutable) {
throw new Error(
'Usage: --artifact-dir <path> --installed-executable <path-to-installed-open-science>'
)
}
return {
artifactDirectory: resolve(artifactDirectory),
installedExecutable: resolve(installedExecutable),
installedCli: resolve(installedCli ?? '/usr/bin/open-science')
}
}
const main = async () => {
if (process.platform !== 'linux') throw new Error('Linux package smoke requires Linux.')
const options = parseArguments(process.argv.slice(2))
const appImage = await findOne(options.artifactDirectory, APPIMAGE_PATTERN, 'Linux AppImage')
const expectedVersion = appImageVersion(appImage)
const root = await mkdtemp(join(process.env.RUNNER_TEMP || tmpdir(), SMOKE_ROOT_PREFIX))
const baseEnv = {
...process.env,
HOME: join(root, 'home 数据 with spaces'),
XDG_CONFIG_HOME: join(root, 'config 数据 with spaces')
}
const launchProfiles = (format) => [
{ storageRoot: join(root, `${format} legacy 数据`), expectLegacyProject: true },
{ storageRoot: join(root, `${format} fresh 数据`), expectLegacyProject: false }
]
try {
await smokeInstalledCli({
executable: options.installedCli,
expectedVersion,
root,
env: baseEnv
})
const sqliteVersions = []
const debProfiles = launchProfiles('deb')
await seedLegacyDatabase(debProfiles[0].storageRoot)
for (const profile of debProfiles) {
sqliteVersions.push(
...(await smokeExecutable({
executable: options.installedExecutable,
expectedVersion,
env: { ...baseEnv, OPEN_SCIENCE_E2E_STORAGE_ROOT: profile.storageRoot },
...profile
}))
)
}
await chmod(appImage, 0o755)
await runProcess(appImage, ['--appimage-extract'], { cwd: root, env: baseEnv })
const appImageProfiles = launchProfiles('appimage')
await seedLegacyDatabase(appImageProfiles[0].storageRoot)
for (const profile of appImageProfiles) {
sqliteVersions.push(
...(await smokeExecutable({
executable: join(root, 'squashfs-root', 'AppRun'),
expectedVersion,
env: { ...baseEnv, OPEN_SCIENCE_E2E_STORAGE_ROOT: profile.storageRoot },
...profile
}))
)
}
await writeDatabaseMigrationCertification({
output: join(options.artifactDirectory, 'database-migration-certification.json'),
sqliteVersions,
checks: {
freshInstall: 'passed',
legacyAdoption: 'passed',
reopen: 'passed',
specialPath: 'passed'
}
})
console.log('Linux deb install and AppImage launch smoke completed successfully.')
} finally {
await rm(root, { force: true, maxRetries: 5, recursive: true, retryDelay: 200 })
}
}
const invokedAsScript =
process.argv[1] !== undefined && import.meta.url === pathToFileURL(resolve(process.argv[1])).href
if (invokedAsScript) {
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
}
export {
authenticatePackagedAppEndpoint,
appImageVersion,
assertPackagedResources,
findOne,
findResourceRoot,
packagedResourcePaths,
parseArguments,
parsePackagedAppEndpoint,
smokeInstalledCli
}