diff --git a/.github/workflows/windows-e2e.yml b/.github/workflows/windows-e2e.yml index 8b527ba..4b542d0 100644 --- a/.github/workflows/windows-e2e.yml +++ b/.github/workflows/windows-e2e.yml @@ -162,28 +162,13 @@ jobs: # 但 TLS 握手不完成**(客户端只见超时)→ 被控端实际不可用,且 macOS 侧 100% 测不出。 # 根因是 pack.mjs 的运行时缓存文件名不含架构。这里直接验**最终产物**的 PE 架构, # 比只验脚本内部逻辑更靠近交付物(缓存串包 / 手工塞错文件都会被这一步拦下)。 + # 校验实现与发版脚本共用(scripts/lib/zip-pe-arch.mjs)—— 避免两套实现各自漂移。 foreach ($arch in @('x64','arm64')) { node scripts/pack.mjs --arch $arch } - $want = @{ 'x64' = [uint16]0x8664; 'arm64' = [uint16]0xaa64 } - Add-Type -AssemblyName System.IO.Compression.FileSystem foreach ($arch in @('x64','arm64')) { - $zip = "release/nodeagent-win-$arch.zip" - if (-not (Test-Path $zip)) { throw "zip not produced: $zip" } - $z = [IO.Compression.ZipFile]::OpenRead((Resolve-Path $zip)) - try { - $e = $z.Entries | Where-Object { $_.Name -eq 'node.exe' } - if (-not $e) { throw "missing node.exe in $zip" } - $s = $e.Open(); $buf = New-Object byte[] 4096 - [void]$s.Read($buf, 0, 4096); $s.Close() - # PE 头:0x3c 处为 PE 签名偏移,+4 为 Machine 字段 - $peOff = [BitConverter]::ToUInt32($buf, 0x3c) - $machine = [BitConverter]::ToUInt16($buf, [int]$peOff + 4) - if ($machine -ne $want[$arch]) { - throw "node.exe arch mismatch in $zip : want 0x$('{0:x}' -f $want[$arch]) got 0x$('{0:x}' -f $machine)" - } - Write-Host "OK - $arch -> node.exe PE machine 0x$('{0:x}' -f $machine)" - } finally { $z.Dispose() } + node scripts/lib/zip-pe-arch.mjs "release/nodeagent-win-$arch.zip" $arch + if ($LASTEXITCODE -ne 0) { throw "node.exe 架构校验失败($arch)—— 严禁发布该包" } } - name: 冒烟 — 被控端监听 shell: pwsh diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c8fc47..429c94f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ ## [未发布] +(暂无) + +--- + +## [v2.0.2] —— 2026-10-11 + +> 一轮真机验证(Parallels 27 + Win11 ARM)抓出的**发布事故**修复批次。 +> 已公开发布的 `nodeagent-win-arm64.zip` 内装的其实是 **x64 的 `node.exe`** —— +> 该包在 ARM Windows 上能启动、能 listen,却 **TLS 握手不完成**(控制端只见 `E_TIMEOUT`), +> 实际不可用。本版重出资产,并把「交付物架构」直接钉进发版脚本与 CI。 + ### 新增 - **控制台自带 Node 运行时,目标机器无需预装 Node**(`pnpm --filter nodeagent-console bundle`) @@ -41,6 +52,12 @@ - **CI 补「双架构包 PE 架构校验」**(`.github/workflows/windows-e2e.yml`)—— 此前 CI 只跑 `pnpm pack:win`(默认 x64)且只校验 x64 包,**arm64 打包零覆盖**, 故该 bug 必然逃过 CI。现两条架构都打,并直接读最终 zip 内 `node.exe` 的 PE 头比对。 +- **发版脚本补「交付物架构断言」**(`scripts/lib/zip-pe-arch.mjs`)—— + `release.mjs` 生成资产前,直接解出**最终 zip 内**的 `node.exe`、读 PE 头比对架构, + 不符即**中止发版**。与 `pack.mjs` 的断言刻意分两层:后者是打包**流程内部**的自洽, + 这里钉的是**交付物本身**的性质。纯内置 `zlib` 实现,不依赖 `unzip` / `Expand-Archive` + (Windows runner 上没有 `unzip`),另留 CLI 自检入口: + `node scripts/lib/zip-pe-arch.mjs `(已注入故障实测会红)。 ### 真机实测(ARM64 Windows 虚拟机) diff --git a/scripts/lib/zip-pe-arch.mjs b/scripts/lib/zip-pe-arch.mjs new file mode 100644 index 0000000..cbcd01f --- /dev/null +++ b/scripts/lib/zip-pe-arch.mjs @@ -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 + * (退出码 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 '); + 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); + } +} diff --git a/scripts/release.mjs b/scripts/release.mjs index 04f483b..ffc118f 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -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');