1783 字
9 分钟

CI 测试裁剪与提交证据复用

一个代码变更通过测试后,准备版本号的提交又跑一遍相同测试;Verify 做过生产构建,镜像 Job 还会再构建一次。这些等待很容易被归到“CI 太慢”,但调大并发之前,值得先确认重复工作在哪里。

2026 年 9 月 11 日,我对一个内部应用的 CI 做了三阶段调整:修正变更范围分类、复用纯版本提交的测试证据,再为少量展示文件选择测试。本文根据当时记录事后整理,项目与业务信息已匿名化。三阶段均已合并,其中一次版本提交的 Verify 用时约 26 秒。

先弄清楚本次到底改了什么#

路径分类的输入必须覆盖整个变更范围。假设一次 push 包含两个提交,先修改后端,再修改说明文档。如果只看最后一个提交,就会把需要回归的代码变化误判成纯文档变化。

这次将 PR 的 Base、Head,以及 push 事件中的 before → after 作为输入。只有事件可读、对象存在、Checkout 与事件 SHA 一致、基线为祖先时,才继续分类。空变更、强推或无法确认范围,都回退全量检查,并记录原因。

删除和重命名也要参与判断。一个代码文件改名为 Markdown,不能因为新路径看起来像文档就免除检查。下面是提取路径的简化命令;两个 SHA 必须先由 CI 校验,结果按 NUL 分隔读取:

Terminal window
git diff --no-renames --name-only -z "$BASE_SHA" "$HEAD_SHA" --

--no-renames 让删除的旧路径和新增路径都进入分类,-z 避免用换行拆坏特殊文件名。参数行为见 Git diff 文档。

纯说明文档可以走轻量检查,但仍然要产生分支保护要求的状态。否则整个 Job 被路径过滤掉,可能留下一个永远等不到的必需检查。这次让筛选发生在 Job 内部:需要的状态始终存在,Job 根据分类决定执行多少工作。

生产构建则交给必需的镜像 Job。Verify 不再重复运行同一生产构建,类型检查和 HTTP 测试所需的独立构建仍保留。这样生产编译失败照样阻止合并,只是它由一个明确的门禁负责。

绿色状态还要追到实际执行#

纯版本提交的快速通道只接受根 package.json 中一个合法 version 值变化。实现会用新版本替换原文本中的版本值,再与修改后文本逐字比较。依赖、脚本、其他字段,甚至额外格式变化,都不符合这条快速通道。

不过,“当前提交只改版本号”还不足以跳过测试。它的基线也可能没有执行测试,或者那次成功对应的是更早的代码。

这次的复用判断分成两层。先取基线 SHA 的最新必需状态,确认它们都成功;再沿状态指向的 Job,核对仓库、准确 SHA、Job 名称与结果,并确认 Lint、Typecheck、Test 三个步骤实际执行成功。某个步骤是 skipped,不能充当成功执行的证明。新失败、取消或仍在运行的状态,也不能被更早的绿色记录覆盖。

下面是步骤证据判断的简化示例,API、仓库与提交身份核验由外层完成:

function hasExecutedChecks(job, expectedSha) {
if (job.head_sha !== expectedSha || job.conclusion !== 'success') {
return false;
}
const steps = new Map(job.steps.map(s => [s.name, s.conclusion]));
return ['Lint', 'Typecheck', 'Test']
.every(name => steps.get(name) === 'success');
}

回溯基线也设了边界:最多检查 32 个提交,只跨过说明文档、严格版本变更,以及树与已测 PR 完全相同的普通合并。遇到新代码就停止;证据缺失或 API 异常,直接做完整验证。

Git 的 tree 对象描述文件树,可以通过 ^{tree} 获取。两棵树相同,能帮助判断合并是否引入了额外文件变化,但不能独自证明运行环境或测试结果相同。这里仍需要同 SHA 的实际 Job 证据。Git rev-parse 文档说明了对象解析方式。

新版本镜像仍然构建,并检查产品版本与 Revision,再执行镜像内验收。复用的是已经证明执行过的代码测试,不会让版本制品绕过自己的验证。

展示文件只能从窄白名单开始#

第三阶段才处理测试选择。不能因为文件后缀是 TSX 或 CSS,就把它当成只影响展示:页面入口可能加载数据,组件可能发请求,也可能承担权限判断。

这次只登记少量经过审查的展示叶子,例如样式、图标和简单页面框架。每个分组仍带上整个快速测试层、导航契约及真实 HTTP 权限检查,再合并与该组相关的额外测试。多个组取并集并去重,最后交给同一个 Node 调度器运行。

页面入口、请求逻辑、认证与授权、媒体处理、后端、依赖、CI、共享包和测试文件自身,都继续全量。未知路径也不会自动加入白名单;展示文件以后增加数据加载或业务编排时,需要在同一变更中移出白名单。

分类结果还携带 Base、Head。测试 Job 消费结果前,再核对自己的 Checkout;SHA 不一致、分组未知或分类缺失,就回退全量。日志与报告保存选择理由和实际文件列表,方便审查一次“少跑了测试”到底依据什么。

当时结果能说明什么#

三阶段按依赖顺序完成合并。纯版本快速通道随后出现一个自然样本:Verify 约 26 秒,同时新镜像构建与验收仍执行。它证明这条分支在真实 Runner 上触发过,不能证明所有版本提交都只需 26 秒。

展示四组并集在本机选中 165 个文件,1,193 项测试通过,执行阶段约 27 秒。最终 CI 因为出现白名单外路径,执行了全量 279 个文件,1,991 项通过、8 项既有跳过、0 失败。后一个结果同样有用:它说明遇到未知修改时,全量回退确实生效。

要继续评估节省多少,还需要同类变更的多轮样本,同时记录测试墙钟时间、完整 PR 等待时间和各 Job 累计执行秒数。并发文件耗时之和不等于墙钟时间,单个步骤变短也不等于 Runner 总工作量按同比例下降。

这次交付留下了可审查的判断条件:完整变更范围、准确 SHA、实际步骤结果,以及解释得清楚的测试白名单。以后某次优化判断不成立,CI 会回到完整验证;少跑的每一项工作,都能追到它被省略的依据。

CI 测试裁剪与提交证据复用
https://blog.yangyus8.top/posts/ci-test-selection-commit-evidence/
作者
杨与S8
发布于
2026-09-11
许可协议
CC BY-NC-SA 4.0

评论