feat(pipeline): 交付流水线规范落地 —— 门禁 + LKG 回滚锚点

将《代码审计 → 上线 完整交付方案》v1.0 适配到 nodeagent,产出
docs/DELIVERY-PIPELINE.md,并落地其中可执行的部分。

## 为什么要适配而不是照搬
原方案面向 Web/容器服务(有服务端、有镜像、有多环境)。nodeagent 是
跑在用户机器上的跨机接管框架,无服务端、无容器、无多环境。照搬会产生
「跑得很好但守不住任何东西」的假门禁。故每个阶段给三档判定:
适用 / 改造后适用 / 不适用(但必须给替代,不允许只写 N/A)。

关键替换:
- 容器镜像扫描 → 交付物 PE 架构断言(arm64 包误装 x64 node.exe 的真实事故)
- DAST → 协议健壮性测试(本项目对外输入面是 WebSocket RPC,非 HTTP)
- dev/staging/prod → 真机矩阵(Win x64/arm64、macOS、Linux)
- 金丝雀 → 设备分批自更新(先一台试点,latest.json 灰度)
- Prometheus → system.metrics(闭环率/P95/拦截率)+ 发布后复核清单

## 本次落地
1. 覆盖率门禁 scripts/ci/check-coverage.mjs
   - 全量**只观测不阻断**(实测基线 66.9%,照搬 80% 全量不现实)
   - 变更文件行覆盖率 ≥70% 阻断;单测 import 的是 dist/,需 src→dist 映射
   - 无覆盖数据的变更文件不阻断,单列供人工判断(算 0% 会让重构无法合入)
2. 提交信息校验 scripts/ci/check-commit-msg.mjs
   - 项目实际已在用 Conventional Commits,但无强制 → 变成会红的门禁
   - 不引 commitlint+husky:本项目只需类型白名单,几十行即可且 CI 本地都能跑
3. .github/workflows/quality-gate.yml:gitleaks + pnpm audit + typecheck + 覆盖率 + 提交规范
4. CI artifact retention-days 30 → 7(公开可下载,留存越久暴露面越大)
5. LKG(最后已知可用版本)apps/agent/src/lkg.ts
   - 只在**启动成功后**才写,不在更新写完时写(否则等于把未验证版本当已知可用)
   - 暴露为 system.info.lkg,matches=false 表示当前版本尚未被证明能启动
   - 控制端据此暂缓向其余设备放量

## 刻意不做的事:自动回滚
远程设备上无法区分「更新坏了」与「网络抖动」;自动回滚可能形成回滚循环;
被控端自作主张改自己会让控制端失去掌控。故 LKG 只提供确定的回滚目标,
决策权留给控制端。已在文档中写明取舍理由。

## 实测证据
- 覆盖率门禁三条故障注入均 exit=1(阈值100 / 全量99 / lcov缺失),正常 exit=0
- 提交规范反例 3 类均红,Merge 与无 scope 正确豁免,历史 20 条提交一次通过
- LKG 新增 8 条单测(含篡改 hash 必须检出 matches=false)
- 既有契约测试在新增 lkg 字段后立刻报红(SystemInfo 缺声明),补接口后转绿
  —— 这本身是门禁有效的实证
- 全量单测 341 → 349 全绿
This commit is contained in:
jkooe committed 2026-10-17 03:13:18 +08:00
1 parent 590f62e968
commit 2bf45fdf98
11 files changed
+1123 -1

No files matched your search

+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env node
/**
* 提交信息规范校验(交付流水线 §8 / 附录 B)。
*
* ## 背景
* 项目**实际早已在用** Conventional Commits(`git log` 可验证:feat(linux): / fix(console): …),
* 但一直没有强制 —— 靠自觉。本次把它变成会红的门禁。
*
* ## 为什么不引 commitlint + husky
* - husky 依赖 git hooks,CI 上不生效,还得额外装依赖;
* - commitlint 需要配置文件与 `@commitlint/config-conventional` 一整套;
* - 本项目只需「类型白名单 + 有 subject」这两条,几十行即可,且 CI 与本地都能跑。
* 与覆盖率门禁同理:不引冗余依赖。
*
* ## 用法
* node scripts/ci/check-commit-msg.mjs # 校验 HEAD 这一条
* node scripts/ci/check-commit-msg.mjs --base origin/main # 校验该区间所有提交
* echo "feat(x): y" | node scripts/ci/check-commit-msg.mjs --stdin
*/
import { execFileSync } from 'node:child_process';
const TYPES = ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'chore', 'ci'];
/** 允许 Revert / Merge / 版本提交等自动化形态,但类型必须合规。 */
const HEADER_RE = new RegExp(`^(${TYPES.join('|')})(\\([^)]+\\))?!?: .+`);
const ALLOWED_EXEMPT = [
/^Merge (branch|pull request|remote-tracking branch)/i,
/^Revert "/,
];
function parseArgs(argv) {
const out = { base: '', stdin: false };
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === '--base') out.base = argv[++i];
else if (argv[i] === '--stdin') out.stdin = true;
else if (argv[i] === '--help' || argv[i] === '-h') {
console.log(
`用法: node scripts/ci/check-commit-msg.mjs [--base <git-ref>] [--stdin]\n` +
` 类型白名单: ${TYPES.join(', ')}\n` +
` 格式: <type>(<scope>): <subject>`,
);
process.exit(0);
}
}
return out;
}
function readStdin() {
try {
return execFileSync('cat', [], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] });
} catch {
return '';
}
}
function commitsInRange(base) {
const range = base ? `${base}..HEAD` : 'HEAD~1..HEAD';
try {
return execFileSync('git', ['log', '--format=%H%x09%s', range], { encoding: 'utf8' })
.split('\n')
.filter((l) => l.trim())
.map((l) => {
const idx = l.indexOf('\t');
return { sha: l.slice(0, idx), subject: l.slice(idx + 1) };
});
} catch {
return [];
}
}
const args = parseArgs(process.argv.slice(2));
let items = [];
if (args.stdin) {
const msg = readStdin().trim();
if (!msg) {
console.error('\n✗ stdin 为空,没有可校验的提交信息');
process.exit(1);
}
items = [{ sha: '(stdin)', subject: msg.split('\n')[0] }];
} else {
items = commitsInRange(args.base);
if (items.length === 0) {
console.log(`(区间内没有提交,跳过)${args.base ? `base=${args.base}` : ''}`);
process.exit(0);
}
}
const bad = [];
for (const it of items) {
const s = it.subject.trim();
if (ALLOWED_EXEMPT.some((re) => re.test(s))) continue;
if (!HEADER_RE.test(s)) bad.push(it);
}
if (bad.length > 0) {
console.error(`\n✗ ${bad.length} 条提交信息不符合 Conventional Commits:`);
for (const b of bad) console.error(` ${b.sha.slice(0, 8)} ${b.subject}`);
console.error(`\n要求格式: <type>(<scope>): <subject>`);
console.error(`type 取值: ${TYPES.join(', ')}`);
console.error(`示例: fix(console): sidecar 补 CREATE_NO_WINDOW(不再弹常驻黑窗)`);
process.exit(1);
}
console.log(`✓ 提交信息规范校验通过(${items.length} 条)`);