chore(release): 交付物架构断言 + CHANGELOG 归置 v2.0.2

已公开发布的 nodeagent-win-arm64.zip 内装的其实是 x64 的 node.exe
(两包内 node.exe 字节完全相同,PE Machine 均为 0x8664),该包在 ARM Windows 上
能启动、能 listen,却 TLS 握手不完成 —— 被控端实际不可用,而 macOS 侧 100% 复现不出。

- 新增 scripts/lib/zip-pe-arch.mjs:纯内置 zlib 手解 zip、读 PE 头判架构。
  不依赖 unzip / Expand-Archive(Windows runner 上没有 unzip)。
  含 CLI 自检入口,已注入故障实测会红(arm64 包去要 x64 期望 → 退出码 1)。
- release.mjs:生成资产前调用之,不符即中止发版 —— 拦在 tag/push 之前,
  是「交付物本身」的断言,与 pack.mjs 的「流程内部」断言分两层。
- windows-e2e.yml:把原先内联的 PowerShell 校验实现改为复用同一模块,
  消除两套实现各自漂移的可能。
- CHANGELOG:[未发布] 归入 v2.0.2。
This commit is contained in:
jkooe committed 2026-10-11 10:17:30 +08:00
1 parent 87b74fd015
commit f558e3e7df
4 files changed
+155 -18

No files matched your search

+107
View File
@@ -0,0 +1,107 @@
#!/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);
if (name === 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);
}
}
+28
View File
@@ -18,6 +18,7 @@ import { createHash } from 'node:crypto';
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { checkZipArch } from './lib/zip-pe-arch.mjs';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const argv = process.argv.slice(2);
@@ -157,6 +158,33 @@ for (const z of [zipX64, zipArm64]) {
if (!existsSync(z)) fail(`找不到 zip 产物: ${z}`);
}
// ---------- ③.5 交付物断言:直读**最终 zip 内**的 node.exe 架构 ----------
// 2026-10-11 事故复盘:arm64 包里曾装进 x64 的 node.exe,而该包在 ARM Windows 上
// **能启动、能 listen,只是 TLS 握手不完成**(控制端只见 E_TIMEOUT)—— 单测、CI、
// macOS 侧全都抓不到,两个公开包内 node.exe 的 sha256 还完全相同。
// pack.mjs 里已有断言,但那仍是**打包流程内部**的断言;真正该钉死的是**交付物本身**
// 的性质。故此处解出最终 zip 里的 node.exe、读 PE 头,与架构期望比对。
// 实现见 scripts/lib/zip-pe-arch.mjs(纯内置 zlib,不依赖 unzip / Expand-Archive)。
console.log('=== 交付物断言(zip 内 node.exe 架构)===');
for (const [arch, zipPath] of [
['x64', zipX64],
['arm64', zipArm64],
]) {
let r;
try {
r = checkZipArch(zipPath, arch);
} catch (err) {
fail(`无法校验 ${zipPath}:${err.message}`);
}
if (!r.ok) {
fail(
`${zipPath} 内的 node.exe 架构不符:期望 ${arch}(${r.wantName}),实际 ${r.gotName}。\n` +
' 该错误包在 ARM Windows 上能启动/listen,却 TLS 握手不通 —— 严禁发布。',
);
}
console.log(` ✓ nodeagent-win-${arch}.zip → node.exe = ${r.gotName}`);
}
// ---------- ④ 生成资产(校验和 + latest.json)----------
const sha256 = (p) => createHash('sha256').update(readFileSync(p)).digest('hex');
const distDir = join(root, 'release', 'dist');