From a7ee56ea4d71d50502e537cd2994b50ccb78d90b Mon Sep 17 00:00:00 2001 From: lofyer Date: Tue, 4 Aug 2026 15:12:49 +0800 Subject: [PATCH] feat: add cross-platform release pipeline --- .github/workflows/linux-packages.yml | 84 ---- .github/workflows/packages.yml | 122 ++++++ build/build-release.cjs | 619 +++++++++++++++++++++++++++ build/file-hash.cjs | 14 + build/runtime-hooks.cjs | 14 +- package.json | 4 +- tests/build-release.test.ts | 331 ++++++++++++++ 7 files changed, 1091 insertions(+), 97 deletions(-) delete mode 100644 .github/workflows/linux-packages.yml create mode 100644 .github/workflows/packages.yml create mode 100644 build/build-release.cjs create mode 100644 build/file-hash.cjs create mode 100644 tests/build-release.test.ts diff --git a/.github/workflows/linux-packages.yml b/.github/workflows/linux-packages.yml deleted file mode 100644 index 9c32a99..0000000 --- a/.github/workflows/linux-packages.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml new file mode 100644 index 0000000..c225488 --- /dev/null +++ b/.github/workflows/packages.yml @@ -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 diff --git a/build/build-release.cjs b/build/build-release.cjs new file mode 100644 index 0000000..60da60f --- /dev/null +++ b/build/build-release.cjs @@ -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') !== '!\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 --arch + +参数: + --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 + }) +} diff --git a/build/file-hash.cjs b/build/file-hash.cjs new file mode 100644 index 0000000..e7b3712 --- /dev/null +++ b/build/file-hash.cjs @@ -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 } diff --git a/build/runtime-hooks.cjs b/build/runtime-hooks.cjs index f090c6b..480c32c 100644 --- a/build/runtime-hooks.cjs +++ b/build/runtime-hooks.cjs @@ -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') diff --git a/package.json b/package.json index 4976248..9c7e611 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/tests/build-release.test.ts b/tests/build-release.test.ts new file mode 100644 index 0000000..b82bb37 --- /dev/null +++ b/tests/build-release.test.ts @@ -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 }) + } + }) +})