Files
peoplelib/build-release.js
T
lofyer 7ca023023e
构建与发布 / 单测与集成测试 (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
feat: 增加 Linux 打包与 GitHub 构建发布链
新增 build-linux.js,直接从官方 zip 转写 tar 保住可执行位,
Windows 上也能构建 Linux 包。新增 build-release.js 作为发布件
唯一出口,排除便携版 data/、回读产物校验内容、生成校验和。
GitHub Actions 分测试、四目标打包、标签发布三段。
2026-08-05 19:07:11 +08:00

282 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 发布打包:调用各平台构建脚本,把产物收敛成带校验和的发布件。
//
// 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;
});
}