构建与发布 / 单测与集成测试 (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 分测试、四目标打包、标签发布三段。
282 lines
10 KiB
JavaScript
282 lines
10 KiB
JavaScript
// 发布打包:调用各平台构建脚本,把产物收敛成带校验和的发布件。
|
||
//
|
||
// 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;
|
||
});
|
||
}
|