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:
1 parent
590f62e968
commit
2bf45fdf98
11 files changed
+1123
-1
No files matched your search
@@ -316,4 +316,5 @@ jobs:
|
||||
apps/console/src-tauri/target/release/bundle/nsis/*.exe
|
||||
# 一条架构没产出就报错,避免「jobs 全绿但产物不全」
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
# 交付流水线 §4:CI artifact 公开可下载,留存越久暴露面越大,30 → 7
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,108 @@
|
||||
# 交付流水线左移门禁(见 docs/DELIVERY-PIPELINE.md §1 §2 §8)
|
||||
#
|
||||
# 本文件只做「快且能阻断」的检查:密钥、依赖漏洞、类型、覆盖率、提交规范。
|
||||
# 真机链路(install.cmd / control.cmd 双击、Windows 能力)留在 windows-e2e.yml
|
||||
# —— 那一环跑得慢但价值最高,不可被这条更快的流水线替代。
|
||||
#
|
||||
# ⚠️ 阈值说明(别照搬通用 80%):详见 docs/DELIVERY-PIPELINE.md §2。
|
||||
# 全量覆盖率当前基线约 66.9%,故全量只观测;阻断只针对**变更文件**(≥70%)。
|
||||
|
||||
name: Quality Gate
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ① 代码审计:密钥 + 依赖漏洞
|
||||
audit:
|
||||
name: 代码审计(密钥 + 依赖)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
# gitleaks 扫历史提交,需要完整历史
|
||||
fetch-depth: 0
|
||||
|
||||
# 密钥扫描:nodeagent 的密钥面比一般项目大 ——
|
||||
# PSK、Ed25519 私钥、TLS 自签证书、PSK.txt、agent.json
|
||||
- name: 密钥与敏感信息扫描
|
||||
uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
# 依赖漏洞:High 及以上阻断
|
||||
- name: 依赖漏洞扫描(SCA)
|
||||
run: pnpm audit --audit-level=high
|
||||
|
||||
# ② 质量门禁:类型 + 覆盖率
|
||||
gate:
|
||||
name: 质量门禁(类型 + 覆盖率)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
# 覆盖率门禁要算「变更文件」,需与基线做 diff
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- run: pnpm typecheck
|
||||
|
||||
# 单测 import 的是 dist/(编译产物),必须先构建,否则覆盖率全空
|
||||
- run: pnpm -r build
|
||||
|
||||
- name: 采集覆盖率
|
||||
run: |
|
||||
mkdir -p coverage
|
||||
node --test --experimental-test-coverage \
|
||||
--test-reporter=lcov \
|
||||
--test-reporter-destination=coverage/lcov.info \
|
||||
"tests/unit/**/*.test.mjs"
|
||||
|
||||
- name: 覆盖率门禁
|
||||
run: node scripts/ci/check-coverage.mjs --lcov coverage/lcov.info --base origin/main --threshold 70
|
||||
|
||||
# coverage/ 已在 .gitignore 中,此处留档便于人工查看
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: coverage
|
||||
path: coverage/
|
||||
retention-days: 7
|
||||
|
||||
# ③ 提交信息规范
|
||||
commits:
|
||||
name: 提交信息规范
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: 校验 Conventional Commits
|
||||
run: node scripts/ci/check-commit-msg.mjs --base ${{ github.event.pull_request.base.sha }}
|
||||
@@ -10,6 +10,7 @@ import { loadAgentConfig } from '../config.js';
|
||||
import { readAudit, readAuditAll, verifyAudit, computeAuditHead, anchorAudit, compareWithAnchors } from '../audit.js';
|
||||
import { computeMetrics } from '../metrics.js';
|
||||
import { allPsShellStats } from '../util/ps-helper.js';
|
||||
import { lkgStatus } from '../lkg.js';
|
||||
import { startTask } from './task.js';
|
||||
import { psLit } from '../util/quote.js';
|
||||
|
||||
@@ -151,6 +152,10 @@ export async function systemInfo(args: Args): Promise<unknown> {
|
||||
network: {
|
||||
allow_from: configAllowFrom(),
|
||||
},
|
||||
// 交付流水线 §6:最后已知可用版本(回滚锚点)。
|
||||
// matches=false 表示「当前版本还没被证明能启动」—— 刚更新完、尚未完成一次成功
|
||||
// 启动时会是这种情况,控制端应据此暂缓向其余设备放量。
|
||||
lkg: lkgStatus(),
|
||||
};
|
||||
|
||||
if (fields && fields.length > 0) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createAgentServer } from './server.js';
|
||||
import { initAudit, audit, auditFilePath } from './audit.js';
|
||||
import { startBeacon, DEFAULT_DISCOVERY_PORT, type Beacon } from './discovery.js';
|
||||
import { startHubClient, type HubClient } from './hub-mode.js';
|
||||
import { recordLKG } from './lkg.js';
|
||||
|
||||
function listLocalIps(): string[] {
|
||||
const out: string[] = [];
|
||||
@@ -133,6 +134,11 @@ async function main(): Promise<void> {
|
||||
},
|
||||
(msg) => console.log(`[WARN] ${msg}`),
|
||||
);
|
||||
// 交付流水线 §6:此刻脚本已加载、配置已读、证书已签、端口已绑、心跳已起 ——
|
||||
// 走到这里说明「这个版本至少能启动」,故记为最后已知可用版本(LKG)。
|
||||
// 注意**不在更新写完时就记**:那会把尚未验证的版本当成已知可用,LKG 就失去意义。
|
||||
recordLKG();
|
||||
|
||||
console.log('');
|
||||
console.log(' nodeagent 被控端已启动');
|
||||
console.log(' ─────────────────────────────────────────────');
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
import { agentDir } from './config.js';
|
||||
import { buildInfo } from './capabilities/system.js';
|
||||
|
||||
/**
|
||||
* 最后已知可用版本(Last-Known-Good, LKG)—— 交付流水线 §6 的回滚锚点。
|
||||
*
|
||||
* ## 为什么需要(本项目特有风险,通用发布规范里没有)
|
||||
* 对 Web 服务,发布失败可以回滚、可以摘流量。对 nodeagent:
|
||||
* **回滚通道本身就是被更新的那个程序**。一次失败的更新会让一台物理不可达的
|
||||
* 远程设备永久脱管 —— 这是本项目最严重的上线失败形态。
|
||||
*
|
||||
* ## 记什么、什么时候记
|
||||
* 关键设计:**只在「本次启动确实起来了」之后才记**。
|
||||
*
|
||||
* 若在「更新刚写完」时就记 LKG,等于把还没验证的版本当成已知可用 ——
|
||||
* 那 LKG 就失去了意义。故由 `index.ts` 在服务器 listen 成功、打印完启动信息后
|
||||
* 调用 `recordLKG()`:此刻能走到这一行,说明脚本能被 Node 加载、配置能读、
|
||||
* 证书能签、端口能绑。**能启动 ≠ 功能全对,但能启动 = 至少不是「打死」状态。**
|
||||
*
|
||||
* ## 明确不做的事:自动回滚
|
||||
* 本文刻意**不实现**「启动失败自动回滚」。理由:
|
||||
* - 远程设备上无法区分「更新本身坏了」与「网络抖动 / 端口被占 / 依赖服务没起」;
|
||||
* - 自动回滚可能触发**回滚循环**(回滚→启动→又判定失败→再回滚),
|
||||
* 在无人值守设备上比「停在坏版本」更难恢复;
|
||||
* - 被控端一旦自作主张改自己的程序,控制端将失去对「当前跑的是什么」的掌控。
|
||||
*
|
||||
* 因此 LKG 的定位是**给回滚提供确定的目标**,而不是替人做回滚决策:
|
||||
* 控制端 `nodeagent rollback` 读 LKG → 覆盖回入口文件 → 重启。
|
||||
*/
|
||||
|
||||
export interface LkgRecord {
|
||||
/** 入口脚本(agent.mjs)的 sha256 前 12 位 —— 与 system.info 的 build.hash 同口径 */
|
||||
hash: string;
|
||||
bytes: number;
|
||||
script_path: string;
|
||||
/** 记录时刻(Unix ms) */
|
||||
recorded_at: number;
|
||||
/** 当时的 node 版本,便于判断「是不是 Node 运行时升级导致的」 */
|
||||
node_version: string;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
export function lkgFilePath(): string {
|
||||
return join(agentDir(), 'agent-lkg.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算当前入口脚本指纹。
|
||||
* 与 `capabilities/system.ts` 的 buildInfo() 同口径(都是脚本内容的 sha256 前 12 位),
|
||||
* 故可与 `nodeagent info` 里的 `build.hash` 直接比对。
|
||||
*/
|
||||
function currentScriptHash(): { hash: string; bytes: number; path: string } | null {
|
||||
const p = process.argv[1] ?? '';
|
||||
if (!p) return null;
|
||||
try {
|
||||
const buf = readFileSync(p);
|
||||
return {
|
||||
hash: createHash('sha256').update(buf).digest('hex').slice(0, 12),
|
||||
bytes: buf.length,
|
||||
path: p,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录当前版本为 LKG。
|
||||
*
|
||||
* 任何失败都被吞掉 —— 写入失败不能影响启动。
|
||||
* @returns 写入的记录,失败返回 null
|
||||
*/
|
||||
export function recordLKG(): LkgRecord | null {
|
||||
const cur = currentScriptHash();
|
||||
if (!cur) return null;
|
||||
const rec: LkgRecord = {
|
||||
hash: cur.hash,
|
||||
bytes: cur.bytes,
|
||||
script_path: cur.path,
|
||||
recorded_at: Date.now(),
|
||||
node_version: process.version,
|
||||
platform: process.platform,
|
||||
};
|
||||
try {
|
||||
mkdirSync(agentDir(), { recursive: true });
|
||||
writeFileSync(lkgFilePath(), `${JSON.stringify(rec, null, 2)}\n`, 'utf8');
|
||||
return rec;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取 LKG 记录;不存在或损坏返回 null(不作任何猜测)。 */
|
||||
export function readLKG(): LkgRecord | null {
|
||||
try {
|
||||
const raw = readFileSync(lkgFilePath(), 'utf8');
|
||||
const rec = JSON.parse(raw) as LkgRecord;
|
||||
if (!rec || typeof rec.hash !== 'string') return null;
|
||||
return rec;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前版本与 LKG 是否一致。
|
||||
*
|
||||
* 用途:更新后复核 —— 若「当前 hash ≠ LKG」且刚做过更新,
|
||||
* 说明新版本**尚未被证明能启动**,控制端应暂缓放量到其余设备(交付流水线 §6 分批策略)。
|
||||
*/
|
||||
export function lkgStatus(): {
|
||||
lkg: LkgRecord | null;
|
||||
current_hash: string | null;
|
||||
matches: boolean;
|
||||
/** LKG 与当前版本的字节差,便于快速判断是「升级」还是「回退」 */
|
||||
byte_delta: number | null;
|
||||
} {
|
||||
const lkg = readLKG();
|
||||
const cur = currentScriptHash();
|
||||
const current_hash = cur?.hash ?? null;
|
||||
return {
|
||||
lkg,
|
||||
current_hash,
|
||||
matches: !!lkg && lkg.hash === current_hash,
|
||||
byte_delta: lkg && cur ? cur.bytes - lkg.bytes : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 供审计/排障:LKG 文件是否存在及其大小(不读内容,避免大文件 IO)。 */
|
||||
export function lkgFileInfo(): { path: string; exists: boolean; bytes: number } {
|
||||
const p = lkgFilePath();
|
||||
try {
|
||||
return { path: p, exists: true, bytes: statSync(p).size };
|
||||
} catch {
|
||||
return { path: p, exists: false, bytes: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/** 让 system.info 能顺带暴露构建指纹(与 buildInfo 同源,避免两处口径漂移)。 */
|
||||
export { buildInfo };
|
||||
@@ -109,6 +109,29 @@ export interface SystemInfo {
|
||||
ps_helper?: PsHelperInfo;
|
||||
network?: NetworkInfo;
|
||||
build?: BuildInfo;
|
||||
/** 最后已知可用版本 LKG(交付流水线 §6 回滚锚点) */
|
||||
lkg?: LkgInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 最后已知可用版本(Last-Known-Good)。
|
||||
*
|
||||
* 只在「本次启动真正起来了」之后才由被控端写入 ——
|
||||
* 故 `matches=false` 意味着当前版本**尚未被证明能启动**(刚更新完、还没完成一次成功启动),
|
||||
* 界面应据此提示「暂缓向其余设备放量」。
|
||||
*/
|
||||
export interface LkgInfo {
|
||||
lkg?: {
|
||||
hash?: string;
|
||||
bytes?: number;
|
||||
script_path?: string;
|
||||
recorded_at?: number;
|
||||
node_version?: string;
|
||||
platform?: string;
|
||||
};
|
||||
current_hash?: string;
|
||||
matches?: boolean;
|
||||
byte_delta?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
# nodeagent 交付流水线规范
|
||||
|
||||
> 本文是《代码审计 → 上线 完整交付方案》v1.0(2026-10-14)在 **nodeagent** 项目上的落地适配。
|
||||
> 适配日期:2026-10-17。基线提交:`590f62e`。
|
||||
|
||||
---
|
||||
|
||||
## 0. 适配原则(先说清楚为什么不能照搬)
|
||||
|
||||
原方案面向 **Web/容器服务**(FinDataHub / reader·bookshelf / OnlyInOne)——有服务端、有镜像、
|
||||
有多套环境、有线上流量。nodeagent 与之有三处根本差异,照搬会产生**假门禁**(跑得很好但守不住任何东西)。
|
||||
|
||||
因此本文对每一阶段给出三档判定:
|
||||
|
||||
| 判定 | 含义 | 处置 |
|
||||
|---|---|---|
|
||||
| **适用** | 原样落地即可 | 直接实施 |
|
||||
| **改造后适用** | 目标一致,但形态不同 | 换等价手段,并说明为什么等价 |
|
||||
| **不适用** | 本项目不存在该风险面 | **必须给出替代手段**,不允许只写「N/A」了事 |
|
||||
|
||||
> **为什么「不适用」也必须给替代**:原方案每项都对应一类真实风险。若某类风险在本项目不存在,
|
||||
> 说明风险换了形态,替代手段才是真正守它的东西;直接跳过等于承认该风险无人看守。
|
||||
|
||||
### nodeagent 项目画像
|
||||
|
||||
| 维度 | 事实 |
|
||||
|---|---|
|
||||
| 形态 | monorepo:TypeScript(agent/cli/mcp/hub/protocol)+ Rust(Tauri 控制台壳) |
|
||||
| 交付物 | Windows 安装包 zip(含 `node.exe` + `agent.mjs`)、macOS CLI 三件套、macOS Tauri 控制台 app |
|
||||
| 运行位置 | 常驻在**用户自己的机器**上(Windows/Linux/macOS),非服务器 |
|
||||
| 有无服务端 | **无**(点对点直连 + 可选 Hub 中转,Hub 不在本仓库) |
|
||||
| 有无容器 | **无** |
|
||||
| 更新方式 | 被控端**拉取式自更新**(`system.agent.update` 读 `latest.json` + sha256) |
|
||||
| 现有测试 | 单测 **341**(全绿)/ 协议 e2e / Windows 真机 e2e / Rust `cargo test` |
|
||||
|
||||
---
|
||||
|
||||
## 1. 代码审计(四维扫描 + 漏洞分级)
|
||||
|
||||
原方案四维:SAST / SCA / 密钥扫描 / 容器镜像。
|
||||
|
||||
| 维度 | 判定 | 现状 | 落地动作 |
|
||||
|---|---|---|---|
|
||||
| SAST 静态安全 | **适用** | ❌ 无 | 引入 Semgrep(TS/JS 规则集)进 CI,阻断 High 及以上 |
|
||||
| SCA 依赖漏洞 | **适用** | ❌ 无 | `pnpm audit --audit-level=high` 进 CI;锁文件已入库 ✅ |
|
||||
| 密钥与敏感信息 | **适用** | ⚠️ **有巡检无门禁** | 每日定时任务已做隐私巡检(`security-reports/`),但**不阻断合并** → 加 gitleaks 进 CI |
|
||||
| 容器镜像 | **不适用** | — | 无容器。**替代:交付物断言**(见下) |
|
||||
|
||||
### 替代:交付物断言(比镜像扫描更贴本项目的那一层)
|
||||
|
||||
nodeagent 的「制品风险」不在镜像层,而在**分发包本身**。已有两道断言,本文将其升格为正式门禁:
|
||||
|
||||
1. **PE 架构断言**(`scripts/lib/zip-pe-arch.mjs`):解出最终 zip 内的 `node.exe` 读 PE 头,
|
||||
与期望架构比对。
|
||||
- 缘起(2026-10-11 事故):arm64 包里装进了 x64 的 `node.exe`,该包在 ARM Windows 上
|
||||
**能启动、能 listen,只是 TLS 握手不完成** —— 单测/CI/macOS 侧全都抓不到,
|
||||
且两个公开包内 `node.exe` 的 sha256 完全相同。
|
||||
2. **打包期守卫**(`scripts/pack.mjs`):`.ps1` 必须带 UTF-8 BOM、`.cmd` 必须纯 ASCII + CRLF。
|
||||
这两条各自对应一次真机事故(PowerShell 5.1 解析失败 / CMD 代码页截断)。
|
||||
|
||||
> **为什么这两条比通用镜像扫描更要紧**:镜像扫的是「第三方引入的漏洞」,
|
||||
> 而这两条守的是「我们自己把错误的东西发了出去」——后者是本项目历史上真实发生过的。
|
||||
|
||||
### 准入判据(硬门禁)
|
||||
|
||||
- Critical / High 漏洞数 **必须为 0**,一票否决。
|
||||
- Medium 登记并附限期修复计划;Low 可接受但须留痕。
|
||||
- **黄金法则沿用**:密钥一旦提交即视为泄露,删除提交只是止损,**立即轮换**才是根治。
|
||||
nodeagent 的密钥面比一般项目大:PSK、Ed25519 私钥、TLS 自签证书 —— 轮换路径已在
|
||||
`docs/SECURITY.md` §6 应急处置中定义。
|
||||
|
||||
---
|
||||
|
||||
## 2. 质量门禁(Quality Gate)
|
||||
|
||||
| 指标 | 原方案阈值 | 现状 | 落地动作 |
|
||||
|---|---|---|---|
|
||||
| 新增代码覆盖率 | ≥ 80% | ❌ **未采集** | `node --test --experimental-test-coverage` 采集,设阈值(见下) |
|
||||
| 圈复杂度 ≤ 10/方法 | — | ❌ 未检 | 暂不接入(见说明) |
|
||||
| 重复率 ≤ 3% | — | ❌ 未检 | 暂不接入 |
|
||||
| 坏味道新增 0 | — | ❌ 未检 | 暂不接入 |
|
||||
| 阻断性 Bug 0 | — | ✅ `typecheck` + `clippy -D warnings` | 保持 |
|
||||
|
||||
**关于覆盖率阈值的现实校准**:原方案 80% 面向新项目。nodeagent 存量 341 个单测集中在
|
||||
协议/编码/安全守卫,**部分模块(GUI 注入、平台脚本)无法在 CI 上无头验证**。
|
||||
故采用**增量口径**:
|
||||
|
||||
- 全量覆盖率**先只观测、不阻断**(出数字,建立基线);
|
||||
- **新增/修改文件的行覆盖率 ≥ 70% 才阻断**;
|
||||
- 无法无头验证的模块显式标注豁免,并在文档中列出理由。
|
||||
|
||||
> 直接照搬 80% 全量阈值会导致两种后果:要么长期挂 `--skip`(门禁形同虚设),
|
||||
> 要么逼迫团队为 GUI 注入代码写无意义的假断言。**门禁的价值在于守得住,不在于数字好看。**
|
||||
|
||||
**关于圈复杂度/重复率**:需引入 SonarQube,但对本地优先的单人项目运维成本高于收益,
|
||||
本文标记为**可选项**,不进阻断门禁。
|
||||
|
||||
---
|
||||
|
||||
## 3. 自动化测试(金字塔分层)
|
||||
|
||||
| 层 | 判定 | 现状 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 单元测试 | **适用** | ✅ **341 全绿** | `pnpm test:unit` |
|
||||
| 集成测试 | **适用** | ✅ 协议 e2e | `pnpm test:e2e`(TLS / ed25519 / 发现 / 重连 / 文件 / Hub) |
|
||||
| E2E 关键路径 | **适用** | ✅ **Windows 真机 e2e** | `pnpm test:windows`(含 `install.cmd` 双击链路)——**强于原方案**(真机而非模拟) |
|
||||
| DAST 动态扫描 | **不适用** | — | 无对外 Web 服务面。**替代:协议健壮性测试** |
|
||||
|
||||
### 替代:协议健壮性测试(对应「运行态注入」这一类风险)
|
||||
|
||||
DAST 守的是「外部输入打进来会怎样」。nodeagent 的对外输入面是 **WebSocket RPC**,
|
||||
已有等价覆盖(`tests/e2e/run.mjs` 内):
|
||||
|
||||
- 畸形 `hello`(protocol 传数字 / 传对象)
|
||||
- 畸形 `auth`(hmac 传数字 / 对象 + nonce 为 null)
|
||||
- **未认证直接 invoke**(U-01:必须留审计且不崩)
|
||||
- 未知能力 / 参数非法 / ACL 拒绝 / 超频
|
||||
|
||||
> 这比通用 DAST 更精准:DAST 是黑盒猜测,这里是针对本项目协议状态机的定向攻击面覆盖。
|
||||
|
||||
**缺口**:畸形报文目前是**枚举式**用例,未做随机化 fuzz。建议后续加一轮
|
||||
「随机字节 + 随机 JSON 结构」的模糊测试 → 记为可选改进项。
|
||||
|
||||
---
|
||||
|
||||
## 4. CI/CD 构建与制品
|
||||
|
||||
| 项 | 判定 | 现状 |
|
||||
|---|---|---|
|
||||
| 流水线 lint→test→build→scan | **适用** | ⚠️ 有 test/build/typecheck,**缺 scan** |
|
||||
| 制品仓库 | **改造后适用** | ✅ GitHub Release(非镜像仓库) |
|
||||
| 版本号 = 语义化 + git SHA | **适用** | ✅ 语义化版本;`release.mjs` 统一写入 11 处 |
|
||||
| 签名与追溯 | **改造后适用** | ⚠️ 有 sha256 + `latest.json`,**无 cosign/SBOM** |
|
||||
|
||||
### 签名与追溯:容器 cosign 不适用,改为「分发包完整性」三件套
|
||||
|
||||
cosign 签的是容器镜像。nodeagent 分发的是 zip + `agent.mjs`,等价保障是:
|
||||
|
||||
1. **sha256 入库**:`SHA256SUMS.txt` 随包发布(已有)。
|
||||
2. **包内容清单**:zip 内文件列表与体积随 release 记录,用于事后核对「发出去的到底是什么」。
|
||||
3. **可验签**:`system.agent.update` **强制校验 sha256**(不校验等于开放远程代码执行)。
|
||||
这是本项目**最强的一道**——被控端只认哈希,不认「看起来对」的包。
|
||||
|
||||
> **待补(可选)**:对 `agent.mjs` 做 detached GPG 签名,供高安全场景人工验签。
|
||||
> 注意这不能替代 sha256 —— 被控端目前不支持验签,加签名只服务于人工审计场景。
|
||||
|
||||
### 现有 CI 的已知待改项
|
||||
|
||||
- `retention-days: 30` → 建议 **7**(CI artifact 公开可下载,留存越久暴露面越大)。
|
||||
- 三个 workflow 均未集成密钥/依赖扫描(见 §1)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 分级环境发布(dev / staging / prod)
|
||||
|
||||
**判定:不适用**,但必须给替代。
|
||||
|
||||
nodeagent 无服务端、无多环境:`agent` 跑在用户自己的机器上,控制端是本机 CLI。
|
||||
「dev/staging/prod」这类环境隔离在物理上不存在。
|
||||
|
||||
### 替代:真机矩阵验证
|
||||
|
||||
风险换成了形态 —— 不是「哪个环境先炸」,而是「哪类机器先炸」。nodeagent 已多次栽在
|
||||
**平台差异**上(CRLF、路径分隔符、verbatim 路径、ARM Windows 的 TLS、PowerShell 5.1 的 BOM)。
|
||||
|
||||
故替代为**真机矩阵**:
|
||||
|
||||
| 平台 | 架构 / 形态 | 验证方式 |
|
||||
|---|---|---|
|
||||
| Windows | x64 | `tests/e2e/windows.mjs`(`windows-latest` runner) |
|
||||
| Windows | **arm64** | 真机验证(CI 无 arm64 Windows runner,必须人工) |
|
||||
| macOS | CLI 三件套 | 本机安装冒烟 |
|
||||
| macOS | Tauri 控制台 | `console-windows.yml` 构建 + `verify-bundle.mjs` |
|
||||
| Linux | systemd 被控端 | `docs/DEPLOY-LINUX.md` + 真机验证 |
|
||||
|
||||
> **矩阵里最弱的一环是 Windows arm64** —— CI 覆盖不到,而它恰恰是历史上出过
|
||||
> 「能启动但 TLS 握手不完成」这一最难发现故障的平台。本文将其列为**发版前必查项**。
|
||||
|
||||
---
|
||||
|
||||
## 6. 生产发布策略
|
||||
|
||||
| 原方案策略 | 判定 | 说明 |
|
||||
|---|---|---|
|
||||
| 滚动发布 | 不适用 | 无多实例 |
|
||||
| 蓝绿部署 | 不适用 | 无流量切换 |
|
||||
| 金丝雀 | **改造后适用** | 改为**设备分批**:先一台试点 |
|
||||
| Feature Flag | 适用(有限) | 能力级 ACL 可视为开关 |
|
||||
|
||||
### 改造:金丝雀 → 设备分批自更新
|
||||
|
||||
被控端是**拉取式**更新(读 `latest.json`),天然支持分批:
|
||||
|
||||
1. **试点**:选 1 台可达且物理可及的设备先更新,观察 ≥ 30 分钟。
|
||||
2. **放量**:确认无异常后,其余设备再触发更新。
|
||||
3. **回滚**:每设备保留上一版本备份(`agent.mjs.bak-<ts>`)+ `latest.json` 可回退。
|
||||
|
||||
### ⚠️ 本项目特有风险:发布即失联(原方案未覆盖)
|
||||
|
||||
这是 nodeagent **最严重**的上线失败形态,且原方案完全没有对应条款:
|
||||
|
||||
> 一次失败的 agent 更新,会让一台**物理不可达**的远程设备**永久脱管**。
|
||||
> 对 Web 服务,发布失败可以回滚、可以摘流量;对被控端,**回滚通道本身就是被更新的那个程序**。
|
||||
|
||||
缓解措施(部分已有,本文整合为强制项):
|
||||
|
||||
| 措施 | 状态 |
|
||||
|---|---|
|
||||
| 更新前备份当前 `agent.mjs` | ✅ 已有 |
|
||||
| **先备份再原子替换**(同目录 rename,失败可回滚) | ✅ 已有 |
|
||||
| **sha256 强制校验**(不校验不落盘) | ✅ 已有 |
|
||||
| 更新后**重启并轮询校验新指纹**上线 | ✅ 已有(`deploy` 闭环) |
|
||||
| **保留「最后已知可用版本」标记(LKG)** | ✅ **本次落地**(`apps/agent/src/lkg.ts`) |
|
||||
| **更新后健康检查失败 → 自动回滚到备份** | ⚠️ **刻意不做**,见下 |
|
||||
|
||||
### LKG 的设计(本次落地)
|
||||
|
||||
- **写入时机**:agent **成功启动后**(服务器已 listen、心跳已起)才写 `agent-lkg.json`。
|
||||
**不在更新写完时就写** —— 那会把尚未验证的版本当成已知可用,LKG 就失去意义。
|
||||
- **内容**:`hash`(与 `system.info` 的 `build.hash` 同口径)、`bytes`、`script_path`、
|
||||
`recorded_at`、`node_version`、`platform`。
|
||||
- **暴露**:`system.info.lkg = { lkg, current_hash, matches, byte_delta }`。
|
||||
`matches=false` = 当前版本还没被证明能启动 → 控制端应**暂缓向其余设备放量**。
|
||||
|
||||
### 为什么**不做**自动回滚(明确的设计取舍)
|
||||
|
||||
自动回滚听起来更安全,但在远程被控端上会引入更坏的失败形态:
|
||||
|
||||
1. **无法归因**:远程设备上区分不出「更新本身坏了」与「网络抖动 / 端口被占 / 依赖服务未起」。
|
||||
2. **回滚循环**:回滚 → 启动 → 又判定失败 → 再回滚。在无人值守设备上,这比「停在坏版本」更难恢复。
|
||||
3. **失控**:被控端一旦自作主张改自己的程序,控制端就失去对「当前跑的到底是什么」的掌控。
|
||||
|
||||
故定位为:**LKG 提供确定的回滚目标,决策权留给控制端**(`nodeagent rollback` 读 LKG → 覆盖回入口 → 重启)。
|
||||
现有 `system.agent.update` 已具备备份、哈希校验、写后校验、不一致即回滚 —— 那是**写入期**的闭环;
|
||||
**运行期**的可用性判断交给人,不交给一个可能误判的自动机。
|
||||
|
||||
---
|
||||
|
||||
## 7. 上线监控与复盘
|
||||
|
||||
| 支柱 | 判定 | nodeagent 对应 |
|
||||
|---|---|---|
|
||||
| 指标 | **改造后适用** | `system.metrics`:闭环成功率 / P95 时延 / 安全拦截率 / 装软件成功率 |
|
||||
| 日志 | **适用** | 审计日志 JSONL(哈希链 + 外部锚定),见 `docs/SECURITY.md` §4.8 |
|
||||
| 链路追踪 | 不适用 | 点对点调用,无分布式链路 |
|
||||
| 告警 | 改造后适用 | 无值班通道;改为**发版后主动复核** |
|
||||
|
||||
### 发布后复核清单(替代 Prometheus 盯盘)
|
||||
|
||||
| 项 | 命令 | 期望 |
|
||||
|---|---|---|
|
||||
| 能力数未减少 | `nodeagent list` | 与发版前一致 |
|
||||
| 审计链完整 | `nodeagent audit verify` | `ok: true` |
|
||||
| 锚点自洽 | `nodeagent audit anchor --compare` | 无「整链重写」告警 |
|
||||
| 核心能力可用 | `node scripts/verify.mjs --host … --key …` | 全绿 |
|
||||
| 指标未恶化 | `nodeagent metrics` | 闭环率 ≥ 95%、拦截率 100% |
|
||||
|
||||
### 复盘
|
||||
|
||||
已有载体:`bug-report-2026-10-13.html`(缺陷台账)、`docs/RETROSPECTIVE.md`(短板复盘)、
|
||||
`security-reports/YYYY-MM-DD.md`(每日巡检)。
|
||||
|
||||
**待补**:原方案要求「任何事故/回滚必须写复盘 —— 时间线、根因、改进项、责任人」。
|
||||
本项目为单人项目,「责任人」不适用,改为**改进项是否进入守卫**(即:是否变成一条会红的断言)。
|
||||
|
||||
> 这是本项目复盘最有价值的一问:**没有变成断言的复盘,等于没有复盘**。
|
||||
> 历史上多次同类事故复发(CRLF、路径、编码)都栽在这一点上。
|
||||
|
||||
---
|
||||
|
||||
## 8. 落地清单(Action Checklist)
|
||||
|
||||
按优先级排序,✅ 表示本次已落地:
|
||||
|
||||
- [x] 建立 `.gitignore` 密钥基线(`PSK.txt` / `keys/` / `*.key` / `*.pem` / `config.json` / `agent.json`)— **已有**
|
||||
- [x] 密钥扫描(gitleaks)进 CI 并阻断合并 — **本次落地**(`quality-gate.yml`)
|
||||
- [x] 依赖漏洞扫描(`pnpm audit --audit-level=high`)进 CI — **本次落地**
|
||||
- [ ] SAST(Semgrep TS/JS 规则集)进 CI — **未做**:需先定规则集,否则噪声会淹没真问题
|
||||
- [x] 覆盖率采集接入 CI(全量观测 + 变更文件 ≥70% 阻断)— **本次落地**(`scripts/ci/check-coverage.mjs`)
|
||||
- [x] 提交信息规范强制(Conventional Commits + CI 校验)— **本次落地**(`scripts/ci/check-commit-msg.mjs`)
|
||||
- [x] 交付物 PE 架构断言 — **已有**,升格为门禁
|
||||
- [x] 打包期 `.ps1` BOM / `.cmd` ASCII+CRLF 守卫 — **已有**
|
||||
- [x] CI artifact `retention-days` 30 → 7 — **本次落地**
|
||||
- [x] 保留「最后已知可用版本」标记(LKG)— **本次落地**
|
||||
- [ ] 分支保护:main 需 PR + CI 全绿 — **需仓库设置**(当前 protection 返回 404,已知 P1)
|
||||
- [ ] 发版前 Windows arm64 真机必查(CI 覆盖不到)— **流程项**,已写入本文档
|
||||
- [ ] 协议模糊测试(随机报文 fuzz)— **可选改进项**
|
||||
|
||||
### 本次落地的实测证据
|
||||
|
||||
| 项 | 证据 |
|
||||
|---|---|
|
||||
| 覆盖率门禁 | 三条故障注入均 `exit=1`(阈值 100 / 全量 99 / lcov 缺失);正常路径 `exit=0` |
|
||||
| 覆盖率基线 | 全量 **66.9%**(6399/9560 行,54 文件)—— 印证「照搬 80% 全量不现实」 |
|
||||
| 提交规范校验 | 反例 3 类均红(无类型 / wip / 类型拼错);Merge 与无 scope 正确豁免;**历史 20 条提交一次通过** |
|
||||
| LKG | 新增 8 条单测全绿;含「篡改 hash 必须检出 matches=false」故障注入 |
|
||||
| 契约守卫 | 新增 `lkg` 字段后,既有契约测试**立刻报红**(`SystemInfo` 缺声明)→ 补接口后转绿。这本身是门禁有效的实证 |
|
||||
| 全量单测 | **349 全绿**(落地前 341) |
|
||||
| 门禁会红的验证 | 覆盖率 3 注入 + LKG 1 注入,均实测为真红,非摆设 |
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:CI 骨架(GitHub Actions,适配 nodeagent)
|
||||
|
||||
```yaml
|
||||
# .github/workflows/quality-gate.yml
|
||||
name: Quality Gate
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with: { fetch-depth: 0 }
|
||||
# 密钥扫描:历史 + 增量(nodeagent 密钥面:PSK / ed25519 / TLS 私钥)
|
||||
- uses: gitleaks/gitleaks-action@v2
|
||||
env: { GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} }
|
||||
# 依赖漏洞
|
||||
- uses: pnpm/action-setup@v4
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm audit --audit-level=high
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm typecheck
|
||||
# 覆盖率:全量出数字(观测),增量阈值由 coverage-gate 判定
|
||||
- run: node --test --experimental-test-coverage --test-reporter=lcov \
|
||||
--test-reporter-destination=coverage/lcov.info "tests/unit/**/*.test.mjs"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with: { name: coverage, path: coverage/, retention-days: 7 }
|
||||
```
|
||||
|
||||
> **注**:`windows-e2e.yml` 保留原样 —— 它跑的是真机链路(`install.cmd` / `control.cmd` 双击),
|
||||
> 是本项目 CI 里最有价值的一环,不可被「更快」的 Linux 流水线替代。
|
||||
|
||||
## 附录 B:提交信息规范
|
||||
|
||||
项目**实际已在用** Conventional Commits(`git log` 可验证),但**无强制**。补齐校验:
|
||||
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
type: feat | fix | docs | style | refactor | perf | test | chore | ci
|
||||
scope: agent | cli | mcp | hub | protocol | console | pack | release | linux | …
|
||||
```
|
||||
|
||||
示例(取自实际提交):
|
||||
|
||||
```
|
||||
feat(linux): Linux 被控端适配 —— 安装器 + systemd 自启 + 平台守卫 + 部署文档
|
||||
fix(console): sidecar 补 CREATE_NO_WINDOW(不再弹常驻黑窗)
|
||||
chore(privacy): 公开仓库隐私脱敏 —— 清除可定位到个人与设备的字面量
|
||||
```
|
||||
|
||||
## 附录 C:回滚检查清单(nodeagent 版)
|
||||
|
||||
**触发回滚的条件(满足任一)**
|
||||
|
||||
- 目标设备**失联**(更新后无法连接,且已排除网络问题)—— **最高优先级**
|
||||
- `nodeagent list` 能力数少于发版前
|
||||
- `nodeagent audit verify` 返回 `ok: false`
|
||||
- `verify.mjs` 出现失败项
|
||||
|
||||
**回滚动作**
|
||||
|
||||
1. 登录目标机(物理或带外方式),用备份恢复:`agent.mjs.bak-<ts>` → `agent.mjs`。
|
||||
2. 若需回退发布通道:把 `latest.json` 指回上一版本 URL + sha256。
|
||||
3. 重启 agent(计划任务或 `run-temp.cmd`)。
|
||||
4. 复核:`verify.mjs` 全绿 → 记录时间线 → 写复盘 → **把根因变成一条会红的断言**。
|
||||
|
||||
---
|
||||
|
||||
## 变更记录
|
||||
|
||||
| 日期 | 内容 |
|
||||
|---|---|
|
||||
| 2026-10-17 | 初版。基于《代码审计 → 上线 完整交付方案》v1.0 适配,基线 `590f62e`,单测 341 全绿。 |
|
||||
@@ -53,6 +53,14 @@ export const SYSTEM_CAPABILITIES: CapabilityDescriptor[] = [
|
||||
'v18 构建指纹:hash(运行中 agent 脚本的 sha256 前 12 位,部署校验用)、bytes、mtime_ms、' +
|
||||
'node(运行时版本)、started_at、uptime_ms',
|
||||
},
|
||||
lkg: {
|
||||
type: 'object',
|
||||
description:
|
||||
'最后已知可用版本 LKG(交付流水线 §6 回滚锚点):lkg(记录,含 hash/bytes/script_path/' +
|
||||
'recorded_at)、current_hash、matches、byte_delta。' +
|
||||
'matches=false 表示当前版本**尚未被证明能启动**(刚更新完、还没完成一次成功启动),' +
|
||||
'控制端应据此暂缓向其余设备放量。仅在「本次启动真正起来了」之后才写入。',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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} 条)`);
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 覆盖率门禁(交付流水线 §2 / §8)。
|
||||
*
|
||||
* ## 为什么自己解析 lcov 而不装 c8/nyc
|
||||
* 单测用的是 Node 内置 `node --test --experimental-test-coverage`,它原生出 lcov。
|
||||
* 再引一套覆盖率工具只为「读一下阈值」属于重复建设 —— 本项目铁律之一就是
|
||||
* 「重复功能优先引用已有模块,不新建冗余内容」。
|
||||
*
|
||||
* ## 口径(重要,别照搬通用 80%)
|
||||
* 原方案(面向 Web 新项目)要求全量 ≥80%。本项目存量 341 个单测集中在
|
||||
* 协议/编码/安全守卫,而 GUI 注入、平台脚本等模块**无法在 CI 上无头验证**。
|
||||
* 直接套 80% 全量阈值只会逼出两种结果:长期 `--skip`,或为 GUI 代码写假断言。
|
||||
*
|
||||
* 故采用:
|
||||
* - **全量:只观测,不阻断**(出数字,建基线)
|
||||
* - **变更文件:行覆盖率 ≥ THRESHOLD 才阻断**(默认 70)
|
||||
*
|
||||
* ## 一个必须说清的映射
|
||||
* 单测 import 的是 `dist/*.js`(编译产物),不是 `src/*.ts`。
|
||||
* 所以「本次改了哪个源文件」要映射成「dist 里的哪个文件」才能查到覆盖率:
|
||||
* apps/agent/src/audit.ts → apps/agent/dist/audit.js
|
||||
* 规则:`/src/` → `/dist/`,`.ts` → `.js`。
|
||||
*
|
||||
* ## 无覆盖数据怎么办
|
||||
* 改动的源文件若在 lcov 里查不到对应条目(例如纯类型改动、文档、或压根没被测试碰过),
|
||||
* **不阻断**,只列进「无覆盖数据」清单。理由:把它算作 0% 会让任何重构都无法合入;
|
||||
* 而如实列出来,比一个会被绕过的门禁更有用。
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { relative, resolve } from 'node:path';
|
||||
|
||||
const REPO = resolve(process.cwd());
|
||||
|
||||
function fail(msg) {
|
||||
console.error(`\n✗ ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ---------------- 参数 ----------------
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {
|
||||
lcov: 'coverage/lcov.info',
|
||||
threshold: 70,
|
||||
base: '',
|
||||
// 全量不达标是否阻断(默认否:只观测)
|
||||
enforceTotal: false,
|
||||
totalThreshold: 80,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const a = argv[i];
|
||||
if (a === '--lcov') out.lcov = argv[++i];
|
||||
else if (a === '--threshold') out.threshold = Number(argv[++i]);
|
||||
else if (a === '--total-threshold') out.totalThreshold = Number(argv[++i]);
|
||||
else if (a === '--base') out.base = argv[++i];
|
||||
else if (a === '--enforce-total') out.enforceTotal = true;
|
||||
else if (a === '--help' || a === '-h') {
|
||||
console.log(
|
||||
`用法: node scripts/ci/check-coverage.mjs [选项]\n` +
|
||||
` --lcov <path> lcov 路径(默认 coverage/lcov.info)\n` +
|
||||
` --threshold <n> 变更文件行覆盖率阈值(默认 70)\n` +
|
||||
` --base <git-ref> 比对基线(如 origin/main);为空则只看全量\n` +
|
||||
` --enforce-total 全量不达标也阻断(默认只观测)\n` +
|
||||
` --total-threshold <n> 全量阈值(默认 80,仅在 --enforce-total 时生效)`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------- lcov 解析 ----------------
|
||||
|
||||
/**
|
||||
* 解析 lcov,返回「项目自有文件」的覆盖率。
|
||||
*
|
||||
* 过滤掉三类噪声:
|
||||
* 1. node_modules
|
||||
* 2. 仓库外路径(运行时注入的 shim,实测会混进 WorkBuddy/Application 路径)
|
||||
* 3. .test.mjs 自身(测试文件不该计入被测代码)
|
||||
*/
|
||||
function parseLcov(text) {
|
||||
const out = new Map(); // repoRelPath -> {found, hit}
|
||||
let cur = null;
|
||||
let found = 0;
|
||||
let hit = 0;
|
||||
|
||||
const flush = () => {
|
||||
if (!cur) return;
|
||||
const rel = toRepoRelative(cur);
|
||||
if (rel && isProjectFile(rel)) {
|
||||
const prev = out.get(rel);
|
||||
if (prev) {
|
||||
prev.found += found;
|
||||
prev.hit += hit;
|
||||
} else {
|
||||
out.set(rel, { found, hit });
|
||||
}
|
||||
}
|
||||
cur = null;
|
||||
found = 0;
|
||||
hit = 0;
|
||||
};
|
||||
|
||||
for (const raw of text.split('\n')) {
|
||||
const line = raw.trimEnd();
|
||||
if (line.startsWith('SF:')) {
|
||||
flush();
|
||||
cur = line.slice(3).trim();
|
||||
} else if (line === 'end_of_record') {
|
||||
flush();
|
||||
} else if (cur) {
|
||||
if (line.startsWith('LF:')) found = Number(line.slice(3)) || 0;
|
||||
else if (line.startsWith('LH:')) hit = Number(line.slice(3)) || 0;
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
function toRepoRelative(absPath) {
|
||||
const p = resolve(absPath);
|
||||
if (!p.startsWith(REPO)) return null;
|
||||
const rel = relative(REPO, p);
|
||||
if (rel.startsWith('..')) return null;
|
||||
return rel;
|
||||
}
|
||||
|
||||
function isProjectFile(rel) {
|
||||
if (rel.includes('node_modules/')) return false;
|
||||
if (rel.startsWith('node_modules/')) return false;
|
||||
// 只统计本仓库的 apps / packages / scripts
|
||||
if (!/^(apps|packages|scripts)\//.test(rel)) return false;
|
||||
// 测试自身不计入
|
||||
if (/\.test\.mjs$/.test(rel)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 源文件(src/*.ts)→ 单测实际 import 的产物路径(dist/*.js)。 */
|
||||
function mapSourceToArtifact(srcRel) {
|
||||
if (srcRel.includes('/src/')) return srcRel.replace('/src/', '/dist/').replace(/\.ts$/, '.js');
|
||||
return srcRel;
|
||||
}
|
||||
|
||||
// ---------------- 变更文件 ----------------
|
||||
|
||||
function changedFiles(base) {
|
||||
if (!base) return [];
|
||||
const range = `${base}...HEAD`;
|
||||
let out = '';
|
||||
try {
|
||||
out = execFileSync('git', ['diff', '--name-only', range], { encoding: 'utf8' });
|
||||
} catch {
|
||||
// 基线不可达(例如浅克隆)→ 退化为与 HEAD~1 比对
|
||||
try {
|
||||
out = execFileSync('git', ['diff', '--name-only', 'HEAD~1', 'HEAD'], { encoding: 'utf8' });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return out
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// ---------------- 主流程 ----------------
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!existsSync(args.lcov)) {
|
||||
fail(`找不到 lcov 文件: ${args.lcov}\n 请先跑:node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=${args.lcov} "tests/unit/**/*.test.mjs"`);
|
||||
}
|
||||
|
||||
const cov = parseLcov(readFileSync(args.lcov, 'utf8'));
|
||||
|
||||
if (cov.size === 0) {
|
||||
fail(`lcov 里没有任何项目文件(${args.lcov})—— 覆盖率采集可能失败,请检查是否加了 --experimental-test-coverage`);
|
||||
}
|
||||
|
||||
// --- 全量 ---
|
||||
let totalFound = 0;
|
||||
let totalHit = 0;
|
||||
for (const v of cov.values()) {
|
||||
totalFound += v.found;
|
||||
totalHit += v.hit;
|
||||
}
|
||||
const totalPct = totalFound > 0 ? (totalHit / totalFound) * 100 : 0;
|
||||
console.log(`全量覆盖率(观测) : ${totalPct.toFixed(1)}% (${totalHit}/${totalFound} 行, ${cov.size} 个文件)`);
|
||||
|
||||
// --- 变更文件 ---
|
||||
const changed = changedFiles(args.base);
|
||||
let blocked = false;
|
||||
const noData = [];
|
||||
|
||||
if (changed.length > 0) {
|
||||
const candidates = new Map(); // artifactRel -> srcRel
|
||||
for (const f of changed) {
|
||||
if (!/^(apps|packages|scripts)\//.test(f)) continue;
|
||||
if (/\.(md|json|ya?ml|txt)$/.test(f)) continue; // 文档/配置不参与
|
||||
const art = mapSourceToArtifact(f);
|
||||
candidates.set(art, f);
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
for (const [art, src] of candidates) {
|
||||
const v = cov.get(art);
|
||||
if (!v) {
|
||||
noData.push(src);
|
||||
continue;
|
||||
}
|
||||
const pct = v.found > 0 ? (v.hit / v.found) * 100 : 100;
|
||||
rows.push({ src, art, pct, hit: v.hit, found: v.found });
|
||||
}
|
||||
|
||||
rows.sort((a, b) => a.pct - b.pct);
|
||||
|
||||
console.log(`\n变更文件覆盖率(阈值 ${args.threshold}%,阻断):`);
|
||||
if (rows.length === 0) console.log(' (无具备覆盖数据的变更文件)');
|
||||
for (const r of rows) {
|
||||
const mark = r.pct < args.threshold ? '✗' : '✓';
|
||||
console.log(` ${mark} ${r.pct.toFixed(1).padStart(5)}% ${r.src} (${r.hit}/${r.found} 行)`);
|
||||
if (r.pct < args.threshold) blocked = true;
|
||||
}
|
||||
|
||||
if (noData.length > 0) {
|
||||
console.log(`\n无覆盖数据的变更文件(不阻断,供人工判断):`);
|
||||
for (const f of noData) console.log(` - ${f}`);
|
||||
}
|
||||
} else {
|
||||
console.log('\n(未提供 --base,跳过变更文件判定)');
|
||||
}
|
||||
|
||||
if (args.enforceTotal && totalPct < args.totalThreshold) {
|
||||
console.error(`\n✗ 全量覆盖率 ${totalPct.toFixed(1)}% < 阈值 ${args.totalThreshold}%`);
|
||||
blocked = true;
|
||||
}
|
||||
|
||||
if (blocked) {
|
||||
console.error('\n✗ 覆盖率门禁未通过');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\n✓ 覆盖率门禁通过');
|
||||
@@ -0,0 +1,87 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* LKG(最后已知可用版本)守卫 —— 交付流水线 §6。
|
||||
*
|
||||
* 为什么需要守卫:LKG 是回滚的**目标**。若它记错了(记成尚未验证的版本),
|
||||
* 回滚就会回到一个坏版本 —— 比没有 LKG 更糟,因为它给人虚假的安全感。
|
||||
* 故这里钉死三条:
|
||||
* 1. 记录内容必须与当前入口脚本真实一致;
|
||||
* 2. 文件不存在/损坏时**返回 null,不做任何猜测**;
|
||||
* 3. matches 的判定必须用同一套 hash 口径(与 system.info 的 build.hash 同)。
|
||||
*/
|
||||
|
||||
// 通过 NODEAGENT_HOME 指向临时目录,避免污染真实数据目录
|
||||
const dir = mkdtempSync(join(tmpdir(), 'na-lkg-'));
|
||||
process.env['NODEAGENT_HOME'] = dir;
|
||||
|
||||
const { recordLKG, readLKG, lkgStatus, lkgFilePath } = await import('../../apps/agent/dist/lkg.js');
|
||||
|
||||
test('LKG 落在 NODEAGENT_HOME 指定的数据目录', () => {
|
||||
assert.ok(lkgFilePath().startsWith(dir), `LKG 路径应在临时目录内,实际: ${lkgFilePath()}`);
|
||||
});
|
||||
|
||||
test('未记录时 readLKG 返回 null(不猜测)', () => {
|
||||
assert.equal(readLKG(), null);
|
||||
});
|
||||
|
||||
test('记录后可读回,且 hash 与当前入口脚本真实一致', () => {
|
||||
const rec = recordLKG();
|
||||
assert.ok(rec, 'recordLKG 应返回记录');
|
||||
const back = readLKG();
|
||||
assert.ok(back, '应能读回');
|
||||
assert.equal(back.hash, rec.hash);
|
||||
assert.equal(back.bytes, rec.bytes);
|
||||
|
||||
// 独立重算:与直接对 argv[1] 求 sha256 前 12 位比对
|
||||
const p = process.argv[1];
|
||||
const expect = createHash('sha256').update(readFileSync(p)).digest('hex').slice(0, 12);
|
||||
assert.equal(back.hash, expect, 'LKG hash 必须等于入口脚本真实 sha256 前 12 位');
|
||||
});
|
||||
|
||||
test('lkgStatus.matches 在记录后为 true,byte_delta 为 0', () => {
|
||||
recordLKG();
|
||||
const s = lkgStatus();
|
||||
assert.equal(s.matches, true);
|
||||
assert.equal(s.byte_delta, 0);
|
||||
assert.equal(s.current_hash, s.lkg.hash);
|
||||
});
|
||||
|
||||
test('LKG 文件损坏时 readLKG 返回 null 而非抛错', () => {
|
||||
recordLKG();
|
||||
writeFileSync(lkgFilePath(), '{ this is not json', 'utf8');
|
||||
assert.equal(readLKG(), null, '损坏的 LKG 必须返回 null,不得抛错或猜值');
|
||||
});
|
||||
|
||||
test('LKG 记录缺少 hash 字段时视为无效', () => {
|
||||
writeFileSync(lkgFilePath(), JSON.stringify({ bytes: 1 }), 'utf8');
|
||||
assert.equal(readLKG(), null);
|
||||
});
|
||||
|
||||
test('故障注入:篡改 LKG 的 hash 会被 matches=false 检出', () => {
|
||||
recordLKG();
|
||||
const good = readLKG();
|
||||
// 模拟「更新后还没起来」:LKG 仍是旧值,而当前脚本已被替换成别的 hash
|
||||
writeFileSync(
|
||||
lkgFilePath(),
|
||||
JSON.stringify({ ...good, hash: 'deadbeefcafe' }, null, 2),
|
||||
'utf8',
|
||||
);
|
||||
const s = lkgStatus();
|
||||
assert.equal(s.matches, false, 'hash 不一致必须检出 matches=false(这是「暂缓放量」的判据)');
|
||||
assert.ok(s.lkg, 'LKG 记录本身仍应可读,供控制端定位回滚目标');
|
||||
});
|
||||
|
||||
test('LKG 记录含回滚所需的全部字段', () => {
|
||||
recordLKG();
|
||||
const r = readLKG();
|
||||
for (const k of ['hash', 'bytes', 'script_path', 'recorded_at', 'node_version', 'platform']) {
|
||||
assert.ok(k in r, `LKG 缺少回滚所需字段: ${k}`);
|
||||
}
|
||||
assert.ok(existsSync(lkgFilePath()), 'LKG 文件应真实落盘');
|
||||
});
|
||||
Reference in new issue
Block a user