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
+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\)/);