feat: add cross-platform release pipeline
This commit is contained in:
@@ -1,84 +0,0 @@
|
|||||||
name: Linux packages
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
package:
|
|
||||||
name: ${{ matrix.arch }} AppImage and DEB
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- arch: x64
|
|
||||||
runner: ubuntu-24.04
|
|
||||||
deb_arch: amd64
|
|
||||||
elf_machine: x86-64
|
|
||||||
- arch: arm64
|
|
||||||
runner: ubuntu-24.04-arm
|
|
||||||
deb_arch: arm64
|
|
||||||
elf_machine: aarch64
|
|
||||||
runs-on: ${{ matrix.runner }}
|
|
||||||
timeout-minutes: 60
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 24
|
|
||||||
cache: npm
|
|
||||||
|
|
||||||
- name: Install packaging tools
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install --yes file ruby ruby-dev build-essential
|
|
||||||
sudo gem install fpm --no-document
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Run validators
|
|
||||||
run: |
|
|
||||||
npm test
|
|
||||||
npm run typecheck
|
|
||||||
npm run lint
|
|
||||||
|
|
||||||
- name: Build Linux packages
|
|
||||||
run: npm run dist:linux:${{ matrix.arch }}
|
|
||||||
|
|
||||||
- name: Verify package architecture and runtimes
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
appimage="$(find dist -maxdepth 1 -type f -name '*.AppImage' -print -quit)"
|
|
||||||
deb="$(find dist -maxdepth 1 -type f -name '*.deb' -print -quit)"
|
|
||||||
unpacked="$(find dist -maxdepth 1 -type d -name 'linux*unpacked' -print -quit)"
|
|
||||||
test -n "$appimage"
|
|
||||||
test -n "$deb"
|
|
||||||
test -n "$unpacked"
|
|
||||||
file "$unpacked/goodbuddy" | grep -qi '${{ matrix.elf_machine }}'
|
|
||||||
file "$unpacked/resources/runtimes/opencode/opencode" | grep -qi '${{ matrix.elf_machine }}'
|
|
||||||
test -f "$unpacked/resources/runtimes/continue/dist/index.js"
|
|
||||||
"$unpacked/resources/runtimes/opencode/opencode" --version
|
|
||||||
dpkg-deb --field "$deb" Architecture | grep -qx '${{ matrix.deb_arch }}'
|
|
||||||
dpkg-deb --contents "$deb" | grep -q 'runtimes/opencode/opencode'
|
|
||||||
chmod +x "$appimage"
|
|
||||||
"$appimage" --appimage-extract >/dev/null
|
|
||||||
test -f squashfs-root/resources/runtimes/continue/dist/index.js
|
|
||||||
|
|
||||||
- name: Upload packages
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: goodbuddy-linux-${{ matrix.arch }}
|
|
||||||
path: |
|
|
||||||
dist/*.AppImage
|
|
||||||
dist/*.deb
|
|
||||||
dist/*.blockmap
|
|
||||||
if-no-files-found: error
|
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
name: Cross-platform packages
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
ELECTRON_CACHE: ${{ github.workspace }}/.cache/electron
|
||||||
|
ELECTRON_BUILDER_CACHE: ${{ github.workspace }}/.cache/electron-builder
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: packages-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
name: Validate source
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Verify release tag version
|
||||||
|
if: github.ref_type == 'tag'
|
||||||
|
run: node -e "const p=require('./package.json'); const expected='v'+p.version; if(process.env.GITHUB_REF_NAME!==expected){throw new Error('Expected tag '+expected+', received '+process.env.GITHUB_REF_NAME)}"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run validators
|
||||||
|
run: |
|
||||||
|
npm test
|
||||||
|
npm run typecheck
|
||||||
|
npm run lint
|
||||||
|
|
||||||
|
- name: Build production bundle
|
||||||
|
run: npm run build:bundle
|
||||||
|
|
||||||
|
- name: Upload production bundle
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: goodbuddy-production-bundle
|
||||||
|
path: out
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
package:
|
||||||
|
name: ${{ matrix.platform }} ${{ matrix.arch }}
|
||||||
|
needs: validate
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: windows
|
||||||
|
arch: x64
|
||||||
|
runner: windows-2025
|
||||||
|
- platform: windows
|
||||||
|
arch: arm64
|
||||||
|
runner: windows-2025
|
||||||
|
- platform: macos
|
||||||
|
arch: x64
|
||||||
|
runner: macos-15-intel
|
||||||
|
- platform: macos
|
||||||
|
arch: arm64
|
||||||
|
runner: macos-15
|
||||||
|
- platform: linux
|
||||||
|
arch: x64
|
||||||
|
runner: ubuntu-24.04
|
||||||
|
- platform: linux
|
||||||
|
arch: arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
timeout-minutes: 75
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Cache packaging toolsets
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.ELECTRON_CACHE }}
|
||||||
|
${{ env.ELECTRON_BUILDER_CACHE }}
|
||||||
|
key: packaging-${{ runner.os }}-${{ matrix.arch }}-${{ hashFiles('package-lock.json') }}
|
||||||
|
restore-keys: packaging-${{ runner.os }}-${{ matrix.arch }}-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Download production bundle
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: goodbuddy-production-bundle
|
||||||
|
path: out
|
||||||
|
|
||||||
|
- name: Build and verify release packages
|
||||||
|
run: npm run release:package -- --platform ${{ matrix.platform }} --arch ${{ matrix.arch }} --skip-build
|
||||||
|
|
||||||
|
- name: Upload release packages
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: goodbuddy-${{ matrix.platform }}-${{ matrix.arch }}
|
||||||
|
path: dist/release/${{ matrix.platform }}-${{ matrix.arch }}
|
||||||
|
if-no-files-found: error
|
||||||
|
compression-level: 0
|
||||||
|
retention-days: 30
|
||||||
@@ -0,0 +1,619 @@
|
|||||||
|
const { spawnSync } = require('node:child_process')
|
||||||
|
const {
|
||||||
|
existsSync,
|
||||||
|
closeSync,
|
||||||
|
openSync,
|
||||||
|
readFileSync,
|
||||||
|
readSync,
|
||||||
|
readdirSync,
|
||||||
|
renameSync,
|
||||||
|
rmSync,
|
||||||
|
statSync,
|
||||||
|
writeFileSync
|
||||||
|
} = require('node:fs')
|
||||||
|
const { basename, dirname, join, parse, resolve } = require('node:path')
|
||||||
|
const { sha256File } = require('./file-hash.cjs')
|
||||||
|
|
||||||
|
const root = join(__dirname, '..')
|
||||||
|
const packageJson = JSON.parse(
|
||||||
|
readFileSync(join(root, 'package.json'), 'utf8')
|
||||||
|
)
|
||||||
|
const productName = packageJson.build?.productName ?? packageJson.name
|
||||||
|
const releaseRoot = join(root, 'dist', 'release')
|
||||||
|
const manifestName = 'release-manifest.json'
|
||||||
|
const supportedArchitectures = new Set(['x64', 'arm64'])
|
||||||
|
const platformAliases = new Map([
|
||||||
|
['win', 'windows'],
|
||||||
|
['win32', 'windows'],
|
||||||
|
['windows', 'windows'],
|
||||||
|
['darwin', 'macos'],
|
||||||
|
['mac', 'macos'],
|
||||||
|
['macos', 'macos'],
|
||||||
|
['linux', 'linux']
|
||||||
|
])
|
||||||
|
const hostPlatforms = {
|
||||||
|
win32: 'windows',
|
||||||
|
darwin: 'macos',
|
||||||
|
linux: 'linux'
|
||||||
|
}
|
||||||
|
const platformDefinitions = {
|
||||||
|
windows: {
|
||||||
|
builderFlag: '--win',
|
||||||
|
defaultFormats: ['nsis', 'portable'],
|
||||||
|
supportedFormats: ['nsis', 'portable'],
|
||||||
|
unpackedPattern: /^win(?:-.+)?-unpacked$/u,
|
||||||
|
executable: [`${productName}.exe`],
|
||||||
|
runtimeExecutable: 'opencode.exe'
|
||||||
|
},
|
||||||
|
macos: {
|
||||||
|
builderFlag: '--mac',
|
||||||
|
defaultFormats: ['dmg', 'zip'],
|
||||||
|
supportedFormats: ['dmg', 'zip'],
|
||||||
|
unpackedPattern: /^mac(?:-.+)?$/u,
|
||||||
|
executable: [
|
||||||
|
`${productName}.app`,
|
||||||
|
'Contents',
|
||||||
|
'MacOS',
|
||||||
|
productName
|
||||||
|
],
|
||||||
|
runtimeExecutable: 'opencode'
|
||||||
|
},
|
||||||
|
linux: {
|
||||||
|
builderFlag: '--linux',
|
||||||
|
defaultFormats: ['AppImage', 'deb'],
|
||||||
|
supportedFormats: ['AppImage', 'deb'],
|
||||||
|
unpackedPattern: /^linux(?:-.+)?-unpacked$/u,
|
||||||
|
executable: [packageJson.name],
|
||||||
|
runtimeExecutable: 'opencode'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const formatExtensions = {
|
||||||
|
nsis: '.exe',
|
||||||
|
portable: '.exe',
|
||||||
|
dmg: '.dmg',
|
||||||
|
zip: '.zip',
|
||||||
|
AppImage: '.AppImage',
|
||||||
|
deb: '.deb'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlatform(value) {
|
||||||
|
return platformAliases.get(String(value).toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFormat(platform, value) {
|
||||||
|
const definition = platformDefinitions[platform]
|
||||||
|
return definition.supportedFormats.find(
|
||||||
|
(candidate) => candidate.toLowerCase() === value.toLowerCase()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArguments(argv, environment = process) {
|
||||||
|
const options = {
|
||||||
|
platform: hostPlatforms[environment.platform],
|
||||||
|
arch: environment.arch,
|
||||||
|
formats: [],
|
||||||
|
skipBuild: false,
|
||||||
|
dryRun: false,
|
||||||
|
help: false
|
||||||
|
}
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const argument = argv[index]
|
||||||
|
if (argument === '--platform') {
|
||||||
|
options.platform = normalizePlatform(argv[index + 1])
|
||||||
|
index += 1
|
||||||
|
} else if (argument === '--arch') {
|
||||||
|
options.arch = argv[index + 1]
|
||||||
|
index += 1
|
||||||
|
} else if (argument === '--format') {
|
||||||
|
const value = argv[index + 1]
|
||||||
|
if (!value) {
|
||||||
|
throw new Error('--format 缺少值')
|
||||||
|
}
|
||||||
|
options.formats.push(
|
||||||
|
...value.split(',').map((item) => item.trim()).filter(Boolean)
|
||||||
|
)
|
||||||
|
index += 1
|
||||||
|
} else if (argument === '--skip-build') {
|
||||||
|
options.skipBuild = true
|
||||||
|
} else if (argument === '--dry-run') {
|
||||||
|
options.dryRun = true
|
||||||
|
} else if (argument === '--help' || argument === '-h') {
|
||||||
|
options.help = true
|
||||||
|
} else {
|
||||||
|
throw new Error(`未知参数:${argument}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (options.help) {
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
if (!options.platform) {
|
||||||
|
throw new Error('不支持当前系统,请显式指定 --platform')
|
||||||
|
}
|
||||||
|
if (!supportedArchitectures.has(options.arch)) {
|
||||||
|
throw new Error(`不支持的架构:${options.arch}`)
|
||||||
|
}
|
||||||
|
const definition = platformDefinitions[options.platform]
|
||||||
|
const requestedFormats =
|
||||||
|
options.formats.length > 0
|
||||||
|
? options.formats
|
||||||
|
: definition.defaultFormats
|
||||||
|
options.formats = [...new Set(requestedFormats.map((format) => {
|
||||||
|
const normalized = normalizeFormat(options.platform, format)
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error(
|
||||||
|
`${options.platform} 不支持打包格式:${format}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}))]
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
function npmInvocation(environment = process.env) {
|
||||||
|
if (environment.npm_execpath) {
|
||||||
|
return {
|
||||||
|
command: process.execPath,
|
||||||
|
prefixArgs: [environment.npm_execpath]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
command: process.platform === 'win32' ? 'npm.cmd' : 'npm',
|
||||||
|
prefixArgs: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, args, environment = process.env) {
|
||||||
|
const result = spawnSync(command, args, {
|
||||||
|
cwd: root,
|
||||||
|
env: environment,
|
||||||
|
shell: false,
|
||||||
|
stdio: 'inherit',
|
||||||
|
windowsHide: true
|
||||||
|
})
|
||||||
|
if (result.error) {
|
||||||
|
throw result.error
|
||||||
|
}
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`命令执行失败(code ${result.status ?? 1}):${command} ${args.join(' ')}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildElectronBuilderArguments(options, outputDirectory) {
|
||||||
|
const definition = platformDefinitions[options.platform]
|
||||||
|
const builderArguments = [
|
||||||
|
join(root, 'node_modules', 'electron-builder', 'cli.js'),
|
||||||
|
definition.builderFlag,
|
||||||
|
...options.formats,
|
||||||
|
`--${options.arch}`,
|
||||||
|
`--config.directories.output=${outputDirectory}`
|
||||||
|
]
|
||||||
|
if (
|
||||||
|
options.platform === 'windows' &&
|
||||||
|
options.formats.includes('nsis')
|
||||||
|
) {
|
||||||
|
builderArguments.push(
|
||||||
|
`--config.nsis.artifactName=${productName}-\${version}-windows-\${arch}-setup.\${ext}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
options.platform === 'windows' &&
|
||||||
|
options.formats.includes('portable')
|
||||||
|
) {
|
||||||
|
builderArguments.push(
|
||||||
|
`--config.portable.artifactName=${productName}-\${version}-windows-\${arch}-portable.\${ext}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return builderArguments
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectBinaryArchitecture(buffer) {
|
||||||
|
if (
|
||||||
|
buffer.length >= 64 &&
|
||||||
|
buffer[0] === 0x4d &&
|
||||||
|
buffer[1] === 0x5a
|
||||||
|
) {
|
||||||
|
const peOffset = buffer.readUInt32LE(0x3c)
|
||||||
|
if (
|
||||||
|
peOffset + 6 <= buffer.length &&
|
||||||
|
buffer.toString('ascii', peOffset, peOffset + 4) === 'PE\0\0'
|
||||||
|
) {
|
||||||
|
const machine = buffer.readUInt16LE(peOffset + 4)
|
||||||
|
if (machine === 0x8664) {
|
||||||
|
return 'x64'
|
||||||
|
}
|
||||||
|
if (machine === 0xaa64) {
|
||||||
|
return 'arm64'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
buffer.length >= 20 &&
|
||||||
|
buffer[0] === 0x7f &&
|
||||||
|
buffer.toString('ascii', 1, 4) === 'ELF'
|
||||||
|
) {
|
||||||
|
const machine =
|
||||||
|
buffer[5] === 2
|
||||||
|
? buffer.readUInt16BE(18)
|
||||||
|
: buffer.readUInt16LE(18)
|
||||||
|
if (machine === 62) {
|
||||||
|
return 'x64'
|
||||||
|
}
|
||||||
|
if (machine === 183) {
|
||||||
|
return 'arm64'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (buffer.length >= 8) {
|
||||||
|
const littleMagic = buffer.readUInt32LE(0)
|
||||||
|
const bigMagic = buffer.readUInt32BE(0)
|
||||||
|
const cpuType =
|
||||||
|
littleMagic === 0xfeedfacf
|
||||||
|
? buffer.readUInt32LE(4)
|
||||||
|
: bigMagic === 0xfeedfacf
|
||||||
|
? buffer.readUInt32BE(4)
|
||||||
|
: undefined
|
||||||
|
if (cpuType === 0x01000007) {
|
||||||
|
return 'x64'
|
||||||
|
}
|
||||||
|
if (cpuType === 0x0100000c) {
|
||||||
|
return 'arm64'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function binaryArchitecture(filePath) {
|
||||||
|
return detectBinaryArchitecture(readChunk(filePath, 4096))
|
||||||
|
}
|
||||||
|
|
||||||
|
function readChunk(filePath, length, position = 0) {
|
||||||
|
const descriptor = openSync(filePath, 'r')
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.alloc(length)
|
||||||
|
const bytesRead = readSync(
|
||||||
|
descriptor,
|
||||||
|
buffer,
|
||||||
|
0,
|
||||||
|
length,
|
||||||
|
position
|
||||||
|
)
|
||||||
|
return buffer.subarray(0, bytesRead)
|
||||||
|
} finally {
|
||||||
|
closeSync(descriptor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findUnpackedDirectory(directory, platform) {
|
||||||
|
const pattern = platformDefinitions[platform].unpackedPattern
|
||||||
|
const candidates = readdirSync(directory, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isDirectory() && pattern.test(entry.name))
|
||||||
|
.map((entry) => join(directory, entry.name))
|
||||||
|
if (candidates.length !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
`无法确定 ${platform} unpacked 目录:${candidates.join(', ') || '未生成'}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return candidates[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourceDirectory(unpackedDirectory, platform) {
|
||||||
|
return platform === 'macos'
|
||||||
|
? join(
|
||||||
|
unpackedDirectory,
|
||||||
|
`${productName}.app`,
|
||||||
|
'Contents',
|
||||||
|
'Resources'
|
||||||
|
)
|
||||||
|
: join(unpackedDirectory, 'resources')
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertFile(filePath, description) {
|
||||||
|
if (!statSync(filePath, { throwIfNoEntry: false })?.isFile()) {
|
||||||
|
throw new Error(`${description}缺失:${filePath}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyUnpackedOutput(directory, options) {
|
||||||
|
const definition = platformDefinitions[options.platform]
|
||||||
|
const unpackedDirectory = findUnpackedDirectory(
|
||||||
|
directory,
|
||||||
|
options.platform
|
||||||
|
)
|
||||||
|
const applicationExecutable = join(
|
||||||
|
unpackedDirectory,
|
||||||
|
...definition.executable
|
||||||
|
)
|
||||||
|
const resources = resourceDirectory(
|
||||||
|
unpackedDirectory,
|
||||||
|
options.platform
|
||||||
|
)
|
||||||
|
const runtimeExecutable = join(
|
||||||
|
resources,
|
||||||
|
'runtimes',
|
||||||
|
'opencode',
|
||||||
|
definition.runtimeExecutable
|
||||||
|
)
|
||||||
|
assertFile(applicationExecutable, '应用主程序')
|
||||||
|
assertFile(join(resources, 'app.asar'), '应用 ASAR')
|
||||||
|
assertFile(runtimeExecutable, 'OpenCode Runtime')
|
||||||
|
assertFile(
|
||||||
|
join(resources, 'runtimes', 'continue', 'dist', 'index.js'),
|
||||||
|
'Continue Runtime'
|
||||||
|
)
|
||||||
|
for (const [filePath, label] of [
|
||||||
|
[applicationExecutable, '应用主程序'],
|
||||||
|
[runtimeExecutable, 'OpenCode Runtime']
|
||||||
|
]) {
|
||||||
|
const actualArchitecture = binaryArchitecture(filePath)
|
||||||
|
if (actualArchitecture !== options.arch) {
|
||||||
|
throw new Error(
|
||||||
|
`${label}架构错误:期望 ${options.arch},实际 ${actualArchitecture ?? '未知'}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unpackedDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyArtifacts(directory, options) {
|
||||||
|
const files = readdirSync(directory, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isFile())
|
||||||
|
.map((entry) => entry.name)
|
||||||
|
for (const format of options.formats) {
|
||||||
|
const extension = formatExtensions[format]
|
||||||
|
const candidates = files.filter((name) => name.endsWith(extension))
|
||||||
|
const matches =
|
||||||
|
options.platform === 'windows'
|
||||||
|
? candidates.filter((name) =>
|
||||||
|
format === 'nsis'
|
||||||
|
? /-setup\.exe$/iu.test(name)
|
||||||
|
: /-portable\.exe$/iu.test(name)
|
||||||
|
)
|
||||||
|
: candidates
|
||||||
|
if (matches.length !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
`${format} 产物数量错误:${matches.join(', ') || '未生成'}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
verifyArtifactSignature(
|
||||||
|
join(directory, matches[0]),
|
||||||
|
format,
|
||||||
|
options.arch
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyArtifactSignature(filePath, format, arch) {
|
||||||
|
if (format === 'nsis' || format === 'portable') {
|
||||||
|
if (readChunk(filePath, 2).toString('ascii') !== 'MZ') {
|
||||||
|
throw new Error(`${format} 产物不是有效的 Windows PE 文件`)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (format === 'zip') {
|
||||||
|
const signature = readChunk(filePath, 4).toString('hex')
|
||||||
|
if (
|
||||||
|
!['504b0304', '504b0506', '504b0708'].includes(signature)
|
||||||
|
) {
|
||||||
|
throw new Error('zip 产物不是有效的 ZIP 文件')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (format === 'dmg') {
|
||||||
|
const size = statSync(filePath).size
|
||||||
|
if (
|
||||||
|
size < 512 ||
|
||||||
|
readChunk(filePath, 4, size - 512).toString('ascii') !==
|
||||||
|
'koly'
|
||||||
|
) {
|
||||||
|
throw new Error('dmg 产物缺少 UDIF 尾部')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (format === 'AppImage') {
|
||||||
|
if (
|
||||||
|
detectBinaryArchitecture(readChunk(filePath, 4096)) !== arch
|
||||||
|
) {
|
||||||
|
throw new Error(`AppImage 产物架构不是 ${arch}`)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
format === 'deb' &&
|
||||||
|
readChunk(filePath, 8).toString('ascii') !== '!<arch>\n'
|
||||||
|
) {
|
||||||
|
throw new Error('deb 产物不是有效的 ar 归档')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeManifest(directory, options) {
|
||||||
|
const entries = readdirSync(directory, { withFileTypes: true })
|
||||||
|
.filter(
|
||||||
|
(entry) => entry.isFile() && entry.name !== manifestName
|
||||||
|
)
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name))
|
||||||
|
const files = []
|
||||||
|
for (const entry of entries) {
|
||||||
|
const filePath = join(directory, entry.name)
|
||||||
|
files.push({
|
||||||
|
name: entry.name,
|
||||||
|
size: statSync(filePath).size,
|
||||||
|
sha256: await sha256File(filePath)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const manifest = {
|
||||||
|
formatVersion: 1,
|
||||||
|
productName,
|
||||||
|
version: packageJson.version,
|
||||||
|
platform: options.platform,
|
||||||
|
arch: options.arch,
|
||||||
|
formats: options.formats,
|
||||||
|
files
|
||||||
|
}
|
||||||
|
writeFileSync(
|
||||||
|
join(directory, manifestName),
|
||||||
|
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
return manifest
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertReplaceableOutput(directory, options) {
|
||||||
|
if (!existsSync(directory)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (readdirSync(directory).length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
readFileSync(join(directory, manifestName), 'utf8')
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
manifest.productName === productName &&
|
||||||
|
manifest.formatVersion === 1 &&
|
||||||
|
manifest.platform === options.platform &&
|
||||||
|
manifest.arch === options.arch
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// The explicit error below describes the safe recovery path.
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`拒绝覆盖未识别的发布目录:${directory}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceOutput(stagingDirectory, destination, options) {
|
||||||
|
const backup = `${destination}.previous-${process.pid}`
|
||||||
|
assertReplaceableOutput(destination, options)
|
||||||
|
rmSync(backup, { recursive: true, force: true })
|
||||||
|
if (existsSync(destination)) {
|
||||||
|
renameSync(destination, backup)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
renameSync(stagingDirectory, destination)
|
||||||
|
} catch (error) {
|
||||||
|
if (!existsSync(destination) && existsSync(backup)) {
|
||||||
|
renameSync(backup, destination)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
rmSync(backup, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeReleasePath(filePath) {
|
||||||
|
const resolved = resolve(filePath)
|
||||||
|
if (
|
||||||
|
resolved === parse(resolved).root ||
|
||||||
|
resolved === root ||
|
||||||
|
resolved === dirname(releaseRoot)
|
||||||
|
) {
|
||||||
|
throw new Error(`拒绝使用不安全的发布目录:${resolved}`)
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp() {
|
||||||
|
console.log(`${productName} 跨平台发布构建
|
||||||
|
|
||||||
|
用法:
|
||||||
|
npm run release:package -- --platform <windows|macos|linux> --arch <x64|arm64>
|
||||||
|
|
||||||
|
参数:
|
||||||
|
--format <列表> 覆盖默认格式,逗号分隔
|
||||||
|
--skip-build 复用已有 out 生产构建
|
||||||
|
--dry-run 仅显示目标与 electron-builder 参数
|
||||||
|
|
||||||
|
默认格式:
|
||||||
|
windows: nsis, portable
|
||||||
|
macos: dmg, zip
|
||||||
|
linux: AppImage, deb`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(argv = process.argv.slice(2)) {
|
||||||
|
const options = parseArguments(argv)
|
||||||
|
if (options.help) {
|
||||||
|
printHelp()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const targetName = `${options.platform}-${options.arch}`
|
||||||
|
const destination = safeReleasePath(join(releaseRoot, targetName))
|
||||||
|
const stagingDirectory = safeReleasePath(
|
||||||
|
join(releaseRoot, `.stage-${targetName}-${process.pid}`)
|
||||||
|
)
|
||||||
|
const builderArguments = buildElectronBuilderArguments(
|
||||||
|
options,
|
||||||
|
stagingDirectory
|
||||||
|
)
|
||||||
|
if (options.dryRun) {
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
target: targetName,
|
||||||
|
formats: options.formats,
|
||||||
|
output: destination,
|
||||||
|
command: process.execPath,
|
||||||
|
arguments: builderArguments
|
||||||
|
}, null, 2))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const hostPlatform = hostPlatforms[process.platform]
|
||||||
|
if (options.platform !== hostPlatform) {
|
||||||
|
throw new Error(
|
||||||
|
`${options.platform} 包必须在对应系统构建,当前系统为 ${hostPlatform ?? process.platform}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
rmSync(stagingDirectory, { recursive: true, force: true })
|
||||||
|
try {
|
||||||
|
if (!options.skipBuild) {
|
||||||
|
const npm = npmInvocation()
|
||||||
|
run(npm.command, [...npm.prefixArgs, 'run', 'build'])
|
||||||
|
}
|
||||||
|
run(
|
||||||
|
process.execPath,
|
||||||
|
builderArguments,
|
||||||
|
{
|
||||||
|
...process.env,
|
||||||
|
CSC_IDENTITY_AUTO_DISCOVERY:
|
||||||
|
process.env.CSC_IDENTITY_AUTO_DISCOVERY ?? 'false'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const unpackedDirectory = verifyUnpackedOutput(
|
||||||
|
stagingDirectory,
|
||||||
|
options
|
||||||
|
)
|
||||||
|
verifyArtifacts(stagingDirectory, options)
|
||||||
|
rmSync(unpackedDirectory, { recursive: true, force: true })
|
||||||
|
const manifest = await writeManifest(stagingDirectory, options)
|
||||||
|
replaceOutput(stagingDirectory, destination, options)
|
||||||
|
console.log(`发布包构建完成:${destination}`)
|
||||||
|
for (const file of manifest.files) {
|
||||||
|
console.log(
|
||||||
|
`${basename(file.name)} ${file.size} bytes sha256:${file.sha256}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
rmSync(stagingDirectory, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
assertReplaceableOutput,
|
||||||
|
buildElectronBuilderArguments,
|
||||||
|
detectBinaryArchitecture,
|
||||||
|
normalizePlatform,
|
||||||
|
parseArguments,
|
||||||
|
platformDefinitions,
|
||||||
|
replaceOutput,
|
||||||
|
verifyArtifacts,
|
||||||
|
verifyArtifactSignature,
|
||||||
|
writeManifest
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
const { createHash } = require('node:crypto')
|
||||||
|
const { createReadStream } = require('node:fs')
|
||||||
|
|
||||||
|
function sha256File(filePath) {
|
||||||
|
const hash = createHash('sha256')
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const stream = createReadStream(filePath)
|
||||||
|
stream.on('data', (chunk) => hash.update(chunk))
|
||||||
|
stream.once('error', reject)
|
||||||
|
stream.once('end', () => resolve(hash.digest('hex')))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { sha256File }
|
||||||
+2
-12
@@ -8,10 +8,11 @@ const {
|
|||||||
stat,
|
stat,
|
||||||
writeFile
|
writeFile
|
||||||
} = require('node:fs/promises')
|
} = require('node:fs/promises')
|
||||||
const { createReadStream, existsSync } = require('node:fs')
|
const { existsSync } = require('node:fs')
|
||||||
const { join } = require('node:path')
|
const { join } = require('node:path')
|
||||||
const { spawnSync } = require('node:child_process')
|
const { spawnSync } = require('node:child_process')
|
||||||
const tar = require('tar')
|
const tar = require('tar')
|
||||||
|
const { sha256File } = require('./file-hash.cjs')
|
||||||
|
|
||||||
const opencodeVersion = '1.18.9'
|
const opencodeVersion = '1.18.9'
|
||||||
const architectureNames = {
|
const architectureNames = {
|
||||||
@@ -28,17 +29,6 @@ function sha512Integrity(contents) {
|
|||||||
return `sha512-${createHash('sha512').update(contents).digest('base64')}`
|
return `sha512-${createHash('sha512').update(contents).digest('base64')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sha256File(filePath) {
|
|
||||||
const hash = createHash('sha256')
|
|
||||||
await new Promise((resolveHash, reject) => {
|
|
||||||
const stream = createReadStream(filePath)
|
|
||||||
stream.on('data', (chunk) => hash.update(chunk))
|
|
||||||
stream.once('error', reject)
|
|
||||||
stream.once('end', resolveHash)
|
|
||||||
})
|
|
||||||
return hash.digest('hex')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function lockedIntegrity(projectDir, packageName) {
|
async function lockedIntegrity(projectDir, packageName) {
|
||||||
const lock = JSON.parse(
|
const lock = JSON.parse(
|
||||||
await readFile(join(projectDir, 'package-lock.json'), 'utf8')
|
await readFile(join(projectDir, 'package-lock.json'), 'utf8')
|
||||||
|
|||||||
+3
-1
@@ -18,7 +18,8 @@
|
|||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"build": "npm run typecheck && electron-vite build",
|
"build": "npm run typecheck && npm run build:bundle",
|
||||||
|
"build:bundle": "electron-vite build",
|
||||||
"dist": "npm run build && electron-builder",
|
"dist": "npm run build && electron-builder",
|
||||||
"dist:win": "npm run build && electron-builder --win nsis --x64 --arm64",
|
"dist:win": "npm run build && electron-builder --win nsis --x64 --arm64",
|
||||||
"dist:mac": "npm run build && electron-builder --mac dmg --x64 --arm64",
|
"dist:mac": "npm run build && electron-builder --mac dmg --x64 --arm64",
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"dist:linux:x64": "npm run build && electron-builder --linux AppImage deb --x64",
|
"dist:linux:x64": "npm run build && electron-builder --linux AppImage deb --x64",
|
||||||
"dist:linux:arm64": "npm run build && electron-builder --linux AppImage deb --arm64",
|
"dist:linux:arm64": "npm run build && electron-builder --linux AppImage deb --arm64",
|
||||||
"icons": "node build/generate-icons.mjs",
|
"icons": "node build/generate-icons.mjs",
|
||||||
|
"release:package": "node build/build-release.cjs",
|
||||||
"portable": "npm run build && node build/build-portable.cjs"
|
"portable": "npm run build && node build/build-portable.cjs"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
|
|||||||
@@ -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