Files
nodeagent/scripts/ci/check-commit-msg.mjs
T
jkooe 2bf45fdf98 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 全绿
2026-10-17 03:13:18 +08:00

108 lines
3.5 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
/**
* 提交信息规范校验(交付流水线 §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} 条)`);