返回 AICAP-180
B7 · Day 68OAuth 2.1 + MCP 安全 + CI gate

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)

  1. 现状(诚实标注).github/workflows/ci.yml 当前只有一个 test-and-build job(checkout → setup pnpm/node → pnpm installpnpm typecheckpnpm testpnpm build),尚未含 eval-gate job。本日实战即「新增该 job」,故「ci.yml 含 eval-gate job」是**待加(待建)**状态,不能宣称已在仓库里——这是诚信底线。
  2. 要加的 job 形状:在 jobs: 下新增 eval-gate,步骤 checkout + setup + pnpm install --frozen-lockfile + run: pnpm eval:gate
  3. eval-gate.ts 的 CI 友好行为(scripts/eval-gate.ts:27):若 agent-evals/reports/ 无报告,打印 no eval report found — skippingprocess.exit(0)——绿色通过。这让「无报告时的 dry-run」天然是绿的(job 不会因缺报告而误红)。
  4. 真正阻断的路径(eval-gate.ts:36-43):有报告时 checkGate(current, baseline, thresholds)!res.pass 则逐条 console.error violation 后 process.exit(1) → job fail → 阻断 merge。
  5. 阈值硬编码(eval-gate.ts:24):{ maxCompletionDrop: 0.05, minKappa: 0.6, maxUnknownRate: 0.25 }——CI 里跑出来的判定就由这三条决定。
  6. 基线缺失的影响(eval-gate.ts:32):当前仓库agent-evals/baseline.json(reports/ 已有两份真实 run),故即使有报告,completion 检查也因 baseline={} 无 finite 值而跳过——含真实 pass-rate 比对的端到端 dry-run 必须等 baseline 就位后才有意义。
  7. 本地预跑act(本地跑 Actions)或直接 pnpm eval:gate dry-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. 今日实战

  1. .github/workflows/ci.yml:新增 eval-gate job,调 pnpm eval:gate(scripts/eval-gate.ts 读 baseline.json 对比 reports/ 最新报告)。
  2. 本地用 act 或直接 pnpm eval:gate dry-run 跑通一次——当前因无 baseline,预期走 skip(0) 或仅 κ/unknown 阈值,绿色通过。
  3. (依赖 Day 67 产物)待 agent-evals/baseline.json 选定后,再跑一次含真实 pass-rate 比对的端到端 dry-run。
  4. 配 branch protection 把 eval-gate 设为 required check,使其真正阻断 merge(blocking)。
  5. 提交 docs/aipa/day68-ci-gate.md 记录 job YAML、解耦取舍、报告新鲜度纪律。

4. 今日实测 / 产出

  • ci.ymleval-gate job + 1 次绿色 dry-run 日志 —— job 新增本身为本日产出(当前 ci.yml 仅 test-and-buildeval-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 复查)。

自测题(讲得出才算掌握)

  1. 为什么 gate 必须 blocking 而非 warning?(答:non-blocking 退化成没人看的日志,失门禁意义。)
  2. 为什么不在 CI 里直接跑模型?(答:要 key、烧钱、不确定;正解是离线产报告、CI 只比对。)
  3. 基线为什么要进 git?(答:让「某 commit 上的判定」可复现;外部动态拉取破坏可复现性。)
  4. eval-gate.ts 在无报告时为何 exit 0 而非 exit 1?(答:避免缺报告误红;缺报告是「没东西可比」不是「退步」。)
  5. 当前 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 跌破阈值,确认闸门真触发。