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,
|
||||
writeFile
|
||||
} = require('node:fs/promises')
|
||||
const { createReadStream, existsSync } = require('node:fs')
|
||||
const { existsSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
const { spawnSync } = require('node:child_process')
|
||||
const tar = require('tar')
|
||||
const { sha256File } = require('./file-hash.cjs')
|
||||
|
||||
const opencodeVersion = '1.18.9'
|
||||
const architectureNames = {
|
||||
@@ -28,17 +29,6 @@ function sha512Integrity(contents) {
|
||||
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) {
|
||||
const lock = JSON.parse(
|
||||
await readFile(join(projectDir, 'package-lock.json'), 'utf8')
|
||||
|
||||
+3
-1
@@ -18,7 +18,8 @@
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"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:win": "npm run build && electron-builder --win nsis --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:arm64": "npm run build && electron-builder --linux AppImage deb --arm64",
|
||||
"icons": "node build/generate-icons.mjs",
|
||||
"release:package": "node build/build-release.cjs",
|
||||
"portable": "npm run build && node build/build-portable.cjs"
|
||||
},
|
||||
"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