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
构建与发布 / 单测与集成测试 (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:
+332
@@ -0,0 +1,332 @@
|
||||
// Linux 打包:产出 dist/PeopleLib-linux-<arch>.tar.gz。
|
||||
// 直接把官方 zip 里的条目转写进 tar,不落地中间目录:Linux 的可执行位存在
|
||||
// zip 的 external attributes 里,先解到 NTFS 再打包会把这些位全部丢掉,
|
||||
// 产物解压后 PeopleLib 与 chrome-sandbox 都不可执行。因此本脚本在任意平台
|
||||
// 都能构建 x64 与 arm64 包。
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const zlib = require('zlib');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const ROOT = __dirname;
|
||||
const pkg = require('./package.json');
|
||||
const PRODUCT = pkg.productName || 'PeopleLib';
|
||||
const SUPPORTED_ARCHES = ['x64', 'arm64'];
|
||||
|
||||
// 固定时间戳让同一份源码重复构建得到逐字节一致的产物,便于用哈希核对发布件。
|
||||
const MTIME = Number(process.env.SOURCE_DATE_EPOCH) || 1735689600;
|
||||
|
||||
const KEEP_LOCALES = new Set(['zh-CN.pak', 'en-US.pak']);
|
||||
// 无扩展名的可执行文件与 .so 之外,这几个也必须带执行位
|
||||
const FORCE_EXECUTABLE = new Set(['chrome-sandbox', 'chrome_crashpad_handler']);
|
||||
|
||||
function parseArch(argv) {
|
||||
const index = argv.indexOf('--arch');
|
||||
const value = index >= 0 ? argv[index + 1] : process.arch;
|
||||
if (!SUPPORTED_ARCHES.includes(value)) {
|
||||
throw new Error(`不支持的架构:${value || '未指定'}(可选 ${SUPPORTED_ARCHES.join(' / ')})`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function run(cmd, args) {
|
||||
const result = spawnSync(cmd, args, { stdio: 'inherit' });
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) throw new Error(`${cmd} 失败(退出码 ${result.status})`);
|
||||
}
|
||||
|
||||
// 官方 zip 只在需要时下载一次,缓存在 node_modules/.cache 下按版本和架构分目录
|
||||
function ensureRuntimeZip(arch) {
|
||||
const version = String(pkg.devDependencies && pkg.devDependencies.electron || '').replace(/^v/, '');
|
||||
if (!version) throw new Error('package.json 未锁定 electron 版本');
|
||||
const cacheDir = path.join(ROOT, 'node_modules', '.cache', `electron-linux-${arch}`, version);
|
||||
const zip = path.join(cacheDir, 'electron.zip');
|
||||
if (fs.existsSync(zip) && fs.statSync(zip).size > 0) return zip;
|
||||
|
||||
const mirror = process.env.ELECTRON_MIRROR
|
||||
|| process.env.npm_config_electron_mirror
|
||||
|| 'https://npmmirror.com/mirrors/electron/';
|
||||
const url = `${mirror.replace(/\/?$/, '/')}v${version}/electron-v${version}-linux-${arch}.zip`;
|
||||
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const partial = `${zip}.download`;
|
||||
fs.rmSync(partial, { force: true });
|
||||
console.log(`下载 Electron ${version} (linux-${arch})...`);
|
||||
run('curl', ['-fSL', '--retry', '3', '-o', partial, url]);
|
||||
fs.renameSync(partial, zip);
|
||||
return zip;
|
||||
}
|
||||
|
||||
function isExecutableName(name) {
|
||||
const base = path.posix.basename(name);
|
||||
if (FORCE_EXECUTABLE.has(base)) return true;
|
||||
if (/\.so(\.\d+)*$/.test(base)) return true;
|
||||
return !base.includes('.');
|
||||
}
|
||||
|
||||
// jszip 会把 external attributes 的高 16 位解析成 unixPermissions;
|
||||
// 个别条目缺失时按文件名兜底,宁可多给执行位也不能让主程序起不来。
|
||||
function modeOf(entry, name) {
|
||||
const raw = entry.unixPermissions;
|
||||
const parsed = typeof raw === 'number' ? raw & 0o7777 : 0;
|
||||
if (parsed) return parsed;
|
||||
return isExecutableName(name) ? 0o755 : 0o644;
|
||||
}
|
||||
|
||||
async function readRuntimeEntries(zipPath, arch) {
|
||||
const JSZip = require('jszip');
|
||||
const zip = await JSZip.loadAsync(fs.readFileSync(zipPath));
|
||||
const entries = [];
|
||||
let sawElectron = false;
|
||||
|
||||
for (const name of Object.keys(zip.files)) {
|
||||
const entry = zip.files[name];
|
||||
if (entry.dir) continue;
|
||||
if (name.startsWith('locales/') && !KEEP_LOCALES.has(path.posix.basename(name))) continue;
|
||||
if (name === 'resources/default_app.asar') continue;
|
||||
|
||||
// Electron Linux 包不该有符号链接;真出现了要显式失败,静默展开成副本
|
||||
// 会让产物体积翻倍且行为不可预期。
|
||||
const unixMode = typeof entry.unixPermissions === 'number' ? entry.unixPermissions : 0;
|
||||
if ((unixMode & 0o170000) === 0o120000) {
|
||||
throw new Error(`运行时包含未预期的符号链接:${name}`);
|
||||
}
|
||||
|
||||
const target = name === 'electron' ? PRODUCT : name;
|
||||
if (name === 'electron') sawElectron = true;
|
||||
entries.push({
|
||||
name: target,
|
||||
mode: name === 'electron' ? 0o755 : modeOf(entry, name),
|
||||
data: await entry.async('nodebuffer')
|
||||
});
|
||||
}
|
||||
|
||||
if (!sawElectron) throw new Error(`Electron 运行时缺少主可执行文件(linux-${arch})`);
|
||||
return entries;
|
||||
}
|
||||
|
||||
function skipDevFiles(e) {
|
||||
const name = e.name.toLowerCase();
|
||||
if (e.isDirectory()) return false;
|
||||
if (/\.(d\.ts|d\.ts\.map|ts|tsx|map|flow)$/.test(name)) return true;
|
||||
if (/^(readme|changelog|history|license|licence|notice|authors|contributing|security)/.test(name)) return true;
|
||||
if (/\.(md|markdown)$/.test(name)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function collectDir(src, prefix, skip) {
|
||||
const out = [];
|
||||
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
if (skip && skip(e)) continue;
|
||||
const from = path.join(src, e.name);
|
||||
const to = path.posix.join(prefix, e.name);
|
||||
if (e.isDirectory()) out.push(...collectDir(from, to, skip));
|
||||
else out.push({ name: to, mode: 0o644, data: fs.readFileSync(from) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectFile(src, name, mode = 0o644) {
|
||||
return { name, mode, data: fs.readFileSync(src) };
|
||||
}
|
||||
|
||||
function appEntries() {
|
||||
const app = 'resources/app';
|
||||
const entries = [
|
||||
collectFile(path.join(ROOT, 'main.js'), `${app}/main.js`),
|
||||
collectFile(path.join(ROOT, 'preload.js'), `${app}/preload.js`),
|
||||
...collectDir(path.join(ROOT, 'src'), `${app}/src`, (e) => e.name.startsWith('_test'))
|
||||
];
|
||||
|
||||
for (const name of ['book-ai-dark.ico', 'book-ai-light.ico']) {
|
||||
entries.push(collectFile(path.join(ROOT, 'icons', 'dist', name), `${app}/icons/dist/${name}`));
|
||||
}
|
||||
for (const theme of ['dark', 'light']) {
|
||||
// Linux 的 BrowserWindow 图标用 PNG:256 供窗口,32 供渲染层复用
|
||||
for (const size of [32, 256]) {
|
||||
entries.push(collectFile(
|
||||
path.join(ROOT, 'icons', 'dist', theme, `icon-${size}.png`),
|
||||
`${app}/icons/dist/${theme}/icon-${size}.png`
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
const undici = path.join(ROOT, 'node_modules', 'undici');
|
||||
for (const name of ['package.json', 'index.js', 'index-fetch.js', 'LICENSE']) {
|
||||
const file = path.join(undici, name);
|
||||
if (fs.existsSync(file)) {
|
||||
entries.push(collectFile(file, `${app}/node_modules/undici/${name}`));
|
||||
}
|
||||
}
|
||||
entries.push(...collectDir(
|
||||
path.join(undici, 'lib'), `${app}/node_modules/undici/lib`, skipDevFiles
|
||||
));
|
||||
|
||||
const foliate = path.join(ROOT, 'node_modules', 'foliate-js');
|
||||
for (const name of ['package.json', 'LICENSE', 'mobi.js']) {
|
||||
entries.push(collectFile(path.join(foliate, name), `${app}/node_modules/foliate-js/${name}`));
|
||||
}
|
||||
entries.push(collectFile(
|
||||
path.join(foliate, 'vendor', 'fflate.js'),
|
||||
`${app}/node_modules/foliate-js/vendor/fflate.js`
|
||||
));
|
||||
|
||||
entries.push({
|
||||
name: `${app}/package.json`,
|
||||
mode: 0o644,
|
||||
data: Buffer.from(`${JSON.stringify({
|
||||
name: pkg.name, version: pkg.version, description: pkg.description,
|
||||
productName: PRODUCT,
|
||||
main: 'main.js', author: pkg.author, license: pkg.license,
|
||||
dependencies: {
|
||||
undici: pkg.dependencies.undici,
|
||||
'foliate-js': pkg.dependencies['foliate-js']
|
||||
}
|
||||
}, null, 2)}\n`, 'utf8')
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
// 桌面集成留给用户自行安装,这里只给出可直接使用的启动脚本与 .desktop 模板。
|
||||
// 不加 --no-sandbox:多数发行版开启了非特权用户命名空间,Electron 能正常起沙箱;
|
||||
// 内核禁用该特性时才需要按 BUILD.md 给 chrome-sandbox 补 setuid。
|
||||
function launcherEntries() {
|
||||
const launcher = `#!/bin/sh
|
||||
set -e
|
||||
HERE="$(dirname "$(readlink -f "$0")")"
|
||||
exec "$HERE/${PRODUCT}" "$@"
|
||||
`;
|
||||
const desktop = `[Desktop Entry]
|
||||
Type=Application
|
||||
Name=${PRODUCT}
|
||||
Comment=${pkg.description}
|
||||
Exec=${PRODUCT}.sh %U
|
||||
Icon=${PRODUCT.toLowerCase()}
|
||||
Terminal=false
|
||||
Categories=Office;Viewer;
|
||||
`;
|
||||
return [
|
||||
{ name: `${PRODUCT}.sh`, mode: 0o755, data: Buffer.from(launcher, 'utf8') },
|
||||
{ name: `${PRODUCT}.desktop`, mode: 0o644, data: Buffer.from(desktop, 'utf8') },
|
||||
collectFile(path.join(ROOT, 'icons', 'dist', 'dark', 'icon-256.png'), `${PRODUCT.toLowerCase()}.png`)
|
||||
];
|
||||
}
|
||||
|
||||
function octal(value, length) {
|
||||
// tar 的数字字段是定长八进制串,末位留给 NUL
|
||||
return Buffer.from(value.toString(8).padStart(length - 1, '0') + '\0', 'ascii');
|
||||
}
|
||||
|
||||
function tarHeader({ name, mode, size, typeflag }) {
|
||||
const header = Buffer.alloc(512);
|
||||
header.write(name, 0, 100, 'utf8');
|
||||
octal(mode & 0o7777, 8).copy(header, 100);
|
||||
octal(0, 8).copy(header, 108); // uid
|
||||
octal(0, 8).copy(header, 116); // gid
|
||||
octal(size, 12).copy(header, 124);
|
||||
octal(MTIME, 12).copy(header, 136);
|
||||
header.write(' ', 148, 8, 'ascii'); // 计算校验和时该字段视为空格
|
||||
header.write(typeflag, 156, 1, 'ascii');
|
||||
header.write('ustar\0', 257, 6, 'ascii');
|
||||
header.write('00', 263, 2, 'ascii');
|
||||
header.write('root', 265, 32, 'ascii');
|
||||
header.write('root', 297, 32, 'ascii');
|
||||
|
||||
let sum = 0;
|
||||
for (const byte of header) sum += byte;
|
||||
header.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 8, 'ascii');
|
||||
return header;
|
||||
}
|
||||
|
||||
function padding(size) {
|
||||
const remainder = size % 512;
|
||||
return remainder ? Buffer.alloc(512 - remainder) : Buffer.alloc(0);
|
||||
}
|
||||
|
||||
// 路径超过 100 字节时用 PAX 扩展头承载完整路径。ustar 的 prefix 字段只能在
|
||||
// 斜杠处切分,undici 与 vendor 里的深层路径切不出合法组合,必须走 PAX。
|
||||
function paxRecords(fullName) {
|
||||
const record = (key, value) => {
|
||||
const body = ` ${key}=${value}\n`;
|
||||
let length = Buffer.byteLength(body) + 1;
|
||||
while (Buffer.byteLength(`${length}${body}`) !== length) length += 1;
|
||||
return Buffer.from(`${length}${body}`, 'utf8');
|
||||
};
|
||||
return record('path', fullName);
|
||||
}
|
||||
|
||||
function tarEntry(fullName, mode, data, typeflag = '0') {
|
||||
const chunks = [];
|
||||
const nameBytes = Buffer.byteLength(fullName, 'utf8');
|
||||
|
||||
if (nameBytes > 100) {
|
||||
const records = paxRecords(fullName);
|
||||
chunks.push(tarHeader({
|
||||
name: `PaxHeader/${path.posix.basename(fullName).slice(0, 80)}`,
|
||||
mode: 0o644,
|
||||
size: records.length,
|
||||
typeflag: 'x'
|
||||
}));
|
||||
chunks.push(records, padding(records.length));
|
||||
}
|
||||
|
||||
chunks.push(tarHeader({
|
||||
// 超长路径已由 PAX 头给出,这里的截断名只是给不支持 PAX 的工具看的回退值
|
||||
name: nameBytes > 100 ? fullName.slice(-100) : fullName,
|
||||
mode,
|
||||
size: data.length,
|
||||
typeflag
|
||||
}));
|
||||
chunks.push(data, padding(data.length));
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
function writeTarGz(entries, root, outFile) {
|
||||
const seen = new Set();
|
||||
const chunks = [];
|
||||
const sorted = [...entries].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
||||
|
||||
for (const entry of sorted) {
|
||||
const full = path.posix.join(root, entry.name);
|
||||
if (seen.has(full)) throw new Error(`打包条目重复:${full}`);
|
||||
seen.add(full);
|
||||
chunks.push(tarEntry(full, entry.mode, entry.data));
|
||||
}
|
||||
chunks.push(Buffer.alloc(1024)); // tar 以两个空块收尾
|
||||
|
||||
const gz = zlib.gzipSync(Buffer.concat(chunks), { level: 9, mtime: 0 });
|
||||
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
||||
fs.writeFileSync(outFile, gz);
|
||||
return gz.length;
|
||||
}
|
||||
|
||||
async function build() {
|
||||
const arch = parseArch(process.argv.slice(2));
|
||||
const target = `${PRODUCT}-linux-${arch}`;
|
||||
const outFile = path.join(ROOT, 'dist', `${target}.tar.gz`);
|
||||
|
||||
const zip = ensureRuntimeZip(arch);
|
||||
console.log('读取 Electron 运行时(保留可执行位)...');
|
||||
const runtime = await readRuntimeEntries(zip, arch);
|
||||
|
||||
console.log('组装 app 源码...');
|
||||
const entries = [...runtime, ...appEntries(), ...launcherEntries()];
|
||||
|
||||
console.log('生成 tar.gz...');
|
||||
const size = writeTarGz(entries, target, outFile);
|
||||
|
||||
console.log('\n构建完成:');
|
||||
console.log(' 架构:', arch);
|
||||
console.log(' 产物:', outFile, `(${(size / 1024 / 1024).toFixed(1)} MB)`);
|
||||
console.log(' 解压后运行:', `./${target}/${PRODUCT}.sh`);
|
||||
}
|
||||
|
||||
module.exports = { parseArch, isExecutableName, modeOf, tarEntry, writeTarGz };
|
||||
|
||||
if (require.main === module) {
|
||||
build().catch((error) => {
|
||||
console.error('构建失败:', error && error.message ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user