feat: add cross-platform release pipeline
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
interface ReleaseOptions {
|
||||
platform: 'windows' | 'macos' | 'linux'
|
||||
arch: 'x64' | 'arm64'
|
||||
formats: string[]
|
||||
skipBuild: boolean
|
||||
dryRun: boolean
|
||||
help: boolean
|
||||
}
|
||||
|
||||
interface ReleaseBuilderModule {
|
||||
assertReplaceableOutput: (
|
||||
directory: string,
|
||||
options: ReleaseOptions
|
||||
) => void
|
||||
buildElectronBuilderArguments: (
|
||||
options: ReleaseOptions,
|
||||
outputDirectory: string
|
||||
) => string[]
|
||||
detectBinaryArchitecture: (
|
||||
buffer: Buffer
|
||||
) => 'x64' | 'arm64' | undefined
|
||||
parseArguments: (
|
||||
arguments_: string[],
|
||||
environment?: { platform: string; arch: string }
|
||||
) => ReleaseOptions
|
||||
replaceOutput: (
|
||||
stagingDirectory: string,
|
||||
destination: string,
|
||||
options: ReleaseOptions
|
||||
) => void
|
||||
verifyArtifacts: (
|
||||
directory: string,
|
||||
options: ReleaseOptions
|
||||
) => void
|
||||
writeManifest: (
|
||||
directory: string,
|
||||
options: ReleaseOptions
|
||||
) => Promise<{
|
||||
files: Array<{
|
||||
name: string
|
||||
size: number
|
||||
sha256: string
|
||||
}>
|
||||
}>
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const releaseBuilder = require(
|
||||
'../build/build-release.cjs'
|
||||
) as ReleaseBuilderModule
|
||||
const windowsOptions: ReleaseOptions = {
|
||||
platform: 'windows',
|
||||
arch: 'x64',
|
||||
formats: ['nsis', 'portable'],
|
||||
skipBuild: false,
|
||||
dryRun: false,
|
||||
help: false
|
||||
}
|
||||
|
||||
function pe(machine: number): Buffer {
|
||||
const buffer = Buffer.alloc(256)
|
||||
buffer.write('MZ', 0, 'ascii')
|
||||
buffer.writeUInt32LE(128, 0x3c)
|
||||
buffer.write('PE\0\0', 128, 'ascii')
|
||||
buffer.writeUInt16LE(machine, 132)
|
||||
return buffer
|
||||
}
|
||||
|
||||
function elf(machine: number, bigEndian = false): Buffer {
|
||||
const buffer = Buffer.alloc(64)
|
||||
buffer.set([0x7f, 0x45, 0x4c, 0x46])
|
||||
buffer[5] = bigEndian ? 2 : 1
|
||||
if (bigEndian) {
|
||||
buffer.writeUInt16BE(machine, 18)
|
||||
} else {
|
||||
buffer.writeUInt16LE(machine, 18)
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
|
||||
function machO(cpuType: number): Buffer {
|
||||
const buffer = Buffer.alloc(8)
|
||||
buffer.writeUInt32LE(0xfeedfacf, 0)
|
||||
buffer.writeUInt32LE(cpuType, 4)
|
||||
return buffer
|
||||
}
|
||||
|
||||
describe('release build arguments', () => {
|
||||
it.each([
|
||||
['win32', 'x64', 'windows', ['nsis', 'portable']],
|
||||
['darwin', 'arm64', 'macos', ['dmg', 'zip']],
|
||||
['linux', 'x64', 'linux', ['AppImage', 'deb']]
|
||||
])(
|
||||
'uses %s host defaults',
|
||||
(host, arch, platform, formats) => {
|
||||
expect(
|
||||
releaseBuilder.parseArguments([], {
|
||||
platform: host,
|
||||
arch
|
||||
})
|
||||
).toMatchObject({ platform, arch, formats })
|
||||
}
|
||||
)
|
||||
|
||||
it('normalizes aliases, formats, and duplicate formats', () => {
|
||||
expect(
|
||||
releaseBuilder.parseArguments(
|
||||
[
|
||||
'--platform',
|
||||
'mac',
|
||||
'--arch',
|
||||
'arm64',
|
||||
'--format',
|
||||
'DMG,zip,dmg',
|
||||
'--skip-build',
|
||||
'--dry-run'
|
||||
],
|
||||
{ platform: 'win32', arch: 'x64' }
|
||||
)
|
||||
).toEqual({
|
||||
platform: 'macos',
|
||||
arch: 'arm64',
|
||||
formats: ['dmg', 'zip'],
|
||||
skipBuild: true,
|
||||
dryRun: true,
|
||||
help: false
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
[['--platform', 'freebsd'], '不支持当前系统'],
|
||||
[['--arch', 'ia32'], '不支持的架构'],
|
||||
[['--format', 'rpm'], '不支持打包格式'],
|
||||
[['--unknown'], '未知参数']
|
||||
])('rejects invalid arguments', (arguments_, message) => {
|
||||
expect(() =>
|
||||
releaseBuilder.parseArguments(arguments_, {
|
||||
platform: 'linux',
|
||||
arch: 'x64'
|
||||
})
|
||||
).toThrow(message)
|
||||
})
|
||||
|
||||
it('builds target-specific electron-builder arguments', () => {
|
||||
const arguments_ =
|
||||
releaseBuilder.buildElectronBuilderArguments(
|
||||
{
|
||||
platform: 'windows',
|
||||
arch: 'arm64',
|
||||
formats: ['nsis', 'portable'],
|
||||
skipBuild: false,
|
||||
dryRun: false,
|
||||
help: false
|
||||
},
|
||||
'C:\\release-stage'
|
||||
)
|
||||
|
||||
expect(arguments_).toEqual(
|
||||
expect.arrayContaining([
|
||||
'--win',
|
||||
'nsis',
|
||||
'portable',
|
||||
'--arm64',
|
||||
'--config.directories.output=C:\\release-stage',
|
||||
expect.stringContaining('nsis.artifactName='),
|
||||
expect.stringContaining('portable.artifactName=')
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('release binary architecture detection', () => {
|
||||
it.each([
|
||||
[pe(0x8664), 'x64'],
|
||||
[pe(0xaa64), 'arm64'],
|
||||
[elf(62), 'x64'],
|
||||
[elf(183, true), 'arm64'],
|
||||
[machO(0x01000007), 'x64'],
|
||||
[machO(0x0100000c), 'arm64'],
|
||||
[Buffer.from('not an executable'), undefined]
|
||||
])('detects binary headers', (buffer, expected) => {
|
||||
expect(
|
||||
releaseBuilder.detectBinaryArchitecture(buffer)
|
||||
).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('release output safety', () => {
|
||||
it('writes a deterministic artifact manifest with streaming hashes', async () => {
|
||||
const directory = mkdtempSync(
|
||||
join(tmpdir(), 'goodbuddy-release-manifest-')
|
||||
)
|
||||
try {
|
||||
writeFileSync(join(directory, 'artifact.exe'), 'release')
|
||||
const manifest = await releaseBuilder.writeManifest(
|
||||
directory,
|
||||
windowsOptions
|
||||
)
|
||||
|
||||
expect(manifest.files).toEqual([
|
||||
{
|
||||
name: 'artifact.exe',
|
||||
size: 7,
|
||||
sha256:
|
||||
'a4d451ec23463726f72c43d64c710968f6b602cd653b4de8adee1b556240a829'
|
||||
}
|
||||
])
|
||||
expect(
|
||||
JSON.parse(
|
||||
readFileSync(
|
||||
join(directory, 'release-manifest.json'),
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
).toMatchObject({
|
||||
productName: 'GoodBuddy',
|
||||
platform: 'windows',
|
||||
arch: 'x64'
|
||||
})
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses to replace an unrecognized non-empty directory', () => {
|
||||
const directory = mkdtempSync(
|
||||
join(tmpdir(), 'goodbuddy-release-unsafe-')
|
||||
)
|
||||
try {
|
||||
writeFileSync(join(directory, 'user-file.txt'), 'keep')
|
||||
expect(() =>
|
||||
releaseBuilder.assertReplaceableOutput(
|
||||
directory,
|
||||
windowsOptions
|
||||
)
|
||||
).toThrow('拒绝覆盖未识别的发布目录')
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('atomically replaces a recognized release directory', () => {
|
||||
const parent = mkdtempSync(
|
||||
join(tmpdir(), 'goodbuddy-release-replace-')
|
||||
)
|
||||
const destination = join(parent, 'windows-x64')
|
||||
const staging = join(parent, 'staging')
|
||||
try {
|
||||
mkdirSync(destination)
|
||||
mkdirSync(staging)
|
||||
writeFileSync(
|
||||
join(destination, 'release-manifest.json'),
|
||||
JSON.stringify({
|
||||
formatVersion: 1,
|
||||
productName: 'GoodBuddy',
|
||||
platform: 'windows',
|
||||
arch: 'x64'
|
||||
})
|
||||
)
|
||||
writeFileSync(join(destination, 'old.exe'), 'old')
|
||||
writeFileSync(join(staging, 'new.exe'), 'new')
|
||||
|
||||
releaseBuilder.replaceOutput(
|
||||
staging,
|
||||
destination,
|
||||
windowsOptions
|
||||
)
|
||||
|
||||
expect(existsSync(join(destination, 'new.exe'))).toBe(true)
|
||||
expect(existsSync(join(destination, 'old.exe'))).toBe(false)
|
||||
expect(existsSync(staging)).toBe(false)
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('requires one artifact for every requested format', () => {
|
||||
const directory = mkdtempSync(
|
||||
join(tmpdir(), 'goodbuddy-release-artifacts-')
|
||||
)
|
||||
try {
|
||||
writeFileSync(
|
||||
join(
|
||||
directory,
|
||||
'GoodBuddy-0.1.0-windows-x64-setup.exe'
|
||||
),
|
||||
'MZ'
|
||||
)
|
||||
writeFileSync(
|
||||
join(
|
||||
directory,
|
||||
'GoodBuddy-0.1.0-windows-x64-portable.exe'
|
||||
),
|
||||
'MZ'
|
||||
)
|
||||
expect(() =>
|
||||
releaseBuilder.verifyArtifacts(
|
||||
directory,
|
||||
windowsOptions
|
||||
)
|
||||
).not.toThrow()
|
||||
rmSync(
|
||||
join(
|
||||
directory,
|
||||
'GoodBuddy-0.1.0-windows-x64-portable.exe'
|
||||
)
|
||||
)
|
||||
expect(() =>
|
||||
releaseBuilder.verifyArtifacts(
|
||||
directory,
|
||||
windowsOptions
|
||||
)
|
||||
).toThrow('portable 产物数量错误')
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user