diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e676f9..ba77d75 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/Makefile b/Makefile index 76e1036..da08bb1 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/deploy/package.mjs b/deploy/package.mjs index 8bf989f..4b609ce 100644 --- a/deploy/package.mjs +++ b/deploy/package.mjs @@ -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)) { diff --git a/docs/插件构建与配置.md b/docs/插件构建与配置.md index be429c2..20ecd75 100644 --- a/docs/插件构建与配置.md +++ b/docs/插件构建与配置.md @@ -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 生产模式现有的插件隔离限制仍然保留,不会因打包而绕过。 diff --git a/docs/文件目录整理说明.md b/docs/文件目录整理说明.md index 5edd220..6160991 100644 --- a/docs/文件目录整理说明.md +++ b/docs/文件目录整理说明.md @@ -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`。 ## 必须保留的例外 diff --git a/test/frontend/output-paths.test.mjs b/test/frontend/output-paths.test.mjs index 2d6a798..c0ad570 100644 --- a/test/frontend/output-paths.test.mjs +++ b/test/frontend/output-paths.test.mjs @@ -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 () => { diff --git a/test/frontend/package-release.test.mjs b/test/frontend/package-release.test.mjs new file mode 100644 index 0000000..817d28f --- /dev/null +++ b/test/frontend/package-release.test.mjs @@ -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' }) +})