统一各平台发布包目录结构并补齐配置迁移工具
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-18 15:55:34 +08:00
parent d78919660b
commit 5aee534751
7 changed files with 238 additions and 56 deletions

View File

@ -40,25 +40,15 @@ jobs:
go-version: '1.26.6'
- name: Gen output name
run: |
echo "FILENAME=smail_${{ matrix.goos }}_${{ matrix.goarch }}" >> ${GITHUB_ENV}
echo "ADMINFILENAME=smail-admin_${{ matrix.goos }}_${{ matrix.goarch }}" >> ${GITHUB_ENV}
echo "WCFILENAME=wechat_push_${{ matrix.goos }}_${{ matrix.goarch }}" >> ${GITHUB_ENV}
echo "SPAMBLOCKFILENAME=spam_block_${{ matrix.goos }}_${{ matrix.goarch }}" >> ${GITHUB_ENV}
echo "TELEGRAMFILENAME=pmail_telegram_push_${{ matrix.goos }}_${{ matrix.goarch }}" >> ${GITHUB_ENV}
echo "ZIPNAME=${{ matrix.goos }}_${{ matrix.goarch }}" >> ${GITHUB_ENV}
- name: Rename Windows File
if: matrix.goos == 'windows'
run: |
echo "FILENAME=smail_${{ matrix.goos }}_${{ matrix.goarch }}.exe" >> ${GITHUB_ENV}
echo "ADMINFILENAME=smail-admin_${{ matrix.goos }}_${{ matrix.goarch }}.exe" >> ${GITHUB_ENV}
echo "WCFILENAME=wechat_push_${{ matrix.goos }}_${{ matrix.goarch }}.exe" >> ${GITHUB_ENV}
echo "SPAMBLOCKFILENAME=spam_block_${{ matrix.goos }}_${{ matrix.goarch }}.exe" >> ${GITHUB_ENV}
echo "TELEGRAMFILENAME=pmail_telegram_push_${{ matrix.goos }}_${{ matrix.goarch }}.exe" >> ${GITHUB_ENV}
archive_extension="tar.gz"
if [ "${{ matrix.goos }}" = "windows" ]; then archive_extension="zip"; fi
echo "PACKAGENAME=Smail_${{ matrix.goos }}_${{ matrix.goarch }}" >> ${GITHUB_ENV}
echo "ARCHIVENAME=Smail_${{ matrix.goos }}_${{ matrix.goarch }}.$archive_extension" >> ${GITHUB_ENV}
- name: FE Build
run: cd frontend && yarn install --frozen-lockfile && yarn build
- name: BE Build
run: |
mkdir -p out/release/config out/release/ssl out/release/data out/release/log
mkdir -p out/backend
cd plugins/pmail_telegram_push
npm ci
npm run type-check
@ -67,25 +57,27 @@ jobs:
node deploy/bundle-plugins.mjs ${{ matrix.goos }}_${{ matrix.goarch }}
extension=""
if [ "${{ matrix.goos }}" = "windows" ]; then extension=".exe"; fi
platform="${{ matrix.goos }}"
if [ "$platform" = "darwin" ]; then platform="mac"; fi
suffix="${platform}_${{ matrix.goarch }}${extension}"
cd backend
BUILD_FLAGS="-s -w -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.Version=${{ env.VERSION }}' -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.GoVersion=$(go version)' -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.GitHash=$(git show -s --format=%H)' -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)'"
go build -tags bundled -trimpath -ldflags "$BUILD_FLAGS" -o ../out/release/smail$extension ./cmd/smail
go build -trimpath -ldflags "$BUILD_FLAGS" -o ../out/release/smail-admin$extension ./cmd/smail-admin
- name: Zip
go build -tags bundled -trimpath -ldflags "$BUILD_FLAGS" -o ../out/backend/smail_$suffix ./cmd/smail
go build -trimpath -ldflags "$BUILD_FLAGS" -o ../out/backend/smail-admin_$suffix ./cmd/smail-admin
go build -trimpath -ldflags "$BUILD_FLAGS" -o ../out/backend/smail-config_$suffix ./cmd/smail-config
- name: Package release
run: |
cd ./out/release
zip -r ${{ env.ZIPNAME }}.zip smail* config ssl data log
ls
SMAIL_VERSION="${{ env.VERSION }}" GIT_HASH="$(git show -s --format=%H)" node deploy/package.mjs ${{ matrix.goos }}_${{ matrix.goarch }}
- name: Upload files to Artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: ${{ env.ZIPNAME }}
path: out/release/${{ env.ZIPNAME }}.zip
name: ${{ env.PACKAGENAME }}
path: out/release/${{ env.ARCHIVENAME }}
- name: Upload binaries to release
uses: svenstaro/upload-release-action@29e53e917877a24fad85510ded594ab3c9ca12de
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: out/release/${{ env.ZIPNAME }}.zip
file: out/release/${{ env.ARCHIVENAME }}
tag: ${{ github.ref }}
file_glob: true

View File

@ -37,6 +37,12 @@ build_server: build_fe plugin
cd backend && CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -trimpath -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail-admin_windows_arm64.exe ./cmd/smail-admin
cd backend && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -trimpath -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail-admin_mac_amd64 ./cmd/smail-admin
cd backend && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail-admin_mac_arm64 ./cmd/smail-admin
cd backend && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail-config_linux_amd64 ./cmd/smail-config
cd backend && CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail-config_linux_arm64 ./cmd/smail-config
cd backend && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail-config_windows_amd64.exe ./cmd/smail-config
cd backend && CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -trimpath -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail-config_windows_arm64.exe ./cmd/smail-config
cd backend && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -trimpath -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail-config_mac_amd64 ./cmd/smail-config
cd backend && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags "$(BUILD_LDFLAGS)" -o ../out/backend/smail-config_mac_arm64 ./cmd/smail-config
wechat_push:
node deploy/build-plugins.mjs --all --plugin=wechat_push

View File

@ -1,54 +1,132 @@
import { createHash } from 'node:crypto'
import { access, copyFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import { execFile } from 'node:child_process'
import { chmod, copyFile, lstat, mkdir, mkdtemp, readFile, rename, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { buildPlan, root, targets } from './build-plugins.mjs'
export function packagePlan(target) {
const execute = promisify(execFile)
const runtimeDirectories = ['config', 'data', 'ssl', 'log']
export function packagePlan(target, workspace = root) {
buildPlan({ target })
const [goos, goarch] = target.split('_')
const extension = goos === 'windows' ? '.exe' : ''
const suffix = `${goos === 'darwin' ? 'mac' : goos}_${goarch}${extension}`
const directory = path.join(root, 'out', 'package', target)
const directory = path.join(workspace, 'out', 'package', `Smail_${target}`, 'smail')
return [
...['smail', 'smail-admin'].map(name => ({
source: path.join(root, 'out', 'backend', `${name}_${suffix}`),
...['smail', 'smail-admin', 'smail-config'].map(name => ({
source: path.join(workspace, 'out', 'backend', `${name}_${suffix}`),
target: path.join(directory, `${name}${extension}`)
}))
]
}
async function digestFile(filename) {
const [bytes, info] = await Promise.all([readFile(filename), stat(filename)])
const info = await lstat(filename)
if (!info.isFile() || info.size === 0) throw new Error(`发布产物无效:${filename}`)
const bytes = await readFile(filename)
return { bytes: info.size, sha256: createHash('sha256').update(bytes).digest('hex') }
}
export async function packageRelease(selectedTargets = targets) {
const plan = selectedTargets.flatMap(packagePlan)
// 所有输入齐备后才写发布目录,不把旧的或其他平台插件混入发布包。
for (const item of plan) await access(item.source)
for (const item of plan) {
await mkdir(path.dirname(item.target), { recursive: true })
await copyFile(item.source, item.target)
async function createArchive(target, staging, filename) {
const options = { cwd: staging, windowsHide: true }
filename = path.basename(filename)
if (target.startsWith('windows_')) {
if (process.platform === 'win32') {
await execute('tar', ['-a', '-cf', filename, 'smail'], options)
} else {
await execute('zip', ['-q', '-r', filename, 'smail'], options)
}
return
}
let tar = process.env.SMAIL_TAR || 'tar'
const args = ['-czf', filename]
if (process.platform === 'win32') {
if (!process.env.SMAIL_TAR) {
const { stdout } = await execute('git', ['--exec-path'], { windowsHide: true })
tar = path.resolve(stdout.trim(), '../../../usr/bin/tar.exe')
}
const { stdout } = await execute(tar, ['--version'], { windowsHide: true })
if (!stdout.includes('GNU tar')) throw new Error('Windows 打包 Linux/macOS 时,请通过 SMAIL_TAR 指定 GNU tar,以保留可执行权限')
const environment = Object.entries(process.env)
const searchPath = environment.find(([name]) => name.toLowerCase() === 'path')?.[1] || ''
options.env = {
...Object.fromEntries(environment.filter(([name]) => name.toLowerCase() !== 'path')),
PATH: `${path.dirname(tar)}${path.delimiter}${searchPath}`
}
args.push('--mode=755')
}
await execute(tar, [...args, 'smail'], options)
}
async function preservePrevious(outputRoot, destinations) {
let history
for (const destination of destinations) {
const relative = path.relative(outputRoot, destination)
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('归档路径必须位于构建输出目录内')
let info
try { info = await lstat(destination) } catch (error) {
if (error.code === 'ENOENT') continue
throw error
}
if (info.isSymbolicLink()) throw new Error(`不能归档符号链接:${destination}`)
if (!history) {
const historyRoot = path.join(outputRoot, 'build-history')
await mkdir(historyRoot, { recursive: true })
history = await mkdtemp(path.join(historyRoot, 'release-'))
}
await rename(destination, path.join(history, path.basename(destination)))
}
}
export async function packageRelease(selectedTargets = targets, { workspace = root } = {}) {
if (!selectedTargets.length || new Set(selectedTargets).size !== selectedTargets.length) throw new Error('打包平台不能为空或重复')
const plan = selectedTargets.flatMap(target => packagePlan(target, workspace))
// 先校验全部输入,再从空目录打包;旧包及其中的数据只归档,不混入新包。
for (const item of plan) await digestFile(item.source)
const outputRoot = path.resolve(workspace, 'out')
const packageRoot = path.join(outputRoot, 'package')
const releaseRoot = path.join(outputRoot, 'release')
const receiptRoot = path.join(outputRoot, 'release-receipts')
for (const directory of [packageRoot, releaseRoot, receiptRoot]) await mkdir(directory, { recursive: true })
const releases = []
for (const target of selectedTargets) {
for (const name of ['config', 'ssl', 'data', 'log']) await mkdir(path.join(root, 'out', 'package', target, name), { recursive: true })
await mkdir(path.join(root, 'out', 'release-receipts'), { recursive: true })
const directory = path.join(root, 'out', 'package', target)
const files = await Promise.all(plan
.filter(item => path.dirname(item.target) === directory)
.map(async item => ({
name: path.basename(item.target),
...await digestFile(item.target)
})))
await writeFile(path.join(root, 'out', 'release-receipts', `${target}.json`), JSON.stringify({
const name = `Smail_${target}`
const directory = path.join(packageRoot, name)
const staging = await mkdtemp(path.join(packageRoot, '.staging-'))
const runtime = path.join(staging, 'smail')
await mkdir(runtime, { mode: 0o755 })
for (const name of runtimeDirectories) await mkdir(path.join(runtime, name), { mode: 0o755 })
const files = []
for (const item of packagePlan(target, workspace)) {
const name = path.basename(item.target)
const destination = path.join(runtime, name)
await copyFile(item.source, destination)
await chmod(destination, 0o755)
files.push({ name, ...await digestFile(destination) })
}
const archiveName = `${name}.${target.startsWith('windows_') ? 'zip' : 'tar.gz'}`
const stagedArchive = path.join(staging, archiveName)
await createArchive(target, staging, stagedArchive)
const archive = path.join(releaseRoot, archiveName)
const receipt = path.join(receiptRoot, `${name}.json`)
const stagedReceipt = path.join(staging, `${name}.json`)
await writeFile(stagedReceipt, JSON.stringify({
version: process.env.SMAIL_VERSION || 'dev', gitHash: process.env.GIT_HASH || '',
protocolVersion: 1, target, plugins: buildPlan({ target }).map(item => item.id), files,
archive: { name: archiveName, ...await digestFile(stagedArchive) },
createdAt: new Date().toISOString()
}, null, 2) + '\n')
await preservePrevious(outputRoot, [directory, archive, receipt])
await rename(stagedArchive, archive)
await rename(stagedReceipt, receipt)
await rename(staging, directory)
releases.push({ target, directory, archive, receipt })
}
console.log(`发布包已生成:${selectedTargets.map(target => `out/package/${target}`).join('、')}`)
console.log(`发布包已生成:${releases.map(item => item.archive).join('、')}`)
return releases
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {

View File

@ -43,8 +43,25 @@ node deploy/build-plugins.mjs --target=windows_amd64
node deploy/build-plugins.mjs --all
```
支持 Linux、Windows、macOS 的 amd64 和 arm64。插件产物位于 `out/plugins/<平台>_<架构>`,发布目录位于 `out/package/<平台>_<架构>`。每个平台的发布目录均包含主程序、管理工具、三个插件及发布元数据;插件使用后端目录中约定的固定文件名,不需要手动去掉平台后缀。
支持 Linux、Windows、macOS 的 amd64 和 arm64。插件编译中间产物位于 `out/plugins/<平台>_<架构>`,发布目录位于 `out/package/Smail_<系统>_<架构>/smail/`。系统名使用 `linux`、`windows`、`darwin`。压缩包输出到 `out/release/`,例如 `Smail_linux_amd64.tar.gz`、`Smail_windows_amd64.zip`。Windows 使用 ZIP,其余平台使用 TAR.GZ。
GitHub 发布工作流复用相同的插件构建脚本。三个插件保持独立进程,随主程序一起交付,并非合并进主程序执行文件;原有启停配置和进程隔离检查不变。编译成功不代表第三方服务已配置、推送已送达或生产平台隔离验收完成。Windows 生产模式现有的插件隔离限制仍然保留,不会因打包而绕过。
每个压缩包解压后直接得到以下结构,Windows 的三个程序带 `.exe` 后缀:
构建和打包不清理旧文件。旧版平铺产物会保留,但不会被新的按平台打包流程选入。
```text
smail/
├── smail
├── smail-admin
├── smail-config
├── config/
├── data/
├── ssl/
└── log/
```
网页和三个插件的可执行文件内嵌于主程序。插件运行时仍是独立进程,原有启停配置和进程隔离检查不变。包内四个目录为空,不携带本机配置、数据库、密钥、证书、部署或续期脚本;首次运行通过项目初始化流程生成配置。管理员恢复工具和配置迁移工具均为编译后的程序。
Makefile 和 GitHub 发布工作流统一调用 `node deploy/package.mjs`。已有编译产物时可附加平台参数单独打包,例如 `node deploy/package.mjs linux_amd64`。Windows 主机生成 Linux/macOS 压缩包时自动使用 Git for Windows 附带的 GNU tar,以写入 Unix 可执行权限,也可通过环境变量 `SMAIL_TAR` 指定其他 GNU tar 路径;Windows ZIP 使用系统 tar,Linux/macOS 主机分别使用 tar 和 zip。上述工具仅在构建机器使用,部署服务器不需要构建或部署脚本。
打包先检查三个程序,再从全新目录生成发布包。相同名字的旧目录、压缩包和回执移入 `out/build-history/release-*/` 保留,旧版平铺产物不参与打包。校验回执在 `out/release-receipts/Smail_<系统>_<架构>.json`,包含程序和压缩包的 SHA-256,不进入发布包。
编译成功不代表第三方服务已配置、推送已送达或生产平台隔离验收完成。Windows 生产模式现有的插件隔离限制仍然保留,不会因打包而绕过。

View File

@ -33,7 +33,7 @@ Smail/
└── docs/ # 项目文档
```
`out/package/` 和 `out/release/` 分别供 Makefile 打包及 CI 发布使用。开发日志由 `deploy/start-dev.ps1` 写入 `test-log/runtime/`,测试入口为 `node test/run.mjs all`。
Makefile 与 CI 使用相同打包流程:`out/package/Smail_<系统>_<架构>/smail/` 保存部署目录,`out/release/` 保存同名压缩包,校验回执独立保存在 `out/release-receipts/`。部署包固定包含三个程序和 `config/data/ssl/log` 四个空目录,完整说明见《插件构建与配置》。开发日志由 `deploy/start-dev.ps1` 写入 `test-log/runtime/`,测试入口为 `node test/run.mjs all`。
## 必须保留的例外

View File

@ -13,7 +13,7 @@ test('跨平台构建输出不得越过项目 out 目录', async () => {
const content = await read('Makefile')
assert.match(content, /GO_VERSION \?= \$\(shell cd backend && go version\)/)
const commands = [...content.matchAll(/cd ([^\s]+) && [^\n]*go build [^\n]* -o ([^\s]+) /g)]
assert.equal(commands.length, 12)
assert.equal(commands.length, 18)
for (const [, directory, output] of commands) {
const relative = path.relative(path.join(root, 'out'), path.resolve(root, directory, output))
assert.ok(relative && !relative.startsWith('..') && !path.isAbsolute(relative), output)
@ -84,9 +84,9 @@ test('六个平台默认包含三个插件,构建和发布名称与后端目
assert.ok(catalog.includes(`"${path.basename(item.output)}"`))
}
const files = packagePlan(target)
assert.equal(files.length, 2)
assert.equal(files.length, 3)
assert.equal(files.filter(file => path.basename(path.dirname(file.target)) === 'plugins').length, 0)
assert.ok(files.every(file => file.target.startsWith(path.join(root, 'out', 'package', target))))
assert.ok(files.every(file => path.dirname(file.target) === path.join(root, 'out', 'package', `Smail_${target}`, 'smail')))
}
assert.ok(buildPlan({ development: true }).every(item => path.dirname(item.output) === path.join(root, 'out', 'plugins')))
assert.throws(() => buildPlan({ target: '../invalid' }))
@ -96,7 +96,8 @@ test('六个平台默认包含三个插件,构建和发布名称与后端目
assert.match(await read('Makefile'), /package: build_server plugin/)
assert.match(await read('.github/workflows/release.yml'), /node deploy\/build-plugins\.mjs --target=/)
assert.match(await read('deploy/package.mjs'), /sha256: createHash\('sha256'\)/)
assert.match(await read('deploy/package.mjs'), /files,\r?\n createdAt/)
assert.match(await read('.github/workflows/release.yml'), /node deploy\/package\.mjs/)
assert.match(await read('.github/workflows/release.yml'), /\.\/cmd\/smail-config/)
})
test('安全工作流必须先构建嵌入资源再执行 Go 测试', async () => {

View File

@ -0,0 +1,88 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { execFile } from 'node:child_process'
import { mkdir, mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { promisify } from 'node:util'
import test from 'node:test'
import { root, targets } from '../../deploy/build-plugins.mjs'
import { packagePlan, packageRelease } from '../../deploy/package.mjs'
const execute = promisify(execFile)
async function fixture(selectedTargets) {
const directory = path.join(root, 'test-log', 'package-tests')
await mkdir(directory, { recursive: true })
const workspace = await mkdtemp(path.join(directory, 'release-'))
for (const target of selectedTargets) {
for (const item of packagePlan(target, workspace)) {
await mkdir(path.dirname(item.source), { recursive: true })
await writeFile(item.source, `fixture:${target}:${path.basename(item.source)}`)
}
}
return workspace
}
test('六个平台发布包只包含三个程序和四个空目录,解压后内容与回执一致', async () => {
const workspace = await fixture(targets)
const releases = await packageRelease(targets, { workspace })
for (const release of releases) {
const extension = release.target.startsWith('windows_') ? '.exe' : ''
const programs = ['smail', 'smail-admin', 'smail-config'].map(name => name + extension)
const directories = ['config', 'data', 'log', 'ssl']
const { stdout } = await execute('tar', ['-tf', release.archive], { windowsHide: true })
assert.deepEqual(stdout.trim().split(/\r?\n/).sort(), [
'smail/', ...programs.map(name => `smail/${name}`), ...directories.map(name => `smail/${name}/`)
].sort())
const extracted = path.join(workspace, 'extracted', release.target)
await mkdir(extracted, { recursive: true })
await execute('tar', ['-xf', release.archive, '-C', extracted], { windowsHide: true })
const receipt = JSON.parse(await readFile(release.receipt, 'utf8'))
assert.equal(receipt.files.length, 3)
assert.equal(receipt.archive.name, path.basename(release.archive))
const archiveBytes = await readFile(release.archive)
assert.equal(receipt.archive.bytes, archiveBytes.length)
assert.equal(receipt.archive.sha256, createHash('sha256').update(archiveBytes).digest('hex'))
for (const name of directories) assert.deepEqual(await readdir(path.join(extracted, 'smail', name)), [])
for (const item of packagePlan(release.target, workspace)) {
const name = path.basename(item.target)
const bytes = await readFile(path.join(extracted, 'smail', name))
assert.deepEqual(bytes, await readFile(item.source))
const record = receipt.files.find(file => file.name === name)
assert.equal(record.bytes, bytes.length)
assert.equal(record.sha256, createHash('sha256').update(bytes).digest('hex'))
}
if (!extension) {
const { stdout: details } = await execute('tar', ['-tvf', release.archive], { windowsHide: true })
for (const name of programs) {
const line = details.split(/\r?\n/).find(value => value.endsWith(`smail/${name}`))
assert.match(line, /^-rwxr-xr-x/)
}
}
}
})
test('重复打包保留旧数据但不会将其带入新发布包', async () => {
const target = 'windows_amd64'
const workspace = await fixture([target])
const [previous] = await packageRelease([target], { workspace })
const database = path.join(previous.directory, 'smail', 'data', 'private.db')
await writeFile(database, 'private fixture')
await writeFile(path.join(previous.directory, 'smail', 'release.json'), 'old metadata')
await packageRelease([target], { workspace })
const { stdout } = await execute('tar', ['-tf', previous.archive], { windowsHide: true })
assert.doesNotMatch(stdout, /private\.db|release\.json/)
const historyRoot = path.join(workspace, 'out', 'build-history')
const history = await readdir(historyRoot)
assert.equal(history.length, 1)
assert.equal(await readFile(path.join(historyRoot, history[0], `Smail_${target}`, 'smail', 'data', 'private.db'), 'utf8'), 'private fixture')
})
test('缺失或空的输入程序阻止打包,不能产生部分发布目录', async () => {
const workspace = await fixture(['windows_amd64'])
await assert.rejects(packageRelease(['windows_amd64', 'linux_amd64'], { workspace }), { code: 'ENOENT' })
await assert.rejects(readdir(path.join(workspace, 'out', 'package')), { code: 'ENOENT' })
await writeFile(packagePlan('windows_amd64', workspace)[2].source, '')
await assert.rejects(packageRelease(['windows_amd64'], { workspace }), /发布产物无效/)
await assert.rejects(readdir(path.join(workspace, 'out', 'package')), { code: 'ENOENT' })
})