#!/usr/bin/env node /** * 发版脚本:一条命令完成「改版本 → 构建 → 打包 → 生成校验和与清单 → 提交打标签 → 发布 GitHub Release」。 * * 用法: * node scripts/release.mjs 1.0.0 # 指定版本 * node scripts/release.mjs patch|minor|major # 按当前版本自增 * node scripts/release.mjs 1.0.0 --dry-run # 只演练:构建+生成资产,不改 git、不发布 * node scripts/release.mjs 1.0.0 --no-publish # 提交打标签,但不发 GitHub Release * * 为什么要有它(发版纪律:「每次更新,GitHub 上也要同步资产」): * 手工发版要记住「改几处 package.json、打哪个包、算哪些哈希、传什么资产」—— * 必然漏项(尤其是给人用的 zip 与给自更新用的 agent.mjs 是**两个不同的东西**)。 * 本脚本把两者都产出,并把 sha256 写进 latest.json,供被控端 `system.agent.update` 直接使用。 */ import { execFileSync } from 'node:child_process'; 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'; import { writeVersionEverywhere } from './lib/version-write.mjs'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const argv = process.argv.slice(2); const flags = new Set(argv.filter((a) => a.startsWith('--'))); const arg = argv.find((a) => !a.startsWith('--')); const dryRun = flags.has('--dry-run'); const publish = !flags.has('--no-publish'); // ⚠️ stdio:'inherit' 时 execFileSync 返回 null(不捕获输出)→ 必须容错, // 否则 build/pack 这类「继承输出」的调用会直接 TypeError(真机踩过) const sh = (cmd, args, opts = {}) => { const out = execFileSync(cmd, args, { cwd: root, encoding: 'utf8', ...opts }); return out === null || out === undefined ? '' : String(out).trim(); }; function fail(msg) { console.error(`✗ ${msg}`); process.exit(1); } /** * 带重试的网络型 git 操作。 * 本机到 GitHub 的连接会间歇性 SSL_ERROR_SYSCALL / SSL timeout(已实测多次), * 发版卡在这里会留下半成品状态 —— 所以 fetch 与 push 都必须重试。 */ function netRetry(label, fn, tries = 4) { for (let i = 1; i <= tries; i += 1) { try { fn(); return; } catch (err) { if (i === tries) throw err; console.warn(`(${label} 第 ${i} 次失败,2s 后重试…)`); execFileSync('sleep', ['2']); } } } function bump(cur, kind) { const [a, b, c] = cur.split('.').map((n) => Number.parseInt(n, 10)); if ([a, b, c].some((n) => Number.isNaN(n))) fail(`现有版本号无法解析: ${cur}`); if (kind === 'patch') return `${a}.${b}.${c + 1}`; if (kind === 'minor') return `${a}.${b + 1}.0`; if (kind === 'major') return `${a + 1}.0.0`; return null; } const pkgPath = join(root, 'package.json'); const cur = JSON.parse(readFileSync(pkgPath, 'utf8')).version; const version = ['patch', 'minor', 'major'].includes(arg) ? bump(cur, arg) : (arg ?? ''); if (!version) fail('用法: node scripts/release.mjs <1.2.3|patch|minor|major> [--dry-run] [--no-publish]'); if (!/^\d+\.\d+\.\d+$/.test(version)) fail(`版本号格式应为 x.y.z,收到 ${version}`); console.log(`=== nodeagent 发版 v${version}(当前 ${cur})${dryRun ? ' [dry-run]' : ''} ===`); // ---------- ① 前置检查 ---------- if (!dryRun) { // 刻意排除 .github/:其改动需要 workflow 权限才能推送, // 若把它算进「干净检查」会让发版永远卡住(见 docs/VERSIONING.md §6) const dirty = sh('git', ['status', '--porcelain', '--', '.', ':(exclude).github']) .split('\n') .filter(Boolean) .join('\n'); if (dirty) fail(`工作区不干净(已忽略 .github/),请先提交:\n${dirty}`); const branch = sh('git', ['rev-parse', '--abbrev-ref', 'HEAD']); if (branch !== 'main') fail(`当前分支是 ${branch},发版请在 main 上`); netRetry('fetch', () => sh('git', ['fetch', 'origin', 'main'])); if (sh('git', ['rev-parse', 'HEAD']) !== sh('git', ['rev-parse', 'origin/main'])) { fail('本地 main 与远端不一致,请先 pull/push(避免发版后才发现分叉)'); } } const remote = sh('git', ['remote', 'get-url', 'origin']) .replace(/^git@github\.com:/, 'https://github.com/') .replace(/\.git$/, ''); const repoPath = remote.replace(/^https:\/\/github\.com\//, ''); if (!/^[\w.-]+\/[\w.-]+$/.test(repoPath)) fail(`无法从 remote 推断 GitHub 仓库: ${remote}`); // ---------- ② 改版本(单一版本源:根 + 各 workspace + Tauri/Cargo 清单)---------- // 2026-10-13:原先这里手写两段 for 循环,只覆盖 package.json + tauri.conf.json, // 于是 src-tauri/{Cargo.toml,Cargo.lock} 里的版本号在发版后会**滞留**(dry-run 实证:8+1 处, // Cargo 两处漏网),与 tauri.conf.json 悄悄分叉 —— 不报错,属最难发现的漂移。 // 现收敛进 lib/version-write.mjs(纯函数),由 tests/unit/version-write.test.mjs 对**真实文件**断言。 const { changed, skipped } = writeVersionEverywhere(root, version, { dryRun }); if (dryRun) { // dry-run 绝不改文件 —— 演练就该是无副作用的(writeVersionEverywhere 已按 dryRun 只读) const byKind = (k) => changed.filter((c) => c.kind === k).map((c) => c.file); console.log(`(dry-run)将把 ${changed.length} 处版本号改为 ${version}:`); for (const rel of byKind('json')) console.log(` ${rel}`); for (const rel of byKind('cargo-toml')) console.log(` ${rel}`); for (const rel of byKind('cargo-lock')) console.log(` ${rel}`); for (const s of skipped) console.log(` (跳过)${s}`); console.log('(dry-run)将执行:构建 → pack:win → pack:mac → 生成资产 → 提交打标签 → gh release create'); process.exit(0); } for (const s of skipped) console.warn(`(跳过)${s}`); console.log(`✓ 版本号已写入 ${changed.length} 处(package.json + tauri.conf.json + Cargo 清单)`); // ---------- ③ 构建 + 打包 ---------- console.log('=== 构建 ==='); sh('pnpm', ['-r', 'build'], { stdio: 'inherit' }); console.log('=== 打包 ==='); // 2026-10-11:Windows 包分架构 —— x64 包在 **ARM Windows** 上协议层不可用(真机实证: // 能启动/listen 但 TLS 握手不完成),故两个架构都产出。 for (const arch of ['x64', 'arm64']) { sh('node', ['scripts/pack.mjs', '--arch', arch], { stdio: 'inherit' }); } try { sh('pnpm', ['pack:mac'], { stdio: 'inherit' }); } catch { console.warn('(pack:mac 跳过:非 macOS 或未安装依赖)'); } const agentPath = join(root, 'release', 'nodeagent-win-x64', 'agent.mjs'); // agent.mjs 与架构无关(纯 JS bundle),两包内容相同;zip 各含对应架构的 node.exe const zipX64 = join(root, 'release', 'nodeagent-win-x64.zip'); const zipArm64 = join(root, 'release', 'nodeagent-win-arm64.zip'); if (!existsSync(agentPath)) fail(`找不到 agent 产物: ${agentPath}`); 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'); mkdirSync(distDir, { recursive: true }); const assets = [ { name: 'agent.mjs', path: agentPath, purpose: '自更新载荷(被控端 system.agent.update 直接使用)' }, { name: 'nodeagent-win-x64.zip', path: zipX64, purpose: '人工安装包 · x64 Windows(自包含 node.exe)' }, { name: 'nodeagent-win-arm64.zip', path: zipArm64, purpose: '人工安装包 · ARM Windows(虚拟机里的 Win11 ARM 等)' }, ]; for (const a of assets) { copyFileSync(a.path, join(distDir, a.name)); a.bytes = readFileSync(a.path).length; a.sha256 = sha256(a.path); } const commit = sh('git', ['rev-parse', '--short', 'HEAD']); const builtAt = new Date().toISOString(); // 能力数从清单动态取,避免写死后漂移 let capabilityCount = 0; try { const mod = await import(join(root, 'packages', 'protocol', 'dist', 'index.js')); capabilityCount = mod.CAPABILITY_MANIFEST?.length ?? 0; } catch { console.warn('(未能从 dist 读取能力数,latest.json 里记为 0)'); } const base = `https://github.com/${repoPath}/releases/download/v${version}`; const latest = { version, commit, built_at: builtAt, protocol: '1.0', capabilities: capabilityCount, agent: { url: `${base}/agent.mjs`, sha256: assets[0].sha256, bytes: assets[0].bytes, note: '自更新用:nodeagent update --url --sha256 ', }, zip: { url: `${base}/nodeagent-win-x64.zip`, sha256: assets[1].sha256, bytes: assets[1].bytes }, // 2026-10-11 新增(只增字段,旧控制端忽略即可):ARM Windows 用包 zip_arm64: { url: `${base}/nodeagent-win-arm64.zip`, sha256: assets[2].sha256, bytes: assets[2].bytes }, }; writeFileSync(join(distDir, 'latest.json'), JSON.stringify(latest, null, 2) + '\n'); const sums = assets.map((a) => `${a.sha256} ${a.name}`).join('\n') + `\n`; writeFileSync(join(distDir, 'SHA256SUMS'), sums); console.log('=== 资产 ==='); for (const a of assets) { console.log(` ${a.name.padEnd(26)} ${(a.bytes / 1024 / 1024).toFixed(1)}MB sha256=${a.sha256.slice(0, 12)}…`); } console.log(` latest.json / SHA256SUMS`); // ---------- ⑤ 提交 + 标签 + 推送 ---------- // ⚠️ 安全闸(两层):发版会把工作区**整体**提交并推到公开仓库,故提交前必须确认 // 「将要提交的内容」正好等于「想提交的内容」。历史上两种偏差都真发生过, // 且都不报错、只是静默推走不想要的东西,故分两层拦。 // // 第一层:未跟踪文件。2026-10-07 真机教训:工作区里有用户在建工程, // 发版脚本的 add -A 把它们连同版本号变更一起提交并推到了公开仓库。 const untracked = sh('git', ['ls-files', '--others', '--exclude-standard']) .split('\n') .filter((f) => f && !f.startsWith('.github/') && f !== '.github'); if (untracked.length > 0) { fail( '发现未跟踪文件,发版已中止(避免把无关文件带入提交):\n' + untracked.map((f) => ` ${f}`).join('\n') + '\n请确认:这些文件是否应纳入版本库?若要,先 git add 并单独提交;' + '若不要,加入 .gitignore 后重试。', ); } // 第二层:已修改的**跟踪**文件。只拦未跟踪文件是不够的 —— 下面的 `git add -A` // 同样会提交已修改的跟踪文件,而它们不在第一层的视野里。 // 真例:`pnpm install` 把某个应用的依赖写进了跟踪中的根 pnpm-lock.yaml // (+1401 行),发版时静默推走,第一层闸门完全不响。 // 故此处用「脏文件白名单」:除脚本自己改的版本号文件外,一律不许有别处改动。 // 正常发版应在**干净工作区**上开始,所以这条不该有例外。 // ⚠️ 白名单**必须**取自 writeVersionEverywhere 的实际改写清单(changed), // 而不是重新手写一份文件名列表 —— 手写的那份一旦与写入逻辑漂移, // 要么把该放行的拦下来(发版卡死),要么把不该放行的放过去(静默推走改动)。 const allowedDirty = new Set(changed.map((c) => c.file)); const dirty = sh('git', ['diff', '--name-only', 'HEAD']) .split('\n') .filter(Boolean); const unexpectedDirty = dirty.filter((f) => !allowedDirty.has(f)); if (unexpectedDirty.length > 0) { fail( '发现**版本号变更之外**的已修改文件,发版已中止(避免误提交):\n' + unexpectedDirty.map((f) => ` ${f}`).join('\n') + '\n这些文件本不该在发版时变动。请 `git checkout -- <文件>` 还原,或先单独提交后重试。\n' + '若其中有 pnpm-lock.yaml,说明依赖图发生了计划外的变化 ——\n' + '应查清是哪个 workspace 成员引入的,而不是让 lock 带着它发版。', ); } sh('git', ['add', '-A', '--', '.', ':(exclude).github']); sh('git', ['commit', '-m', `chore(release): v${version}`]); sh('git', ['tag', '-a', `v${version}`, '-m', `nodeagent v${version}`]); netRetry('push main', () => sh('git', ['push', 'origin', 'main'])); netRetry('push tag', () => sh('git', ['push', 'origin', `v${version}`])); console.log(`✓ 已提交并推送标签 v${version}`); // ---------- ⑥ 发布 GitHub Release ---------- if (!publish) { console.log('(--no-publish:已跳过 GitHub Release)'); process.exit(0); } const notes = join(distDir, 'RELEASE_NOTES.md'); writeFileSync( notes, [ `# nodeagent v${version}`, ``, `- 构建: \`${commit}\` @ ${builtAt}`, `- 能力: ${latest.capabilities} 项`, ``, `## 资产`, ...assets.map((a) => `- \`${a.name}\` — ${a.purpose}`), `- \`latest.json\` — 版本清单(含 sha256),供被控端自更新校验`, `- \`SHA256SUMS\` — 校验和`, ``, `## 更新被控端(拉取式,只需它能上网)`, '```bash', `nodeagent --node <设备名> update \\`, ` --url ${latest.agent.url} \\`, ` --sha256 ${latest.agent.sha256}`, '```', ``, `## 人工安装(首次)`, `\`\`\`powershell`, `Expand-Archive nodeagent-win-x64.zip C:\\nodeagent -Force`, `# ARM Windows(如虚拟机里的 Win11 ARM)请改用 nodeagent-win-arm64.zip —— x64 包在其上 TLS 握手不通`, `# 先把 C:\\nodeagent 加入杀软信任区(主流杀软)`, `powershell -ExecutionPolicy Bypass -File C:\\nodeagent\\install.ps1 -NodeId win -AllowInput`, '```', ].join('\n') + '\n', ); sh('gh', [ 'release', 'create', `v${version}`, '--title', `nodeagent v${version}`, '--notes-file', notes, join(distDir, 'agent.mjs'), join(distDir, 'nodeagent-win-x64.zip'), join(distDir, 'nodeagent-win-arm64.zip'), join(distDir, 'latest.json'), join(distDir, 'SHA256SUMS'), ]); console.log(`✓ GitHub Release v${version} 已发布`); console.log(''); console.log('被控端更新命令:'); console.log(` nodeagent --node <设备名> update --url ${latest.agent.url} --sha256 ${latest.agent.sha256}`);