Files
nodeagent/scripts/release.mjs
T
jkooe 11127b1a89 chore(privacy): 杀软实名与网卡名示例泛化(安全报告第十三轮 P2 两项)
## 甲1 杀软实名泛化(6 行)
`主流杀软 / 杀软 / 360` → 「主流杀毒软件 / 杀软」:
- docs/REMOTE-LIMITS.md:标题 + 正文 + 表格两处(保留"会拦什么"的实操信息,去掉具体厂牌)
- scripts/release.mjs:278:Release 正文模板里的信任区提示
- apps/cli/src/cmd/ops.ts:398:部署失败排查提示

## 甲2 网卡名示例泛化(13 行)
「Ethernet」→ `Ethernet`(示例)/泛化为"中文网卡名"(讲原理时):
- docs/REMOTE-LIMITS.md 网络适配器表格、ROADMAP 该项标记为已完成
- scripts/rescue-network.ps1 参数说明、automation.ts 的 interface 描述
- apps/agent/src/capabilities/network.ts 注释:改为"脚本里的非 ASCII 字符(如中文网卡名)"
  —— **保留"为什么"的教训,去掉具体实况**
- tests/unit/net-scripts.test.mjs:8 处测试数据与正则断言同步改为 Ethernet

## 验证
工作区残留「主流杀软/杀软/Ethernet」= **0 处**;单测 273 全绿;7 包构建通过。
2026-10-10 12:26:41 +08:00

301 lines
13 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
/**
* 发版脚本:一条命令完成「改版本 → 构建 → 打包 → 生成校验和与清单 → 提交打标签 → 发布 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';
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 清单)----------
const pkgFiles = ['package.json'];
for (const f of sh('git', ['ls-files', '*package.json']).split('\n').filter(Boolean)) {
if (f === 'package.json' || f.includes('node_modules')) continue;
pkgFiles.push(f);
}
// Tauri 的版本号不在 package.json 里,另存于 src-tauri/tauri.conf.json,
// 且它才是最终打进 .app / 安装包的版本。漏掉会「包里是旧版本号」,故一并纳入。
const tauriConfFiles = sh('git', ['ls-files', '*src-tauri/tauri.conf.json'])
.split('\n')
.filter(Boolean);
if (dryRun) {
// dry-run 绝不改文件 —— 演练就该是无副作用的
console.log(`(dry-run)将把 ${pkgFiles.length} 个 package.json 的 version 改为 ${version}`);
if (tauriConfFiles.length) {
console.log(`(dry-run)以及 ${tauriConfFiles.length} 个 tauri.conf.json:${tauriConfFiles.join(', ')}`);
}
console.log('(dry-run)将执行:构建 → pack:win → pack:mac → 生成资产 → 提交打标签 → gh release create');
process.exit(0);
}
for (const f of pkgFiles) {
const p = join(root, f);
const j = JSON.parse(readFileSync(p, 'utf8'));
if (!j.version) continue;
j.version = version;
writeFileSync(p, JSON.stringify(j, null, 2) + '\n');
}
for (const f of tauriConfFiles) {
const p = join(root, f);
const j = JSON.parse(readFileSync(p, 'utf8'));
if (!j.version) continue;
j.version = version;
writeFileSync(p, JSON.stringify(j, null, 2) + '\n');
}
console.log(`✓ 版本号已写入 ${pkgFiles.length} 个 package.json + ${tauriConfFiles.length} 个 tauri.conf.json`);
// ---------- ③ 构建 + 打包 ----------
console.log('=== 构建 ===');
sh('pnpm', ['-r', 'build'], { stdio: 'inherit' });
console.log('=== 打包 ===');
sh('pnpm', ['pack:win'], { 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');
const zipPath = join(root, 'release', 'nodeagent-win-x64.zip');
if (!existsSync(agentPath)) fail(`找不到 agent 产物: ${agentPath}`);
if (!existsSync(zipPath)) fail(`找不到 zip 产物: ${zipPath}`);
// ---------- ④ 生成资产(校验和 + 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: zipPath, purpose: '人工安装包(自包含 node.exe,免装 Node.js)' },
];
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 <url> --sha256 <sha256>',
},
zip: { url: `${base}/nodeagent-win-x64.zip`, sha256: assets[1].sha256, bytes: assets[1].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 行),发版时静默推走,第一层闸门完全不响。
// 故此处用「脏文件白名单」:除脚本自己改的 package.json 外,一律不许有别处改动。
// 正常发版应在**干净工作区**上开始,所以这条不该有例外。
const allowedDirty = new Set([...pkgFiles, ...tauriConfFiles]);
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`,
`# 先把 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, '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}`);