GitHub Actions 接 eval 阻断
Day 67 把 eval gate 的决策逻辑(gate.ts,fail-closed,纯函数)做好了。但一个躺在仓库里、要人手动跑的脚本不是「门禁」——门禁的定义是「不通过就合不进去」。今天 Day 68 把 pnpm eval:gate 接进 CI 编排(GitHub Actions),让 PR 触发时自动跑、低于阈值就 exit 1 使 job fail、从而阻断 merge。在 B1→B
阶段: B7 · OAuth 2.1 + MCP 安全 + CI gate(Day 61-70) 标签: #github-actions #ci-gate #deterministic #fail-closed
今日导引(由浅入深)
Day 67 把 eval gate 的决策逻辑(gate.ts,fail-closed,纯函数)做好了。但一个躺在仓库里、要人手动跑的脚本不是「门禁」——门禁的定义是「不通过就合不进去」。今天 Day 68 把 pnpm eval:gate 接进 CI 编排(GitHub Actions),让 PR 触发时自动跑、低于阈值就 exit 1 使 job fail、从而阻断 merge。在 B1→B18 曲线上,这是评测支柱从「能算」升级到「能挡」的关键一格。明天 Day 69 会故意制造回归来验证这道闸真的会红。最小可判定产出:CI 里的 eval-gate job + 一次绿色 dry-run(注意:接真实 baseline 比对的端到端 dry-run 标注为待跑,需 key 选定基线后)。
由浅入深三层
- 浅:写好了
pnpm eval:gate,能算出退步了没。 - 中:但要人手动跑的脚本不是门禁——门禁的定义是「不过就合不进」。要接进 CI 并设为 required check。
- 深:接进 CI 后还有两条工程红线:job 必须 deterministic(否则时绿时红、被无视)、基线必须随仓库版本化(否则判定不可复现)。本仓用「离线产报告 + CI 只比对」把不确定性挡在门禁之外。
1. 机理精读
CI 编排把 eval 从「报告」变成「门禁」。 流水线:PR 触发 → checkout → 装依赖 → 跑 eval → 与 baseline.json 对比 → 低于阈值则 process.exit(1) 使 job fail → GitHub 据 job 状态阻断 merge(配 branch protection 的 required check)。门禁的本质是把「人会不会记得跑 eval」这个不可靠环节,替换成「不跑/不过就合不进」的机器约束。
两条不可妥协的工程约束:determinism + 基线版本化。
- deterministic(确定性):同一份输入,job 每次跑必须给同一结论。若 gate 依赖时钟、随机、网络抖动,门禁就「时绿时红」,团队很快会学会无视它(alarm fatigue),门禁失效。
gate.ts是纯函数(无时钟/随机/网络),这是确定性的基础;不确定性只可能来自「跑模型那一步」,所以本仓的设计是把 baseline 冻成文件、gate 只对比文件,把不确定性挡在 gate 之外。 - 基线随仓库版本化:
baseline.json提交进 git,和代码同版本。这样「在某 commit 上 gate 的判定」是可复现的——回滚代码也回滚基线,门禁结论可重建。若基线放在外部存储/动态拉取,门禁就不可复现。
关键设计选择:blocking vs non-blocking。 gate 必须是 blocking(fail → 阻断 merge)。若设成 non-blocking warning(只打条警告、不挡合并),它就退化成一条没人看的日志——失去门禁意义。这是 2026 CI eval 的硬共识。
为什么不在 CI 里直接跑模型? 跑真实模型要 API key、要花钱、有网络不确定性,放进每个 PR 的 CI 既不确定又昂贵。本仓的分层是:「跑模型产报告」与「gate 对比报告」解耦——模型跑在 key 就位的离线环境产 agent-evals/reports/*.json,CI 只跑 eval:gate(无 key,读已存报告对比 baseline)。代价:CI 看到的是「最近一次离线报告」而非「本 PR 实时重跑」,所以报告的新鲜度要靠流程纪律保证。这是一个经过权衡的妥协:用「报告可能不是本 PR 实时」换「CI 确定 + 零成本 + 无需在 CI 注入 key」。对一个还没有大规模付费 eval 预算的个人作品集仓库,这个取舍是合理的;规模化后可升级成「nightly 全量跑 + PR 增量跑」的两段式。
门禁 job 应与 build job 并列还是串联? 并列(独立 job)更好:test-and-build 失败不应掩盖 eval-gate 的结论,反之亦然——两条独立信号让人一眼看出「是代码挂了还是模型退步了」。GitHub 的 required status checks 可以把两个 job 都设为必过,merge 才放行。串联(一个 job 里跑完 build 再跑 gate)会让先失败的那步遮住后面,诊断更难。
2. 代码走读(.github/workflows/ci.yml + scripts/eval-gate.ts)
- 现状(诚实标注):
.github/workflows/ci.yml当前只有一个test-and-buildjob(checkout → setup pnpm/node →pnpm install→pnpm typecheck→pnpm test→pnpm build),尚未含eval-gatejob。本日实战即「新增该 job」,故「ci.yml 含 eval-gate job」是**待加(待建)**状态,不能宣称已在仓库里——这是诚信底线。 - 要加的 job 形状:在
jobs:下新增eval-gate,步骤 checkout + setup +pnpm install --frozen-lockfile+run: pnpm eval:gate。 eval-gate.ts的 CI 友好行为(scripts/eval-gate.ts:27):若agent-evals/reports/无报告,打印no eval report found — skipping并process.exit(0)——绿色通过。这让「无报告时的 dry-run」天然是绿的(job 不会因缺报告而误红)。- 真正阻断的路径(eval-gate.ts:36-43):有报告时
checkGate(current, baseline, thresholds),!res.pass则逐条console.errorviolation 后process.exit(1)→ job fail → 阻断 merge。 - 阈值硬编码(eval-gate.ts:24):
{ maxCompletionDrop: 0.05, minKappa: 0.6, maxUnknownRate: 0.25 }——CI 里跑出来的判定就由这三条决定。 - 基线缺失的影响(eval-gate.ts:32):当前仓库无
agent-evals/baseline.json(reports/ 已有两份真实 run),故即使有报告,completion 检查也因baseline={}无 finite 值而跳过——含真实 pass-rate 比对的端到端 dry-run 必须等 baseline 就位后才有意义。 - 本地预跑:
act(本地跑 Actions)或直接pnpm eval:gatedry-run 验证 job 能绿。
要新增的 YAML 形状(参照现有 test-and-build)。 复用现有 job 的 setup 段(pnpm action-setup@v4 version 10、setup-node@v4 node 20 cache pnpm、pnpm install --frozen-lockfile),尾部换成 pnpm eval:gate:
eval-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 10 }
- uses: actions/setup-node@v4
with: { node-version: 20, cache: pnpm }
- run: pnpm install --frozen-lockfile
- name: Eval gate (fail-closed)
run: pnpm eval:gate
这段是本日要写进 .github/workflows/ci.yml 的内容;写入前 ci.yml 只有 test-and-build 一个 job。
一次「绿色 dry-run」长什么样。 当前无 baseline、且 reports/ 有报告时,pnpm eval:gate 会打印 [eval-gate] 的 JSON(current/baseline/thresholds + pass:true)后 [eval-gate] PASS,exit 0 → job ✅。若改成「无报告」场景,则打印 no eval report found — skipping 后 exit 0——同样绿。两种绿要区分:前者是「比对通过」,后者是「没东西可比」。本日的绿色 dry-run 属后者或「仅 κ/unknown 阈值」类,因为 completion 比对还缺 baseline。
3. 今日实战
- 扩
.github/workflows/ci.yml:新增eval-gatejob,调pnpm eval:gate(scripts/eval-gate.ts 读 baseline.json 对比 reports/ 最新报告)。 - 本地用
act或直接pnpm eval:gatedry-run 跑通一次——当前因无 baseline,预期走 skip(0) 或仅 κ/unknown 阈值,绿色通过。 - (依赖 Day 67 产物)待
agent-evals/baseline.json选定后,再跑一次含真实 pass-rate 比对的端到端 dry-run。 - 配 branch protection 把
eval-gate设为 required check,使其真正阻断 merge(blocking)。 - 提交
docs/aipa/day68-ci-gate.md记录 job YAML、解耦取舍、报告新鲜度纪律。
4. 今日实测 / 产出
ci.yml含eval-gatejob + 1 次绿色 dry-run 日志 —— job 新增本身为本日产出(当前 ci.yml 仅test-and-build,eval-gate job 待加)。- gate 脚本(
scripts/eval-gate.ts/gate.ts):已 built+tested。 - 含真实 pass-rate 的端到端 dry-run:待跑(需 key 跑选定基线后) —— 接 baseline 比对的实跑依赖
baseline.json就位。 - OTel/Langfuse 联动观测:待云/待接线(未 built)。
- 真实锚点不变:reports/ 已有
n=29/completionRate≈0.8965/codePassRate≈0.7931/judgeHumanKappa=null的 run。不臆造新数字。
5. 常见误区 / 陷阱
- 把 eval 设成 non-blocking warning:等于没门禁——必须 blocking(fail → 阻断 merge)。
- 以为 ci.yml 已含 eval-gate:当前仓库 ci.yml 只有
test-and-build,eval-gate 是本日要加的,别提前宣称已存在。 - 在 CI 里直接跑模型:不确定 + 烧钱 + 要 key;正解是离线产报告、CI 只比对。
- 基线不进 git:基线必须随仓库版本化,否则门禁判定不可复现。
- 缺报告时让 job 红:eval-gate.ts 设计成「无报告→skip(0)绿」,是为避免误红;别把它改成缺报告就 fail。
- gate job 串在 build 后:先失败的步骤会遮住后面,诊断变难;应与
test-and-build并列为独立 required check。 - 忘了配 branch protection:job 会跑但不挡 merge——required status check 没勾上,门禁形同虚设。
- 以为
pnpm install不用 frozen-lockfile:CI 必须--frozen-lockfile,否则依赖漂移破坏可复现性。
附:概念辨析(易混淆)
- blocking vs non-blocking:blocking gate 失败即阻断 merge;non-blocking 只打警告。门禁必须 blocking。
- job 失败 vs check 失败:
process.exit(1)使 job 失败;要让它真挡 merge,还需在 branch protection 把该 job 设为 required status check。 - deterministic gate vs 实时跑模型:gate 是纯函数(确定);模型跑那步不确定。本仓把后者挪到离线,CI 只跑前者。
- dry-run 绿(skip)vs dry-run 绿(pass):无报告→skip(0) 绿;有报告且比对通过→pass 绿。两种绿语义不同。
- 基线进 git vs 外部存储:进 git 才能「某 commit 上的判定可复现」;外部动态拉取会破坏可复现性。
附:报告新鲜度的流程纪律(解耦的代价)
因「跑模型」与「gate 比对」解耦,CI 看到的是最近一次离线报告,须用纪律补足新鲜度:
- 每次改动可能影响模型行为(prompt 模板、工具描述、上下文拼装)后,离线重跑
pnpm eval:agent刷新 reports/。 - reports/ 文件名带时间戳(如
run-2026-06-22T17-34-34-717Z.json),latestReport()按文件名排序取最新——保证比对用的是最近一份。 - PR 描述里标注「本 PR 是否需要刷新 eval 报告」,避免「改了 prompt 却用旧报告过门禁」的盲区。
- 规模化后升级为两段式:nightly 全量跑刷基线,PR 增量跑关键子集。
6. 学习资源(每条带 YYYY-MM)
- GitHub Actions docs — Workflow syntax / Required status checks(2026,执行当周核对)。
nektos/act— 本地运行 GitHub Actions(2026,dry-run 验证)。- Anthropic, Demystifying evals(2026-01)— eval 进 CI 的可重复性要求。
- 本仓
.github/workflows/ci.yml(2026-06,现仅test-and-build)+scripts/eval-gate.ts(2026-06)— 现状与待加 job。 - 本仓
agent-evals/reports/*.json(2026-06)— 离线报告,CI 比对的输入。
SOTA检查 (2026-06 更新)
- 当前主流:GitHub Actions + 脚本化 fail-closed gate 为当前 CI eval 主流,仍 SOTA。
- 待接线(未 built):OTel/Langfuse 联动观测属待云/待接线,不在本日范围。
- 过时黑名单:non-blocking warning gate(失门禁意义);CI 内实时跑模型当门禁(不确定 + 高成本)。
- 演进方向:规模化后从「单次离线报告比对」升级为「nightly 全量刷基线 + PR 增量跑关键子集」的两段式;并把 OTel/Langfuse 观测接入 CI(当前待云/待接线)。
- 下次复查点:baseline.json 就位后补端到端 dry-run;branch protection required check 设定核对;GitHub Actions 语法执行当周核版本(action 版本 pin 复查)。
自测题(讲得出才算掌握)
- 为什么 gate 必须 blocking 而非 warning?(答:non-blocking 退化成没人看的日志,失门禁意义。)
- 为什么不在 CI 里直接跑模型?(答:要 key、烧钱、不确定;正解是离线产报告、CI 只比对。)
- 基线为什么要进 git?(答:让「某 commit 上的判定」可复现;外部动态拉取破坏可复现性。)
eval-gate.ts在无报告时为何 exit 0 而非 exit 1?(答:避免缺报告误红;缺报告是「没东西可比」不是「退步」。)- 当前 ci.yml 里有 eval-gate job 吗?(答:没有,只有 test-and-build;本日要新增。)
衔接
- 昨天:Day 67 — eval CI gate 设计(fail-closed 决策逻辑 + completion-drop ∧ κ 双条件)。
- 今天:把
pnpm eval:gate接进 GitHub Actions,PR 触发 → 低于阈值 exit 1 阻断 merge(job 待加)。 - 明天:Day 69 — 制造回归验证 gate 会红:故意让 pass-rate 跌破阈值,确认闸门真触发。