feat: 增加 Linux 打包与 GitHub 构建发布链
构建与发布 / 单测与集成测试 (push) Waiting to run
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
构建与发布 / 发布 GitHub Release (push) Blocked by required conditions

新增 build-linux.js,直接从官方 zip 转写 tar 保住可执行位,
Windows 上也能构建 Linux 包。新增 build-release.js 作为发布件
唯一出口,排除便携版 data/、回读产物校验内容、生成校验和。
GitHub Actions 分测试、四目标打包、标签发布三段。
This commit is contained in:
lofyer
2026-08-05 19:07:11 +08:00
parent cb7b020dc8
commit 7ca023023e
7 changed files with 964 additions and 5 deletions
+281
View File
@@ -0,0 +1,281 @@
// 发布打包:调用各平台构建脚本,把产物收敛成带校验和的发布件。
//
// node build-release.js --platform windows|macos|linux --arch x64|arm64 [--skip-build]
// node build-release.js --verify <目录> # 回验下载下来的各平台发布件
//
// 产物统一落在 dist/release/<platform>-<arch>/,含发布件本体、SHA256SUMS.txt
// 与 release-manifest.json。发布流程只上传这个目录,避免把构建中间物或
// 本地 data/ 误传到 Release。
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
const crypto = require('crypto');
const { spawnSync } = require('child_process');
const ROOT = __dirname;
const DIST = path.join(ROOT, 'dist');
const pkg = require('./package.json');
const PRODUCT = pkg.productName || 'PeopleLib';
const VERSION = pkg.version;
const TARGETS = {
'windows-x64': {
build: ['build-portable.js'],
source: path.join(DIST, `${PRODUCT}-windows-x64`),
asset: `${PRODUCT}-${VERSION}-windows-x64.zip`,
pack: packWindowsZip,
verify: verifyWindowsZip
},
'macos-arm64': {
build: ['build-mac.js'],
source: path.join(DIST, `${PRODUCT}-macos-arm64.dmg`),
asset: `${PRODUCT}-${VERSION}-macos-arm64.dmg`,
pack: copyAsset,
verify: verifyDmg
},
'linux-x64': {
build: ['build-linux.js', '--arch', 'x64'],
source: path.join(DIST, `${PRODUCT}-linux-x64.tar.gz`),
asset: `${PRODUCT}-${VERSION}-linux-x64.tar.gz`,
pack: copyAsset,
verify: verifyLinuxTarball
},
'linux-arm64': {
build: ['build-linux.js', '--arch', 'arm64'],
source: path.join(DIST, `${PRODUCT}-linux-arm64.tar.gz`),
asset: `${PRODUCT}-${VERSION}-linux-arm64.tar.gz`,
pack: copyAsset,
verify: verifyLinuxTarball
}
};
function parseArgs(argv) {
const args = { skipBuild: false };
for (let i = 0; i < argv.length; i += 1) {
const key = argv[i];
if (key === '--skip-build') args.skipBuild = true;
else if (key === '--platform') args.platform = argv[++i];
else if (key === '--arch') args.arch = argv[++i];
else if (key === '--verify') args.verify = argv[++i];
else throw new Error(`无法识别的参数:${key}`);
}
return args;
}
function sha256(file) {
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
}
function rimraf(p) { if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true }); }
function runBuild(script) {
const result = spawnSync(process.execPath, script, { cwd: ROOT, stdio: 'inherit' });
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(`${script[0]} 失败(退出码 ${result.status}`);
}
function walk(dir, prefix = '') {
const out = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...walk(abs, rel));
else out.push({ rel, abs });
}
return out;
}
// 便携版把用户书库放在程序同级 data/,本机构建目录里通常是有内容的。
// 漏掉这条排除就会把整个书库连同笔记打进公开发布件。
function packWindowsZip(source, outFile) {
const files = walk(source).filter((f) => !f.rel.startsWith('data/'));
if (!files.some((f) => f.rel === `${PRODUCT}.exe`)) {
throw new Error(`构建目录缺少 ${PRODUCT}.exe`);
}
const leaked = files.find((f) => /(^|\/)_test\//.test(f.rel));
if (leaked) throw new Error(`构建目录混入测试文件:${leaked.rel}`);
const JSZip = require('jszip');
const zip = new JSZip();
const root = `${PRODUCT}-${VERSION}-windows-x64`;
for (const file of files.sort((a, b) => (a.rel < b.rel ? -1 : 1))) {
zip.file(`${root}/${file.rel}`, fs.readFileSync(file.abs), { date: new Date(0) });
}
return zip
.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', compressionOptions: { level: 6 } })
.then((buffer) => { fs.writeFileSync(outFile, buffer); });
}
async function copyAsset(source, outFile) {
fs.copyFileSync(source, outFile);
}
async function verifyWindowsZip(file) {
const JSZip = require('jszip');
const zip = await JSZip.loadAsync(fs.readFileSync(file));
const names = Object.keys(zip.files).map((n) => n.replace(/^[^/]+\//, ''));
requireEntries(names, [`${PRODUCT}.exe`, 'resources/app/main.js', 'locales/zh-CN.pak']);
forbidEntries(names, [/^data\//, /(^|\/)_test\//]);
}
async function verifyDmg(file) {
const magic = Buffer.alloc(2);
const fd = fs.openSync(file, 'r');
try { fs.readSync(fd, magic, 0, 2, 0); } finally { fs.closeSync(fd); }
// UDZO 镜像是 zlib 压缩块,头两字节为 zlib 魔数;空壳或半截文件在这里就暴露
if (magic[0] !== 0x78) throw new Error('DMG 内容无效');
if (fs.statSync(file).size < 50 * 1024 * 1024) throw new Error('DMG 体积异常偏小');
}
// tar 里的可执行位是 Linux 产物能不能启动的唯一依据,必须解包回读确认。
async function verifyLinuxTarball(file) {
const entries = readTarEntries(zlib.gunzipSync(fs.readFileSync(file)));
const names = entries.map((e) => e.name.replace(/^[^/]+\//, ''));
requireEntries(names, [PRODUCT, `${PRODUCT}.sh`, 'resources/app/main.js', 'locales/zh-CN.pak']);
forbidEntries(names, [/^data\//, /(^|\/)_test\//]);
for (const required of [PRODUCT, `${PRODUCT}.sh`, 'chrome-sandbox']) {
const entry = entries.find((e) => e.name.replace(/^[^/]+\//, '') === required);
if (!entry) throw new Error(`发布件缺少 ${required}`);
if (!(entry.mode & 0o111)) throw new Error(`${required} 缺少可执行位`);
}
}
function requireEntries(names, required) {
for (const name of required) {
if (!names.includes(name)) throw new Error(`发布件缺少 ${name}`);
}
}
function forbidEntries(names, patterns) {
for (const pattern of patterns) {
const hit = names.find((name) => pattern.test(name));
if (hit) throw new Error(`发布件混入不该发布的内容:${hit}`);
}
}
function readTarEntries(buffer) {
const entries = [];
let pending = '';
for (let offset = 0; offset + 512 <= buffer.length;) {
const header = buffer.subarray(offset, offset + 512);
if (header.every((b) => b === 0)) break;
const readField = (start, length) => header
.toString('ascii', start, start + length).replace(/\0.*$/, '').trim();
const size = parseInt(readField(124, 12) || '0', 8);
const typeflag = header.toString('ascii', 156, 157);
const body = offset + 512;
const advance = 512 + Math.ceil(size / 512) * 512;
if (typeflag === 'x') {
const record = /(?:^|\n)\d+ path=([^\n]*)/.exec(buffer.toString('utf8', body, body + size));
pending = record ? record[1] : '';
} else {
entries.push({
name: pending || readField(0, 100),
mode: parseInt(readField(100, 8) || '0', 8),
size
});
pending = '';
}
offset += advance;
}
return entries;
}
async function packRelease(args) {
const arch = args.arch || (args.platform === 'macos' ? 'arm64' : 'x64');
const key = `${args.platform}-${arch}`;
const target = TARGETS[key];
if (!target) {
throw new Error(`不支持的目标:${key}(可选 ${Object.keys(TARGETS).join(' / ')}`);
}
if (!args.skipBuild) runBuild(target.build);
if (!fs.existsSync(target.source)) {
throw new Error(`构建产物不存在:${target.source}`);
}
const outDir = path.join(DIST, 'release', key);
rimraf(outDir);
fs.mkdirSync(outDir, { recursive: true });
const outFile = path.join(outDir, target.asset);
console.log(`打包发布件 ${target.asset} ...`);
await target.pack(target.source, outFile);
console.log('校验发布件内容...');
await target.verify(outFile);
const digest = sha256(outFile);
const size = fs.statSync(outFile).size;
fs.writeFileSync(path.join(outDir, 'SHA256SUMS.txt'), `${digest} ${target.asset}\n`);
fs.writeFileSync(path.join(outDir, 'release-manifest.json'), `${JSON.stringify({
product: PRODUCT,
version: VERSION,
platform: args.platform,
arch,
commit: process.env.GITHUB_SHA || null,
files: [{ name: target.asset, size, sha256: digest }]
}, null, 2)}\n`);
console.log('\n发布件就绪:');
console.log(' 目标:', key);
console.log(' 文件:', outFile, `(${(size / 1024 / 1024).toFixed(1)} MB)`);
console.log(' 校验:', digest);
}
// 发布任务把各平台 artifact 下载到一处,这里逐个回验哈希再汇总,
// 防止把传输中损坏或版本不一致的产物挂到 Release 上。
function verifyDownloads(inputDir) {
const root = path.resolve(inputDir);
if (!fs.existsSync(root)) throw new Error(`目录不存在:${root}`);
const manifests = walk(root)
.filter((f) => path.basename(f.rel) === 'release-manifest.json')
.sort((a, b) => (a.rel < b.rel ? -1 : 1));
if (!manifests.length) throw new Error('没有找到任何 release-manifest.json');
const outDir = path.join(DIST, 'release-upload');
rimraf(outDir);
fs.mkdirSync(outDir, { recursive: true });
const rows = [];
for (const entry of manifests) {
const manifest = JSON.parse(fs.readFileSync(entry.abs, 'utf8'));
if (manifest.version !== VERSION) {
throw new Error(`${entry.rel} 的版本 ${manifest.version} 与 package.json 的 ${VERSION} 不一致`);
}
for (const file of manifest.files) {
const asset = path.join(path.dirname(entry.abs), file.name);
if (!fs.existsSync(asset)) throw new Error(`缺少发布件 ${file.name}`);
const digest = sha256(asset);
if (digest !== file.sha256) throw new Error(`${file.name} 校验和不匹配`);
if (fs.statSync(asset).size !== file.size) throw new Error(`${file.name} 体积不匹配`);
fs.copyFileSync(asset, path.join(outDir, file.name));
rows.push(`${digest} ${file.name}`);
}
}
rows.sort();
fs.writeFileSync(path.join(outDir, 'SHA256SUMS.txt'), `${rows.join('\n')}\n`);
console.log(`已校验 ${rows.length} 个发布件,汇总到 ${outDir}`);
for (const row of rows) console.log(' ' + row);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.verify) verifyDownloads(args.verify);
else if (args.platform) await packRelease(args);
else throw new Error('缺少 --platform 或 --verify 参数');
}
module.exports = { TARGETS, parseArgs, readTarEntries };
if (require.main === module) {
main().catch((error) => {
console.error('发布打包失败:', error && error.message ? error.message : error);
process.exitCode = 1;
});
}