fix: stage target runtime dependencies for packaging
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
const { spawn } = require('node:child_process')
|
||||
const { createHash } = require('node:crypto')
|
||||
const {
|
||||
createReadStream,
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
closeSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readSync,
|
||||
@@ -14,6 +17,7 @@ const {
|
||||
writeFileSync
|
||||
} = require('node:fs')
|
||||
const { once } = require('node:events')
|
||||
const { tmpdir } = require('node:os')
|
||||
const {
|
||||
basename,
|
||||
dirname,
|
||||
@@ -36,6 +40,9 @@ const root = join(__dirname, '..')
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(join(root, 'package.json'), 'utf8')
|
||||
)
|
||||
const packageLock = JSON.parse(
|
||||
readFileSync(join(root, 'package-lock.json'), 'utf8')
|
||||
)
|
||||
const productName = packageJson.build?.productName ?? packageJson.name
|
||||
const releaseRoot = join(root, 'dist', 'release')
|
||||
const manifestName = 'release-manifest.json'
|
||||
@@ -205,6 +212,29 @@ function npmInvocation(environment = process.env) {
|
||||
prefixArgs: [environment.npm_execpath]
|
||||
}
|
||||
}
|
||||
const npmCli = [
|
||||
join(
|
||||
dirname(process.execPath),
|
||||
'node_modules',
|
||||
'npm',
|
||||
'bin',
|
||||
'npm-cli.js'
|
||||
),
|
||||
join(
|
||||
dirname(dirname(process.execPath)),
|
||||
'lib',
|
||||
'node_modules',
|
||||
'npm',
|
||||
'bin',
|
||||
'npm-cli.js'
|
||||
)
|
||||
].find((candidate) => existsSync(candidate))
|
||||
if (npmCli) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
prefixArgs: [npmCli]
|
||||
}
|
||||
}
|
||||
return {
|
||||
command: process.platform === 'win32' ? 'npm.cmd' : 'npm',
|
||||
prefixArgs: []
|
||||
@@ -246,6 +276,38 @@ function run(command, args, environment = process.env) {
|
||||
})
|
||||
}
|
||||
|
||||
function runCapture(command, args, environment = process.env) {
|
||||
return new Promise((resolveRun, rejectRun) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: root,
|
||||
env: environment,
|
||||
shell: false,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout = `${stdout}${chunk}`.slice(-1024 * 1024)
|
||||
})
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr = `${stderr}${chunk}`.slice(-64 * 1024)
|
||||
})
|
||||
child.once('error', rejectRun)
|
||||
child.once('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolveRun(stdout)
|
||||
return
|
||||
}
|
||||
const error = new Error(
|
||||
`命令执行失败(code ${code ?? 1}):${command} ${args.join(' ')}`
|
||||
)
|
||||
error.outputTail = stderr
|
||||
rejectRun(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function buildElectronBuilderArguments(options, outputDirectory) {
|
||||
const definition = platformDefinitions[options.platform]
|
||||
const builderFormats = [...new Set(
|
||||
@@ -443,6 +505,195 @@ function targetHarnessPaths(options) {
|
||||
}
|
||||
}
|
||||
|
||||
function targetRuntimePackageNames(options) {
|
||||
const target = targetHarnessPaths(options)
|
||||
return [
|
||||
target.koffiPackage,
|
||||
...(target.landlockPackage ? [target.landlockPackage] : [])
|
||||
]
|
||||
}
|
||||
|
||||
function lockedTargetRuntimePackage(packageName) {
|
||||
const expectedVersion =
|
||||
packageJson.optionalDependencies?.[packageName]
|
||||
const lockEntry =
|
||||
packageLock.packages?.[`node_modules/${packageName}`]
|
||||
if (
|
||||
typeof expectedVersion !== 'string' ||
|
||||
lockEntry?.version !== expectedVersion ||
|
||||
typeof lockEntry.resolved !== 'string' ||
|
||||
typeof lockEntry.integrity !== 'string'
|
||||
) {
|
||||
throw new Error(
|
||||
`目标 Runtime 依赖未完整锁定:${packageName}`
|
||||
)
|
||||
}
|
||||
return {
|
||||
name: packageName,
|
||||
version: expectedVersion,
|
||||
resolved: lockEntry.resolved,
|
||||
integrity: lockEntry.integrity
|
||||
}
|
||||
}
|
||||
|
||||
function parsePackedPackageMetadata(output, expected) {
|
||||
let entries
|
||||
try {
|
||||
entries = JSON.parse(output)
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`目标 Runtime 依赖 npm pack 输出无效:${expected.name}`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
const metadata =
|
||||
Array.isArray(entries) && entries.length === 1
|
||||
? entries[0]
|
||||
: undefined
|
||||
if (
|
||||
metadata?.name !== expected.name ||
|
||||
metadata.version !== expected.version ||
|
||||
metadata.integrity !== expected.integrity ||
|
||||
typeof metadata.filename !== 'string' ||
|
||||
basename(metadata.filename) !== metadata.filename
|
||||
) {
|
||||
throw new Error(
|
||||
`目标 Runtime 依赖 npm pack 元数据不匹配:${expected.name}`
|
||||
)
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
function verifyArchiveIntegrity(filePath, expectedIntegrity) {
|
||||
const match = /^(sha(?:256|384|512))-(\S+)$/u.exec(
|
||||
expectedIntegrity
|
||||
)
|
||||
if (!match) {
|
||||
throw new Error(`不支持的依赖完整性格式:${expectedIntegrity}`)
|
||||
}
|
||||
const actual = createHash(match[1])
|
||||
.update(readFileSync(filePath))
|
||||
.digest('base64')
|
||||
if (actual !== match[2]) {
|
||||
throw new Error(`目标 Runtime 依赖完整性校验失败:${filePath}`)
|
||||
}
|
||||
}
|
||||
|
||||
function installedPackageMatches(packageName, expectedVersion) {
|
||||
const manifestPath = join(
|
||||
root,
|
||||
'node_modules',
|
||||
...packageName.split('/'),
|
||||
'package.json'
|
||||
)
|
||||
if (!existsSync(manifestPath)) {
|
||||
return false
|
||||
}
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
||||
if (
|
||||
manifest.name !== packageName ||
|
||||
manifest.version !== expectedVersion
|
||||
) {
|
||||
throw new Error(
|
||||
`目标 Runtime 依赖版本错误:${packageName}`
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function stageTargetRuntimeDependencies(options) {
|
||||
const missing = targetRuntimePackageNames(options)
|
||||
.map(lockedTargetRuntimePackage)
|
||||
.filter(
|
||||
(dependency) =>
|
||||
!installedPackageMatches(
|
||||
dependency.name,
|
||||
dependency.version
|
||||
)
|
||||
)
|
||||
if (missing.length === 0) {
|
||||
return () => undefined
|
||||
}
|
||||
|
||||
const stagingRoot = mkdtempSync(
|
||||
join(tmpdir(), 'goodbuddy-release-dependencies-')
|
||||
)
|
||||
const stagedDirectories = []
|
||||
const cleanup = () => {
|
||||
for (const directory of stagedDirectories.reverse()) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
rmSync(stagingRoot, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
try {
|
||||
const npm = npmInvocation()
|
||||
for (const [index, dependency] of missing.entries()) {
|
||||
const archiveDirectory = join(
|
||||
stagingRoot,
|
||||
`package-${index}`
|
||||
)
|
||||
mkdirSync(archiveDirectory, { recursive: true })
|
||||
const output = await runCapture(npm.command, [
|
||||
...npm.prefixArgs,
|
||||
'pack',
|
||||
`${dependency.name}@${dependency.version}`,
|
||||
'--ignore-scripts',
|
||||
'--json',
|
||||
'--pack-destination',
|
||||
archiveDirectory
|
||||
])
|
||||
const metadata = parsePackedPackageMetadata(
|
||||
output,
|
||||
dependency
|
||||
)
|
||||
const archivePath = join(
|
||||
archiveDirectory,
|
||||
metadata.filename
|
||||
)
|
||||
verifyArchiveIntegrity(archivePath, dependency.integrity)
|
||||
|
||||
const destination = join(
|
||||
root,
|
||||
'node_modules',
|
||||
...dependency.name.split('/')
|
||||
)
|
||||
if (existsSync(destination)) {
|
||||
throw new Error(
|
||||
`拒绝覆盖目标 Runtime 依赖目录:${destination}`
|
||||
)
|
||||
}
|
||||
mkdirSync(destination, { recursive: true })
|
||||
stagedDirectories.push(destination)
|
||||
await run('tar', [
|
||||
'-xzf',
|
||||
archivePath,
|
||||
'-C',
|
||||
destination,
|
||||
'--strip-components',
|
||||
'1'
|
||||
])
|
||||
if (
|
||||
!installedPackageMatches(
|
||||
dependency.name,
|
||||
dependency.version
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`目标 Runtime 依赖暂存失败:${dependency.name}`
|
||||
)
|
||||
}
|
||||
console.log(
|
||||
`已暂存目标 Runtime 依赖:${dependency.name}@${dependency.version}`
|
||||
)
|
||||
}
|
||||
return cleanup
|
||||
} catch (error) {
|
||||
cleanup()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function verifyHarnessPackage(
|
||||
resources,
|
||||
options,
|
||||
@@ -1278,6 +1529,7 @@ async function main(argv = process.argv.slice(2)) {
|
||||
}
|
||||
|
||||
rmSync(stagingDirectory, { recursive: true, force: true })
|
||||
let cleanupTargetDependencies = () => undefined
|
||||
try {
|
||||
if (!options.skipBuild) {
|
||||
const npm = npmInvocation()
|
||||
@@ -1286,6 +1538,8 @@ async function main(argv = process.argv.slice(2)) {
|
||||
[...npm.prefixArgs, 'run', 'build']
|
||||
)
|
||||
}
|
||||
cleanupTargetDependencies =
|
||||
await stageTargetRuntimeDependencies(options)
|
||||
await run(
|
||||
process.execPath,
|
||||
builderArguments,
|
||||
@@ -1322,6 +1576,7 @@ async function main(argv = process.argv.slice(2)) {
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
cleanupTargetDependencies()
|
||||
rmSync(stagingDirectory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
@@ -1333,9 +1588,13 @@ module.exports = {
|
||||
detectBinaryArchitecture,
|
||||
normalizePlatform,
|
||||
parseArguments,
|
||||
parsePackedPackageMetadata,
|
||||
platformDefinitions,
|
||||
replaceOutput,
|
||||
stageTargetRuntimeDependencies,
|
||||
targetRuntimePackageNames,
|
||||
verifyHarnessPackage,
|
||||
verifyArchiveIntegrity,
|
||||
verifyUnpackedOutput,
|
||||
verifyArtifacts,
|
||||
verifyArtifactSignature,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -44,11 +45,27 @@ interface ReleaseBuilderModule {
|
||||
arguments_: string[],
|
||||
environment?: { platform: string; arch: string }
|
||||
) => ReleaseOptions
|
||||
parsePackedPackageMetadata: (
|
||||
output: string,
|
||||
expected: {
|
||||
name: string
|
||||
version: string
|
||||
integrity: string
|
||||
}
|
||||
) => {
|
||||
name: string
|
||||
version: string
|
||||
integrity: string
|
||||
filename: string
|
||||
}
|
||||
replaceOutput: (
|
||||
stagingDirectory: string,
|
||||
destination: string,
|
||||
options: ReleaseOptions
|
||||
) => void
|
||||
targetRuntimePackageNames: (
|
||||
options: ReleaseOptions
|
||||
) => string[]
|
||||
verifyHarnessPackage: (
|
||||
resources: string,
|
||||
options: ReleaseOptions,
|
||||
@@ -73,6 +90,10 @@ interface ReleaseBuilderModule {
|
||||
options: ReleaseOptions
|
||||
) => void
|
||||
verifyPortableZip: (filePath: string) => void
|
||||
verifyArchiveIntegrity: (
|
||||
filePath: string,
|
||||
expectedIntegrity: string
|
||||
) => void
|
||||
writeManifest: (
|
||||
directory: string,
|
||||
options: ReleaseOptions
|
||||
@@ -296,6 +317,80 @@ describe('release build arguments', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('identifies the native packages required by each release target', () => {
|
||||
expect(
|
||||
releaseBuilder.targetRuntimePackageNames({
|
||||
...windowsOptions,
|
||||
arch: 'arm64'
|
||||
})
|
||||
).toEqual(['@koromix/koffi-win32-arm64'])
|
||||
expect(
|
||||
releaseBuilder.targetRuntimePackageNames({
|
||||
...windowsOptions,
|
||||
platform: 'linux',
|
||||
formats: ['AppImage', 'deb']
|
||||
})
|
||||
).toEqual([
|
||||
'@koromix/koffi-linux-x64',
|
||||
'@deepseek-ai/node-addon-landlock-run-linux-x64'
|
||||
])
|
||||
})
|
||||
|
||||
it('validates packed target dependency metadata and archive integrity', () => {
|
||||
const directory = mkdtempSync(
|
||||
join(tmpdir(), 'goodbuddy-release-dependency-test-')
|
||||
)
|
||||
const archive = join(directory, 'target.tgz')
|
||||
const contents = Buffer.from('locked target dependency')
|
||||
writeFileSync(archive, contents)
|
||||
const integrity = `sha512-${createHash('sha512')
|
||||
.update(contents)
|
||||
.digest('base64')}`
|
||||
const expected = {
|
||||
name: '@koromix/koffi-win32-arm64',
|
||||
version: '3.1.4',
|
||||
integrity
|
||||
}
|
||||
|
||||
try {
|
||||
expect(
|
||||
releaseBuilder.parsePackedPackageMetadata(
|
||||
JSON.stringify([
|
||||
{
|
||||
...expected,
|
||||
filename: 'koffi-win32-arm64.tgz'
|
||||
}
|
||||
]),
|
||||
expected
|
||||
)
|
||||
).toMatchObject({
|
||||
...expected,
|
||||
filename: 'koffi-win32-arm64.tgz'
|
||||
})
|
||||
releaseBuilder.verifyArchiveIntegrity(archive, integrity)
|
||||
expect(() =>
|
||||
releaseBuilder.verifyArchiveIntegrity(
|
||||
archive,
|
||||
`sha512-${Buffer.alloc(64).toString('base64')}`
|
||||
)
|
||||
).toThrow('完整性校验失败')
|
||||
expect(() =>
|
||||
releaseBuilder.parsePackedPackageMetadata(
|
||||
JSON.stringify([
|
||||
{
|
||||
...expected,
|
||||
version: '3.1.5',
|
||||
filename: 'koffi-win32-arm64.tgz'
|
||||
}
|
||||
]),
|
||||
expected
|
||||
)
|
||||
).toThrow('元数据不匹配')
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps Web3D test fixtures out of release resources', () => {
|
||||
const packageJson = require('../package.json') as {
|
||||
build: {
|
||||
|
||||
Reference in New Issue
Block a user