// 发布打包:调用各平台构建脚本,把产物收敛成带校验和的发布件。 // // node build-release.js --platform windows|macos|linux --arch x64|arm64 [--skip-build] // node build-release.js --verify <目录> # 回验下载下来的各平台发布件 // // 产物统一落在 dist/release/-/,含发布件本体、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; }); }