Files
nodeagent/scripts/lib/zip-pe-arch.mjs
T
jkooe 5e6231493e fix(pack): Windows 打包条目带目录前缀 —— 与 macOS 产物结构不一致
CI 在 f85acad 上红了:`✗ zip 内未找到条目 node.exe: release/nodeagent-win-x64.zip`
(而本地 macOS 全绿 —— 方向恰好相反)。根因是两平台用了不同打包器:

- macOS/Linux: `cd <dir> && zip -r <out> .`       → 条目是裸名 `node.exe`
- Windows    : `Compress-Archive -Path '<dir>\*'` → 条目带前缀 `<dir>/node.exe`

两处一起修:

1. `scripts/lib/zip-pe-arch.mjs`:条目名匹配改用 **basename**,兼容两种打包方式。
   已用构造的带前缀 zip 实测通过,并注入错架构验证守卫仍会红。
2. `scripts/pack.mjs`:Windows 分支改用 .NET `ZipFile::CreateFromDirectory`
   —— 它打包**目录内容**,条目同样是裸名,从根上消除两平台产物结构差异。
   否则 CI 校验的产物与发布产物**不同构**,校验本身的价值打折;
   且「解压后双击 install.cmd」的说明会因多一层目录而失配。

教训:**「本地全绿」≠「Windows 绿」** —— 平台差异只有真 runner 能暴露(第 N 次实证)。
2026-10-11 10:32:41 +08:00

114 lines
4.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 交付物级断言:直读 zip 内的可执行文件、取 PE 头判断目标架构。
*
* 为什么单独成模块(2026-10-11 事故复盘):
* `nodeagent-win-arm64.zip` 里曾装进 x64 的 `node.exe`,而该包在 ARM Windows 上
* **能启动、能 listen,只是 TLS 握手不完成**(控制端只见 E_TIMEOUT)——
* 单测、CI、macOS 侧全都抓不到,两个公开包内 `node.exe` 的 sha256 还完全相同。
* 打包流程内部(pack.mjs)已有断言,但那仍是「中间逻辑」的断言;
* 真正该钉死的是**交付物本身**。故这里只做一件事:解出最终 zip 里的 exe,读 PE 头。
*
* 实现约束:只用 Node 内置能力(`node:zlib` 手解 zip)。
* 不依赖 `unzip` / `Expand-Archive` —— Windows runner 上没有 unzip,
* 而发版与 CI 都要在 Windows 上跑。
*
* 自检用法:
* node scripts/lib/zip-pe-arch.mjs <zip路径> <x64|arm64>
* (退出码 0 = 架构相符;1 = 不符;2 = 读不了文件/不是有效 zip)
*/
import { readFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
import { inflateRawSync } from 'node:zlib';
/** PE `Machine` 字段 → 人类可读名 */
export const PE_MACHINE = { 0x8664: 'x86-64', 0xaa64: 'ARM64', 0x14c: 'i386' };
/** 本项目的架构标识 → 期望的 PE Machine */
export const ARCH_MACHINE = { x64: 0x8664, arm64: 0xaa64 };
/** 从 zip 中取出指定条目的内容(支持 stored / deflate;zip64 不适用本仓产物) */
export function readZipEntry(zipPath, entryName) {
const buf = readFileSync(zipPath);
let eocd = -1;
const minPos = Math.max(0, buf.length - 22 - 65535);
for (let i = buf.length - 22; i >= minPos; i -= 1) {
if (buf.readUInt32LE(i) === 0x06054b50) {
eocd = i;
break;
}
}
if (eocd < 0) throw new Error(`未找到 EOCD(不是有效 zip?): ${zipPath}`);
const count = buf.readUInt16LE(eocd + 10);
let off = buf.readUInt32LE(eocd + 16);
for (let n = 0; n < count; n += 1) {
if (buf.readUInt32LE(off) !== 0x02014b50) throw new Error(`中央目录签名异常: ${zipPath}`);
const method = buf.readUInt16LE(off + 10);
const compSize = buf.readUInt32LE(off + 20);
const nameLen = buf.readUInt16LE(off + 28);
const extraLen = buf.readUInt16LE(off + 30);
const commentLen = buf.readUInt16LE(off + 32);
const localOff = buf.readUInt32LE(off + 42);
const name = buf.toString('utf8', off + 46, off + 46 + nameLen);
// 条目名匹配用 **basename**,而不是完整名 —— 因为两平台的打包器不一样:
// macOS/Linux:`cd <dir> && zip -r <out> .` → 条目是裸名 `node.exe`
// Windows :`Compress-Archive -Path <dir>\*` → 条目带前缀 `<dir>/node.exe`
// 2026-10-11 真例:只比完整名会让「本地(macOS)全绿、CI(Windows)全红」,
// 且方向恰好相反 —— 而 CI 是唯一会跑 Windows 打包路径的地方。
const entryBase = name.replace(/\\/g, '/').split('/').pop();
if (entryBase === entryName) {
const lNameLen = buf.readUInt16LE(localOff + 26);
const lExtraLen = buf.readUInt16LE(localOff + 28);
const start = localOff + 30 + lNameLen + lExtraLen;
const raw = buf.subarray(start, start + compSize);
return method === 0 ? raw : inflateRawSync(raw);
}
off += 46 + nameLen + extraLen + commentLen;
}
throw new Error(`zip 内未找到条目 ${entryName}: ${zipPath}`);
}
/** 读 PE 头的 Machine 字段 */
export function peMachine(buf) {
const peOff = buf.readUInt32LE(0x3c);
if (buf.readUInt32LE(peOff) !== 0x00004550) throw new Error('不是有效的 PE 文件');
return buf.readUInt16LE(peOff + 4);
}
/**
* 校验 zip 内条目的架构。
* @returns {{ok:boolean, got:number, want:number, gotName:string, wantName:string}}
*/
export function checkZipArch(zipPath, arch, entryName = 'node.exe') {
const want = ARCH_MACHINE[arch];
if (!want) throw new Error(`未知架构标识: ${arch}(可选 ${Object.keys(ARCH_MACHINE).join('/')})`);
const got = peMachine(readZipEntry(zipPath, entryName));
return {
ok: got === want,
got,
want,
gotName: PE_MACHINE[got] ?? `0x${got.toString(16)}`,
wantName: PE_MACHINE[want],
};
}
// ---------- CLI 自检:可被人工/CI 直接调用 ----------
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const [, , zipPath, arch] = process.argv;
if (!zipPath || !arch) {
console.error('用法: node scripts/lib/zip-pe-arch.mjs <zip路径> <x64|arm64>');
process.exit(2);
}
try {
const r = checkZipArch(zipPath, arch);
if (r.ok) {
console.log(`✓ ${zipPath} → node.exe = ${r.gotName}(期望 ${arch})`);
process.exit(0);
}
console.error(`✗ ${zipPath} → node.exe = ${r.gotName},但期望 ${arch}(${r.wantName})`);
process.exit(1);
} catch (err) {
console.error(`✗ ${err.message}`);
process.exit(2);
}
}