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
+159
View File
@@ -0,0 +1,159 @@
name: 构建与发布
on:
workflow_dispatch:
push:
branches:
- main
tags:
- 'v*'
pull_request:
branches:
- main
permissions:
contents: read
concurrency:
group: build-${{ github.ref }}
cancel-in-progress: ${{ github.ref_type != 'tag' }}
env:
# 仓库的 .npmrc 指向 npmmirrorGitHub runner 在境外,走官方源更稳
npm_config_registry: https://registry.npmjs.org/
ELECTRON_MIRROR: https://github.com/electron/electron/releases/download/
jobs:
validate:
name: 单测与集成测试
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- name: 校验发布标签与版本号一致
if: github.ref_type == 'tag'
run: node -e "const p=require('./package.json'); const expected='v'+p.version; if(process.env.GITHUB_REF_NAME!==expected){throw new Error('标签应为 '+expected+',实际 '+process.env.GITHUB_REF_NAME)}"
- name: 安装依赖
run: npm ci
- name: 单元测试
run: npm test
# Electron 集成测试要开真实窗口,无头环境靠 xvfb 提供 X server
- name: Electron 集成测试
run: |
sudo apt-get update
sudo apt-get install -y xvfb libnss3 libatk1.0-0t64 libatk-bridge2.0-0t64 libcups2t64 libgbm1 libasound2t64 libgtk-3-0t64
for suite in startup download cover annotation reader-features library-notes ai-scope; do
echo "::group::$suite"
xvfb-run -a npx electron "src/_test/electron/$suite.integration.js"
echo "::endgroup::"
done
package:
name: 打包 ${{ matrix.platform }} ${{ matrix.arch }}
needs: validate
strategy:
fail-fast: false
matrix:
include:
- platform: windows
arch: x64
runner: windows-2025
- platform: macos
arch: arm64
runner: macos-15
- platform: linux
arch: x64
runner: ubuntu-24.04
- platform: linux
arch: arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- name: 缓存 Electron 运行时
uses: actions/cache@v4
with:
path: node_modules/.cache
key: electron-${{ matrix.platform }}-${{ matrix.arch }}-${{ hashFiles('package.json') }}
- name: 安装依赖
run: npm ci
- name: 构建并校验发布件
run: npm run release -- --platform ${{ matrix.platform }} --arch ${{ matrix.arch }}
- name: 上传发布件
uses: actions/upload-artifact@v4
with:
name: peoplelib-${{ matrix.platform }}-${{ matrix.arch }}
path: dist/release/${{ matrix.platform }}-${{ matrix.arch }}
if-no-files-found: error
compression-level: 0
retention-days: 30
release:
name: 发布 GitHub Release
if: github.event_name == 'push' && github.ref_type == 'tag'
needs: package
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 24
- name: 核对标签指向当前提交
shell: bash
run: |
set -euo pipefail
expected="v$(node -p "require('./package.json').version")"
test "$GITHUB_REF_NAME" = "$expected"
test "$(git rev-parse "refs/tags/$GITHUB_REF_NAME^{commit}")" = "$GITHUB_SHA"
- name: 下载各平台发布件
uses: actions/download-artifact@v4
with:
pattern: peoplelib-*
path: dist/release-downloads
- name: 回验校验和并汇总
run: npm run release -- --verify dist/release-downloads
# 先建草稿再转正式,避免上传中途失败留下一个资产不全的 Release
- name: 创建 Release 并上传
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
tag="$GITHUB_REF_NAME"
if gh release view "$tag" >/dev/null 2>&1; then
gh release edit "$tag" --draft --verify-tag
else
gh release create "$tag" --draft --verify-tag --generate-notes --title "PeopleLib $tag"
fi
gh release upload "$tag" dist/release-upload/* --clobber
gh release edit "$tag" --draft=false
+25 -4
View File
@@ -135,8 +135,10 @@ npx electron src/_test/electron/<name>.integration.js # Electron 集成
## 构建
```bash
npm run build # Windows,输出 dist/PeopleLib-windows-x64/
npm run build:mac # macOS arm64,输出 .app 与 .dmg,只能在 macOS 上跑
npm run build # Windows,输出 dist/PeopleLib-windows-x64/
npm run build:mac # macOS arm64,输出 .app 与 .dmg,只能在 macOS 上跑
npm run build:linux -- --arch x64 # Linux,输出 tar.gz,任意平台可构建
npm run release -- --platform linux --arch x64 # 发布件 + 校验和
```
Windows
@@ -155,12 +157,31 @@ macOS`build-mac.js`,易踩坑):
- 数据目录走 `~/Library/Application Support/PeopleLib`**不要**沿用 Windows 的便携布局:`.app` 在 DMG 里只读,且升级覆盖会删掉用户书库。
- `.icns``npm run icons:icns` 生成,纯 Node 实现(icns 自 10.7 起内嵌 PNG),不依赖 macOS 的 `iconutil`。窗口图标在非 Windows 平台用 PNG`.ico` 只有 Windows 认。
Linux`build-linux.js`):
- 直接从官方 zip 的条目转写进 tar,**不落地中间目录**。可执行位存在 zip 的 external attributes 里,先解到 NTFS 再打包会全部丢掉,产物解压后主程序和 `chrome-sandbox` 都不可执行。因此这个脚本在 Windows 上也能构建。
- tar 头是手写的。路径超过 100 字节要走 PAX 扩展头:ustar 的 `prefix` 只能在斜杠处切分,`undici` 与 vendor 里的深层路径切不出合法组合。
发布件(`build-release.js`):
- 发布件的唯一出口,不要手工压缩构建目录上传。Windows 便携版的 `data/` 就在程序同级,手工压缩会把用户书库连同笔记打进公开发布件;脚本按前缀排除并在打包后回读压缩包确认。
- 打完包一定回读产物再签校验和:Linux 要确认可执行位还在,Windows 要确认没有 `data/``_test`。只算哈希不看内容,等于把「构建脚本改坏了」这类问题一路放到用户手上。
## 持续集成
- `.github/workflows/build.yml``validate`(单测 + 全部集成套件,`xvfb-run` 起 X server)→ `package`(四目标矩阵)→ `release`(仅 `v*` 标签)。
- 仓库 `.npmrc` 指向 npmmirrorGitHub runner 在境外拉不动,工作流用 `npm_config_registry``ELECTRON_MIRROR` 覆盖回官方源。新增构建步骤时别把这两个环境变量漏掉。
- 新增集成套件后要同步加进工作流的套件列表,单测里有断言按 `src/_test/electron/` 的实际文件逐个核对,漏加会直接失败。
- 标签名必须等于 `v` + `package.json``version`,且标签要指向被构建的那个提交。
## 仓库
两个远端,用途不同:
- `github`(公开):**只放 README 与截图**,不推源码、不推 `BUILD.md`。历史与本地无共同祖先,用独立的 orphan/docs 提交推送
- `origin`(私有):完整源码
- `origin`(私有):完整源码,日常开发推这里
- `github`(公开):源码与 CI。GitHub Actions 必须 checkout 到源码才能构建,所以公开仓不再只放 README。历史与本地无共同祖先,推送前先核对两边的差异范围
推公开仓前必须确认:没有本地配置、账号凭据、`data/` 内容或诊断产物混进去。公开仓一旦推出去,删提交也留在别人的克隆里。
其他:
+59 -1
View File
@@ -19,7 +19,25 @@ npm run portable
输出目录固定为 `dist/PeopleLib-windows-x64/`,不随版本号变化,重复构建会保留其中的 `data/` 目录。构建前需退出该目录下正在运行的 `PeopleLib.exe`,否则会因文件占用而中止。
发布时将完整的 `dist/PeopleLib-windows-x64/` 目录压缩,上传到 GitHub Release,并使用 `v2.0.0` 形式的版本标签。应用根据最新 Release 标签判断是否需要更新
发布件不要手工压缩目录上传,用下面的发布打包入口生成,它会排除 `data/` 并附带校验和
### Linuxx64 / arm64
```bash
npm run build:linux -- --arch x64
npm run build:linux -- --arch arm64
```
产出 `dist/PeopleLib-linux-<arch>.tar.gz`,解压后运行其中的 `PeopleLib.sh`
脚本直接把官方 Electron zip 里的条目转写进 tar,不落地中间目录,因此在 Windows 上也能构建出可用的 Linux 包。可执行位存在 zip 的 external attributes 里,先解压到 NTFS 再打包会把这些位全部丢掉,产物解压后 `PeopleLib``chrome-sandbox` 都不可执行。
多数发行版开启了非特权用户命名空间,无需额外配置。内核禁用该特性时(`kernel.unprivileged_userns_clone=0`),需给沙箱补 setuid
```bash
sudo chown root:root PeopleLib-linux-x64/chrome-sandbox
sudo chmod 4755 PeopleLib-linux-x64/chrome-sandbox
```
### macOSApple Silicon
@@ -45,6 +63,46 @@ xattr -dr com.apple.quarantine /Applications/PeopleLib.app
图标 `icons/dist/book-ai-*.icns` 已随仓库提供。源 PNG 变更后用 `npm run icons:icns` 重新生成,该脚本在任意平台都能运行,不依赖 macOS 的 `iconutil`
## 发布打包
`build-release.js` 是发布件的唯一出口,负责调用平台构建脚本、校验产物内容、生成校验和:
```bash
npm run release -- --platform windows --arch x64
npm run release -- --platform macos --arch arm64
npm run release -- --platform linux --arch x64
npm run release -- --platform linux --arch arm64
```
`--skip-build` 可复用已有的构建产物。输出落在 `dist/release/<platform>-<arch>/`,含发布件、`SHA256SUMS.txt``release-manifest.json`
发布前的校验是硬要求,不要跳过直接压缩目录上传:
- Windows 便携版把用户书库放在程序同级 `data/`,本机构建目录里通常有内容,手工压缩会把整个书库连同笔记打进公开发布件。脚本按前缀排除 `data/`,并在打包后回读压缩包确认。
- Linux 产物必须回读 tar 确认 `PeopleLib``PeopleLib.sh``chrome-sandbox` 带可执行位,丢了就是解压后点不开。
- 三个平台都会检查有没有混入 `_test`
汇总多平台产物时用回验模式,它逐个比对哈希、体积与版本,再汇总到 `dist/release-upload/`
```bash
npm run release -- --verify dist/release-downloads
```
## 持续集成
`.github/workflows/build.yml` 在推送 `main`、提交 PR、打 `v*` 标签和手动触发时运行,分三个阶段:
1. `validate``npm ci` 后跑单测与全部 Electron 集成套件。集成测试要开真实窗口,无头 runner 上用 `xvfb-run` 提供 X server。
2. `package`:四个目标并行打包(`windows-2025``macos-15``ubuntu-24.04``ubuntu-24.04-arm`),各自调用 `npm run release`,产物作为 artifact 保留 30 天。
3. `release`:仅在推送 `v*` 标签时执行,回验各平台校验和后创建 GitHub Release 并上传。
两个容易踩的点:
- 仓库 `.npmrc` 指向 npmmirrorGitHub runner 在境外拉不动,工作流用 `npm_config_registry``ELECTRON_MIRROR` 覆盖回官方源。
- 标签名必须与 `package.json``version` 一致(`v2.0.0` 对应 `2.0.0`),且标签要指向被构建的那个提交,两处校验不过直接中止发布。
Release 先建草稿、上传完再转正式,上传中途失败不会在页面上留下一个资产不全的版本。应用根据最新 Release 标签判断是否需要更新。
## 固定构建变体
PeopleLib 采用固定构建变体,不使用远程开关在应用发布后改变功能范围:
+332
View File
@@ -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 图标用 PNG256 供窗口,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;
});
}
+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;
});
}
+2
View File
@@ -14,6 +14,8 @@
"build": "node build-portable.js",
"portable": "node build-portable.js",
"build:mac": "node build-mac.js",
"build:linux": "node build-linux.js",
"release": "node build-release.js",
"icons:icns": "node icons/tools/make-icns.js"
},
"dependencies": {
+106
View File
@@ -470,6 +470,112 @@ test('macOS 打包脚本保留签名前提并产出 arm64 DMG', () => {
assert.match(build, /'--verify', '--deep', '--strict'/);
});
test('Linux 打包直接从官方 zip 转写 tar 并保住可执行位', () => {
const root = path.join(__dirname, '..', '..');
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
const linux = require(path.join(root, 'build-linux.js'));
assert.strictEqual(pkg.scripts['build:linux'], 'node build-linux.js');
// 主程序、沙箱与共享库丢了执行位就起不来;普通资源不该被误判成可执行
for (const name of ['PeopleLib', 'chrome-sandbox', 'chrome_crashpad_handler', 'libffmpeg.so']) {
assert.ok(linux.isExecutableName(name), `${name} 应带可执行位`);
}
for (const name of ['resources/app/main.js', 'locales/zh-CN.pak', 'icudtl.dat']) {
assert.ok(!linux.isExecutableName(name), `${name} 不该带可执行位`);
}
// jszip 给出的权限位优先,缺失时才按文件名兜底
assert.strictEqual(linux.modeOf({ unixPermissions: 0o644 }, 'chrome-sandbox'), 0o644);
assert.strictEqual(linux.modeOf({}, 'chrome-sandbox'), 0o755);
assert.strictEqual(linux.modeOf({ unixPermissions: null }, 'resources/app/main.js'), 0o644);
assert.strictEqual(linux.parseArch(['--arch', 'arm64']), 'arm64');
assert.throws(() => linux.parseArch(['--arch', 'mips']), /不支持的架构/);
});
test('Linux tar 条目对超长路径用 PAX 头,权限位可被标准解析读回', () => {
const root = path.join(__dirname, '..', '..');
const linux = require(path.join(root, 'build-linux.js'));
const { readTarEntries } = require(path.join(root, 'build-release.js'));
// ustar 的 prefix 只能在斜杠处切分,undici 深层路径切不出合法组合
const long = `PeopleLib-linux-x64/resources/app/node_modules/undici/lib/web/${'d'.repeat(60)}/x.js`;
assert.ok(Buffer.byteLength(long) > 100);
const buffer = Buffer.concat([
linux.tarEntry('PeopleLib-linux-x64/PeopleLib', 0o755, Buffer.from('bin')),
linux.tarEntry(long, 0o644, Buffer.from('src')),
Buffer.alloc(1024)
]);
const entries = readTarEntries(buffer);
assert.deepStrictEqual(entries.map((e) => e.name), ['PeopleLib-linux-x64/PeopleLib', long]);
assert.strictEqual(entries[0].mode & 0o111, 0o111);
assert.strictEqual(entries[1].mode & 0o111, 0);
});
test('发布打包排除便携版 data 目录并按校验和回验', () => {
const root = path.join(__dirname, '..', '..');
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
const release = fs.readFileSync(path.join(root, 'build-release.js'), 'utf8');
const { TARGETS } = require(path.join(root, 'build-release.js'));
assert.strictEqual(pkg.scripts.release, 'node build-release.js');
assert.deepStrictEqual(
Object.keys(TARGETS).sort(),
['linux-arm64', 'linux-x64', 'macos-arm64', 'windows-x64']
);
// 发布件名字带版本号,Release 页面上不同版本的资产才不会互相覆盖
for (const [key, target] of Object.entries(TARGETS)) {
assert.ok(target.asset.includes(pkg.version), `${key} 发布件名缺少版本号`);
}
// 便携版书库就在构建目录同级 data/,漏掉这条会把用户数据打进公开发布件
assert.match(release, /!f\.rel\.startsWith\('data\/'\)/);
assert.match(release, /forbidEntries\(names, \[\/\^data\\\/\/, \/\(\^\|\\\/\)_test\\\/\/\]\)/);
// 跨平台产物汇总时逐个比对哈希与版本,防止挂上损坏或版本错配的资产
assert.match(release, /if \(digest !== file\.sha256\) throw new Error/);
assert.match(release, /manifest\.version !== VERSION/);
});
test('CI 工作流覆盖三平台并在标签上核对版本后发布', () => {
const root = path.join(__dirname, '..', '..');
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
const workflow = fs.readFileSync(path.join(root, '.github', 'workflows', 'build.yml'), 'utf8');
for (const target of ['windows-2025', 'macos-15', 'ubuntu-24.04', 'ubuntu-24.04-arm']) {
assert.ok(workflow.includes(target), `构建矩阵缺少 ${target}`);
}
// 集成测试要开真实窗口,无头 runner 上没有 xvfb 会直接崩
assert.match(workflow, /xvfb-run -a npx electron/);
for (const suite of ['startup', 'download', 'cover', 'annotation', 'reader-features', 'library-notes', 'ai-scope']) {
assert.ok(workflow.includes(suite), `CI 缺少集成套件 ${suite}`);
}
for (const suite of fs.readdirSync(path.join(root, 'src', '_test', 'electron'))) {
if (!suite.endsWith('.integration.js')) continue;
assert.ok(
workflow.includes(suite.replace('.integration.js', '')),
`CI 漏跑集成套件 ${suite}`
);
}
// 仓库 .npmrc 指向 npmmirrorGitHub runner 在境外必须切回官方源
assert.match(fs.readFileSync(path.join(root, '.npmrc'), 'utf8'), /registry\.npmmirror\.com/);
assert.match(workflow, /npm_config_registry: https:\/\/registry\.npmjs\.org\//);
assert.match(workflow, /ELECTRON_MIRROR: https:\/\/github\.com\/electron\/electron\/releases\/download\//);
// 打标签才发布,且发布前要求标签名与 package.json 版本一致
assert.match(workflow, /if: github\.event_name == 'push' && github\.ref_type == 'tag'/);
assert.match(workflow, /标签应为/);
assert.ok(workflow.includes(`v'+p.version`));
assert.match(workflow, /--verify dist\/release-downloads/);
// 先建草稿再转正式,上传中途失败不会留下资产不全的 Release
assert.match(workflow, /gh release edit "\$tag" --draft --verify-tag/);
assert.match(workflow, /gh release edit "\$tag" --draft=false/);
assert.ok(pkg.version && /^\d+\.\d+\.\d+$/.test(pkg.version));
});
test('删除阅读资料先等待阅读器排空,后续迟到写入会被拒绝', () => {
assert.match(mainSrc, /await requestReaderPurge\(id\)/);
assert.match(mainSrc, /purgedReaderEntries\.add\(key\)/);