更新 PR 验证与自动合并:从风险分层到 Merge Queue 的证据链
绿灯很多,为什么这个补丁仍然不该自动合并
一个机器人提交了日志库的 patch 更新。单元测试、lint 和构建都显示绿色,自动合并规则便在深夜把它送进主分支。第二天才发现,这个 PR 同时重写了锁文件中的原生传递依赖,Linux 测试通过,但生产使用的另一种架构从未运行;更糟的是,保护规则要求的检查名在 workflow 重命名后失效,界面上的几个绿色圆点并不是团队以为的那组门禁。
依赖更新自动合并最危险的误区,是把“PR 上有成功检查”写成“证据完整”。检查必须先回答它证明什么、由谁产生、绑定哪个 commit、是否覆盖变更风险,然后才谈成功。一个缺席的检查不是失败,也不是成功;它是没有证据。一次旧 SHA 上的成功也不能为 rebase 后的新 SHA 背书。
因此自动合并不是 bot 的特权,而是一台状态机:分类器根据实际 diff 给出风险;仓库规则把风险映射到 owner 与必需检查;CI 为当前 head SHA 产生证据;分支保护或 ruleset 拒绝缺失、失败和过期结果;繁忙分支再通过 Merge Queue 在最新基线与队列组合上重验。机器人最多请求进入下一状态,不能自行宣布通过。
这里有三层能力,不能用一个“已开启自动合并”混称。Dependabot Version Updates 是 GitHub 托管能力,由仓库中的 dependabot.yml 配置;Renovate 既可使用 Mend 托管 App,也可运行 MIT 许可的开源 CLI。自托管 Renovate 要自行维护运行器、平台令牌、包管理器和升级,官方只支持最新发行版。Renovate 43.x 的配置 schema 与 GitHub 当前 ruleset / Merge Queue 语义可以组成这套门禁;落到 GitHub Enterprise Server 时,实例发行线的文档才是判断开关是否可用的依据。平台原生 auto-merge 和 Merge Queue 属于托管平台能力,不会因为更新器是开源软件就自动出现在其他代码托管平台。
从 diff 计算风险,而不是相信 PR 标题
机器人标题里的 patch 只是候选版本关系,不是仓库风险。分类器必须读取变更文件与更新元数据,再给出以下几类信号:
低风险候选通常是单一开发依赖的 patch/digest 更新,manifest 与 lockfile 差异边界稳定,不触碰运行时、构建基础设施、代码生成、迁移或安全配置。它可以申请自动合并,但仍需要必需检查。
中风险候选包括运行时依赖 minor、多个包分组更新、基础镜像 digest、编译器插件、测试框架或锁文件出现超出声明依赖的变化。它需要更宽的测试矩阵或 owner review。
高风险候选包括 major、运行时核心库、数据库驱动、序列化协议、认证授权、加密、原生扩展、容器基础镜像跨轨、GitHub Actions、Terraform provider、Helm chart、迁移文件,以及任何无法解释的 manifest/lockfile 漂移。它只能人工批准,并根据对象增加迁移、兼容或安全验证。
风险只允许向上提升。例如 PR 标题声称 patch,但 diff 修改 .github/workflows/,最终仍是高风险;多个低风险包被分为一个原子 PR 后,组合风险可能上升。分类结果要绑定 head_sha + policy_revision + changed_files_digest,rebase 或机器人重建分支后自动失效。
把检查矩阵写成可执行契约
不要为每个标签随意拼 workflow。先定义证据契约,再由分类器输出所需检查。一个实用起点是:
policyRevision: dependency-gate-v1
classes:
low:
requiredChecks:
- dependency-policy
- unit-test
- lockfile-integrity
ownerApproval: false
automergeEligible: true
medium:
requiredChecks:
- dependency-policy
- unit-test
- integration-test
- lockfile-integrity
- dependency-review
ownerApproval: true
automergeEligible: false
high:
requiredChecks:
- dependency-policy
- unit-test
- integration-test
- compatibility-test
- dependency-review
- migration-safety
ownerApproval: true
automergeEligible: false这里的名字必须与平台保护规则中的 required check context 完全一致。unit-test (node-current) 和 unit-test 是不同检查;矩阵 job 动态改名、workflow 跳过整个 job、第三方 App 换了 check source,都可能让必需检查永远 pending 或被同名伪造。GitHub 允许把 required check 绑定到预期 GitHub App,避免其他主体写入同名状态,行为见 About protected branches。
还有一个反直觉边界:GitHub 分支保护把 required check 的 successful、skipped 和 neutral 都视为满足。于是“条件不匹配所以 job skipped”在平台层不是缺证据,而可能是一张可合并的绿票。汇总检查必须读取底层 job 的实际结论和分类输入,只接受策略定义的 success;需要运行却得到 skipped、neutral、cancelled、timed_out、action_required 或根本没有 check run,都输出失败。对于可选检查,分类器应明确写出 not-required-for:<risk>,不能靠 workflow 自己跳过来表达策略。
平台通常只能静态要求一组检查,风险矩阵却是动态的。解决办法是设置一个永远必需的汇总检查 dependency-policy:它读取当前 SHA 的分类结果和底层检查,缺少任何一项就失败。底层重型 workflow 可以按风险条件运行,但汇总器不能因条件跳过;否则“没有调度兼容测试”会被误当作“兼容测试成功”。
CODEOWNERS 是路由,不是批准本身
把更新策略、工作流、迁移和运行时依赖分别路由给真正 owner:
# .github/CODEOWNERS
/.github/dependabot.yml @your-org/dependency-governors
/renovate.json @your-org/dependency-governors
/.github/workflows/ @your-org/ci-platform
/package.json @your-org/service-owners
/package-lock.json @your-org/service-owners
/db/migrations/ @your-org/database-owners
/infra/ @your-org/platform-ownersGitHub 会为匹配文件请求 code owner,但只有保护规则或 ruleset 启用 required code owner review 后,批准才成为合并条件,见 About code owners。CODEOWNERS 文件本身也必须被 owner 保护,否则机器人可以在同一个 PR 中改写审批路由。启用“新提交使旧批准失效”或“最后一次可审查 push 必须由其他人批准”后,rebase 和机器人重建分支会让旧批准失效,这是正确的安全信号,不应由机器人自动重新批准。
GitLab 对应的是 protected branch、approval rules 和 code owner approval。要防止直推,Allowed to push and merge 必须显式为 No one;审批规则再决定哪些 owner 可以批准,见 GitLab protected branches与 merge request approval rules。套餐与可用能力会变化,仓库模板应检测实际平台能力,不要在文章配置中写死产品层级。
让仓库保护成为最后裁判
GitHub 的受保护分支或 ruleset 至少应要求:通过 PR 合并、指定 required checks、必要的 code owner review、解决对话、阻止绕过,并禁止 force push 与删除。管理员和 App 也应受规则约束;若某个应急身份确需 bypass,要独立审批和审计,更新机器人不在名单中。
严格要求分支 up to date 能证明 PR head 已包含最新 base,但繁忙仓库会反复 rebase、重复消耗 CI。Merge Queue 解决的是这个竞争窗口:PR 先满足入队条件,平台创建包含最新 base 和队列前序变更的 merge group,再对组合结果运行 required checks;通过后才合并。它不是“跳过 rebase 的按钮”,而是把重验从每个作者分支移到平台管理的临时组合提交。GitHub 的队列语义与并发设置见 Managing a merge queue。
如果 workflow 只监听 pull_request,队列创建的 merge_group 不会获得检查,队列会一直等待。因此所有队列 required workflow 都要增加该事件:
name: dependency-required-checks
on:
pull_request:
merge_group:
permissions:
contents: read
jobs:
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<reviewed-full-commit-sha>
- run: npm ci --ignore-scripts
- run: npm test
lockfile-integrity:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<reviewed-full-commit-sha>
- run: npm ci --ignore-scripts
- run: git diff --exit-code -- package-lock.json生产中要把 action 固定到审核过的完整 SHA,并在支持队列的隔离仓库先验证 pull_request 与 merge_group 都产生同名 check。队列并发越高,吞吐越大,CI 峰值和取消浪费也越高;group 越大,单次部署包含的变更越多,失败定位与回滚越难。参数由主分支到达率、CI 时长、失败率、部署批大小和预算共同决定,不照搬固定数字。
队列还有一个容易被忽略的开关:是否只合并检查无失败的单个 PR。关闭它时,平台可以把单个 PR 上曾失败的检查留给组合构建重新裁决,只要 merge group 的组合证据通过;这适合治理偶发测试,但会让单 PR 证据更难解释。依赖更新仓库应默认要求单 PR 与 merge group 两阶段都通过,确需放宽时先把 flaky check 从 required 集合中治理掉,而不是让队列吞掉确定性失败。第三方 CI 不监听 Actions 的 merge_group,它必须识别 gh-readonly-queue/{base_branch} 临时分支,并把结果写到临时分支对应 SHA,不能回写 PR head SHA。
自动合并请求必须在哪些状态拒绝
“请求 auto-merge”与“允许 merge”是两个动作。分类器只在低风险候选上发出请求;平台在以下任一事实出现时保持拒绝或取消请求:PR 是 draft,目标分支改变,head SHA 或 diff digest 改变,存在冲突,required check 缺失或不是可信 App 产生,策略要求的检查出现非 success,owner review 缺失或因新提交失效,对话未解决,必需部署未成功,规则不允许所选 merge method,机器人拥有的身份与允许身份不匹配,或 PR 没有按规则进入 Merge Queue。
GitHub 原生 auto-merge 还会在无仓库写权限的人向 head branch 推送新提交或切换 base branch 后自动关闭。这不是偶发 UI 状态,而是防止维护者的旧授权覆盖贡献者新代码的拒绝条件。自动化应记录 automerge_requested_head_sha,观察到请求消失时重新分类并等待人或受信任策略重新启用,绝不能调用 API 无条件补开。
进入队列后,裁决对象从 PR head 变成 merge group SHA。组合检查失败、等待 CI 超时、与 base 冲突、保护规则无法满足或被主动移除时,PR 会离队;机器人只能记录 queue_removal_reason 并按失败类型处置。网络中断或 runner 丢失可有限重试,测试失败、策略拒绝和权限失败不自动重新入队。这样才能避免一个确定性坏补丁通过无限排队消耗容量并掩盖真实拒绝。
用一个本地分类器跑通正向和缺检查反例
下面的 Node.js 脚本不依赖第三方包。它模拟汇总门禁:输入风险、当前 SHA、分类绑定 SHA、实际检查与 owner 批准,输出 ALLOW 或带原因的 BLOCK。在临时目录创建 gate.js:
const fs = require('node:fs');
const input = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const matrix = {
low: ['dependency-policy', 'unit-test', 'lockfile-integrity'],
medium: [
'dependency-policy', 'unit-test', 'integration-test',
'lockfile-integrity', 'dependency-review'
],
high: [
'dependency-policy', 'unit-test', 'integration-test',
'compatibility-test', 'dependency-review', 'migration-safety'
]
};
const required = matrix[input.risk];
if (!required) throw new Error(`unknown risk: ${input.risk}`);
const reasons = [];
if (input.headSha !== input.classifiedHeadSha) {
reasons.push('classification-stale');
}
for (const name of required) {
if (!(name in input.checks)) reasons.push(`check-missing:${name}`);
else if (input.checks[name] !== 'success') {
reasons.push(`check-${input.checks[name]}:${name}`);
}
}
if (input.risk !== 'low' && !input.ownerApproved) {
reasons.push('owner-approval-missing');
}
if (input.risk === 'low' && input.changedFiles.some((path) =>
path.startsWith('.github/workflows/') ||
path.startsWith('db/migrations/') ||
path.startsWith('infra/'))) {
reasons.push('risk-underclassified');
}
const result = reasons.length
? {decision: 'BLOCK', reasons}
: {decision: 'ALLOW', headSha: input.headSha, risk: input.risk};
console.log(JSON.stringify(result));
process.exitCode = reasons.length ? 1 : 0;创建正向输入:
{
"risk": "low",
"headSha": "abc123",
"classifiedHeadSha": "abc123",
"changedFiles": ["package.json", "package-lock.json"],
"checks": {
"dependency-policy": "success",
"unit-test": "success",
"lockfile-integrity": "success"
},
"ownerApproved": false
}保存为 good.json 后运行:
node gate.js good.json预期输出包含 "decision":"ALLOW"、"headSha":"abc123" 和 "risk":"low",退出码为 0。这条结果只说明策略函数接受输入,不声称真实 CI、分支保护或队列已经配置成功。
再创建 missing-check.json,故意移除 lockfile-integrity,并把 workflow 文件伪装成低风险更新:
{
"risk": "low",
"headSha": "def456",
"classifiedHeadSha": "abc123",
"changedFiles": [
"package.json",
"package-lock.json",
".github/workflows/release.yml"
],
"checks": {
"dependency-policy": "success",
"unit-test": "success"
},
"ownerApproved": false
}执行反例:
if node gate.js missing-check.json; then
echo 'FAIL missing check was incorrectly allowed'
exit 1
else
echo 'PASS missing check kept the gate blocked'
fi预期先看到分类器输出 BLOCK,原因同时包含 classification-stale、check-missing:lockfile-integrity 和 risk-underclassified,随后测试封装输出 PASS 并以 0 结束。分类器自身返回非零是反例的预期证据,不代表测试封装失败。如果实现用“所有已出现检查都成功”判断,输入中两个绿色检查会错误放行;修复就是从风险矩阵计算 required set,再与实际 check map 做集合差,并把分类绑定到当前 head SHA。修复后重跑两份输入,必须分别保持 ALLOW 与 BLOCK。
PowerShell 可用 $LASTEXITCODE 验证反例:
node gate.js missing-check.json
if ($LASTEXITCODE -eq 0) { throw 'missing check was incorrectly allowed' }实验结束删除 gate.js、good.json 和 missing-check.json。这些文件只验证门禁算法,不需要进入业务仓库。
把分类器接入 GitHub Actions
自动合并 workflow 应由受信任的基础分支定义,且绝不 checkout 机器人 PR 后执行其中的脚本再持有写权限。对于 Dependabot,可以用 GitHub 官方的 dependabot/fetch-metadata action读取更新类型与依赖范围,然后再叠加 changed-files 分类。GitHub 的官方自动化示例见 Automating Dependabot with GitHub Actions。
下面只为低风险 PR 启用 auto-merge。pull_request_target 具有基础分支上下文,所以 job 不 checkout、不执行 PR 代码;action 在生产中固定完整 SHA:
name: dependency-automerge-request
on:
pull_request_target:
types: [opened, synchronize, reopened, labeled]
permissions:
contents: write
pull-requests: write
jobs:
request-automerge:
if: github.actor == 'dependabot[bot]'
runs-on: ubuntu-latest
steps:
- name: Read Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@<reviewed-full-commit-sha>
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Require an explicit low-risk label and patch update
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
UPDATE_TYPE: ${{ steps.metadata.outputs.update-type }}
LABELS_JSON: ${{ toJSON(github.event.pull_request.labels.*.name) }}
run: |
test "$UPDATE_TYPE" = 'version-update:semver-patch'
printf '%s' "$LABELS_JSON" | jq -e 'index("dependencies:low-risk") != null'
gh pr merge --auto --squash "$PR_URL"这个 workflow 只是“请求平台在所有规则满足后合并”,不是立即合并命令。dependencies:low-risk 必须由受信任分类器写入,不能允许 PR 作者自行贴标签获得权限;保护规则仍必须要求 dependency-policy、测试和 lockfile 检查。若分支要求 Merge Queue,平台应让合格 PR 入队并在 merge group 上重验,而不是让 bot 绕过队列。
Renovate 的 automerge 同样只是策略请求。它默认关闭,而且如果平台没有选定必需检查,可能出现测试失败仍被视为可合并的危险组合;因此 Renovate 配置与平台保护必须一起验收,参见 Renovate automerge。安全起点是仅为已分类低风险的规则启用,且机器人没有 branch protection bypass:
{
"packageRules": [
{
"description": "Only pre-classified patch updates may request automerge",
"matchUpdateTypes": ["patch"],
"matchDepTypes": ["devDependencies"],
"addLabels": ["dependencies:low-risk"],
"automerge": true,
"automergeType": "pr"
}
]
}仅靠 matchUpdateTypes 和 matchDepTypes 不能识别 workflow、原生传递依赖或异常 lockfile diff,所以仓库侧分类器必须有权把风险升级并阻断。配置不要把“不稳定包”“内部运行时代码”伪装成 devDependency 来逃避检查。
Renovate 的 ignoreTests: true 会允许没有测试结果时继续自动合并,automergeType: branch 则会尝试不经 PR 直接更新目标分支;两者都不适合这里的证据模型。启用 Merge Queue 时保持 automergeType: pr,让平台原生自动合并负责排队,并确认仓库已经允许 auto-merge、目标分支要求队列、required checks 同时覆盖 pull_request 与 merge_group。Renovate 文档中关于 approval helper 或 bypass 的做法解决的是“怎样让它合并”,不是“怎样证明它应当合并”;依赖更新身份不应进入 bypass 列表,也不应给自己批准。
在隔离仓库验证真实保护规则
本地分类器通过后,还要用无生产数据的 GitHub 仓库做平台实验。需要 gh 登录、仓库规则管理权限和一个测试 PR。先查看 PR 当前 SHA 与检查:
gh pr view <pr-number> --repo your-org/dependency-lab \
--json headRefOid,mergeStateStatus,reviewDecision,statusCheckRollup
gh pr checks <pr-number> --repo your-org/dependency-lab正向路径是在所有 required checks 对当前 headRefOid 成功后请求 auto-merge;预期 PR 显示已启用自动合并,但只有平台规则满足后才真正合并。反向路径不是去破坏生产规则,而是在测试 PR 的分支提交一个只让 lockfile-integrity 失败的改动,或临时让测试 workflow 对 permission-negative 分支返回非零:
- name: Deliberate negative probe
if: startsWith(github.head_ref, 'permission-negative')
run: |
echo 'intentional required-check failure'
exit 1推送后执行 gh pr checks,应看到该 check 失败,PR 保持不可合并或被移出队列。删除负向提交并重新生成锁文件,再推送新 SHA;旧 SHA 的绿灯不能复用,新 SHA 上所有 required checks 重新通过后才恢复资格。若失败检查仍能合并,立即关闭自动合并,检查 required check 名是否一致、保护规则是否应用到目标分支、bot 是否拥有 bypass,以及 check 是否由预期 App 产生。
测试 Merge Queue 时,确认 Actions 运行记录出现 merge_group 事件,required workflow 在组合 SHA 上执行。若 PR 可入队但一直等待,首查 workflow 是否监听 merge_group、check 名是否稳定、队列超时与 CI 容量;若 PR 入队后立刻被移除,保存 merge group SHA 和失败 check,再重试,不要无上限重新入队。
rebase、重试与并发竞争要有状态
更新分支落后时有三种处理方式。机器人 rebase 会重写 head SHA,所有分类、检查与可能的 review 都要重算;平台 update branch 可能创建 merge commit,若仓库要求线性历史便不适用;Merge Queue 则在临时组合提交上验证。不要同时开启高频机器人 rebase、strict up-to-date 和队列,让每次主分支变化触发多轮重复 CI。
为每个 PR 保存 observed_head_sha、base_sha、classification_revision、attempt 和 last_failure_class。只有基础设施类失败才自动重试,例如 runner 丢失、网络超时、平台限流;测试断言、lockfile diff、owner 拒绝、迁移检查失败不自动重试。重试采用有上限的退避,并在 head SHA 变化时开新 attempt。连续重跑同一确定性失败只会消耗 runner 和 API 配额,还可能掩盖真正积压。
多个依赖 PR 同时修改同一 lockfile 时,逐个 rebase 可能形成“更新风暴”。同一兼容单元应分组;不能分组时按仓库或 lockfile 串行进入队列。队列吞吐看成功合并数与总 CI 分钟的比值,而不是只看等待时间。频繁取消的 merge group 也消耗 runner,应纳入成本。
什么情况下关闭 PR,什么情况下回滚合并
关闭 PR 是对提议的处置,不会撤销已经进入主分支的 commit。以下状态应关闭并让机器人重建,而不是无限 rebase:候选版本被撤回;更新策略已改变;lockfile 差异无法解释;base 发生结构迁移;分组中的依赖不再是同一兼容单元;凭据或生成器版本变化导致 artifact 不可复现。关闭原因写机器可读标签和简短证据,避免机器人立即创建同一失败 PR。
合并后发现问题,优先 revert 合并产生的 commit,并让正常发布链验证回滚;不要直接把 manifest 改回去却保留新 lockfile。若更新同时包含数据库迁移、制品格式或不可逆外部状态,代码 revert 只恢复仓库,不恢复外部状态,必须调用对应迁移的补偿或前向修复流程。自动合并候选不应包含需要不可逆动作的变更,这正是高风险分类的意义。
回滚后给该依赖、版本范围或规则设置有 owner 和复查条件的临时阻断,保存失败环境、head SHA、merge SHA、发布结果和 revert SHA。不要永久 ignore 后失去升级路径;也不要在生产故障尚未定界时让机器人立刻重新创建相同 PR。
敏感数据、CI 权限和 Fork 边界
更新 PR 会执行上游包管理器、构建脚本和测试代码。来自 fork 或机器人分支的 workflow 默认不应获得发布密钥、云凭据或生产网络。需要私有 Registry 才能测试时,使用只读、限定仓库的凭据,并把有写权限的自动合并 job 与执行 PR 代码的测试 job 分开。
pull_request_target 能访问基础分支上下文与 Secret,绝不能 checkout PR head 后执行脚本;上面的自动合并 job只读取事件元数据。普通 pull_request job运行代码时保持 contents: read,不授予 packages/write、deployments/write 或 OIDC 云角色。Dependabot 事件使用 Dependabot Secret 域,不能假设普通 Actions secrets 可用。
检查日志和 artifact 可能包含私有包名、依赖图、内部路径、测试数据和 SBOM。外部观测平台只接收匿名仓库标识、风险类、检查类、耗时和结果;详细依赖 diff 留在仓库权限域。失败重试上传 artifact 前先过滤 npmrc、Maven settings、环境变量和下载 URL。
用容量预算决定自动化强度
每个更新 PR 的成本不是一次 CI,而是 初始检查 + rebase 重验 + merge group 重验 + 发布验证 + 失败重试。风险矩阵越宽,单 PR 证据越强,吞吐与费用也越高。低风险更新可以共享缓存和较窄矩阵,高风险更新运行完整兼容与迁移检查;但缓存键必须包含 lockfile hash、运行时与平台维度,不能让旧依赖缓存制造假绿。
长期观察 PR 到达率、分类分布、每类 CI 分钟、rebase 次数、队列取消率、缺检查阻断数、自动合并后回滚率和人工等待年龄。阈值来自仓库基线、发布 SLO 与成本预算,不写成跨团队通用数字。若积压增长,先判断是依赖到达过快、检查容量不足、owner 无人响应、规则漂移还是确定性失败;直接扩大 auto-merge 范围通常只会把积压变成事故。
长期治理与安全退出
策略文件、CODEOWNERS、workflow 和保护规则都有 owner,并以同一个 policy revision 审计。定期从平台 API 导出实际 required checks、expected App、bypass 列表、Merge Queue 设置和允许的 merge method,与仓库声明比较。检查重命名必须先让新旧名称并行产生结果,更新保护规则,再删除旧名称;直接改 workflow 名会制造永远等待或意外缺门禁。
停用自动合并时,先关闭策略入口和 auto-merge workflow,取消尚未执行的自动合并请求与队列项,保留 PR 供人工审查;再撤销机器人合并相关权限。不要先删 required checks,否则窗口中的 PR 可能在证据变少后突然可合并。退出后用一个故意失败的测试 PR 证明平台仍阻断,再清理临时分支、测试标签、无用 workflow 和分类缓存。
整条链路的运行不变量是:只有当前 head SHA 上、由可信来源产生、与风险矩阵完整匹配的证据,才允许 PR 进入合并动作;rebase、队列组合、策略变化或 diff 扩大都会使旧证据失效。低风险可以少人工等待,但不能少证据;高风险可以进入自动化流水线,但不能自动获得批准。
