This commit is contained in:
parent
d78919660b
commit
5aee534751
40
.github/workflows/release.yml
vendored
40
.github/workflows/release.yml
vendored
@ -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
|
||||
|
||||
|
||||
6
Makefile
6
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
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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 生产模式现有的插件隔离限制仍然保留,不会因打包而绕过。
|
||||
|
||||
@ -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`。
|
||||
|
||||
## 必须保留的例外
|
||||
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
88
test/frontend/package-release.test.mjs
Normal file
88
test/frontend/package-release.test.mjs
Normal 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' })
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user