一条分支要穿过几道闸门才能回到主干 左边是 PR 合并框的缩影:两项检查通过、一项评审在等、一处冲突,下面是合并按钮。右边是一条从主干分出去的分支,沿途立着三道闸门,最后一道是强调色,穿过之后才汇回主干上的合并点。 ✓ test ✓ lab/outside-ci ● Review required ✗ conflicts Merge pull request mergeable_state: dirty

打开一个 GitHub 上的 PR,往下拉到合并按钮那一块,能看到的提示五花八门:Some checks were not successful、Review required、This branch is out-of-date with the base branch、This branch has conflicts that must be resolved、Merging is blocked,有的仓库还有 Merge when ready、This branch was successfully deployed。每一条后面都能展开出一串小勾小叉。

可是在 twwy.net 这个仓库里,这一块一直很干净。拿最近合并的 PR #157 看,它的 head 提交上一个 check 都没有,commit status 也是 0 条,合并按钮永远是绿的,点下去就合进去了。

于是问题就来了:这些提示是谁放上去的?哪些只是提醒,哪些真能把合并按钮锁住?检查失败了还能不能合?它们之间有没有先后,谁要等谁?本文顺着一个 PR 从打开到合并以后的顺序,一道一道看过去。为了不只照着文档讲,我在 twwy.net 里拉了一个临时分支 lab/pr-gates-base 当作“主干”,放了一个实验用的 workflow,开了四个指向它的测试 PR(#159–#162),文中的提交哈希和日志都出自这次实验。

一、检查从哪里来

现象

测试 PR #159 只改了一个文本文件,以 draft 状态打开。几秒钟后,它的 head 提交 e796c53 上出现了 6 个 check run 和 1 个 commit status。实验 workflow 里一共写了 6 个 job,其中 after-merge 和 on-push 两个因为 if 条件不满足没有运行,但它们照样出现在列表里,结论是 skipped。

原因:两套上报接口

GitHub 上的“检查”其实来自两套接口1。

commit status 是老接口。 任何拿到仓库写权限令牌的系统,都可以对某个提交 POST 一条 {state, context, description},context 就是它在列表里显示的名字。Jenkins、早年的 Travis 和很多部署平台用的都是它。实验里我用 curl 模拟了一个外部 CI,发了一条 lab/outside-ci。

check run 是新接口。 只有 GitHub App 能写,能带更丰富的输出(摘要、注解、重跑按钮)。GitHub Actions 本身就是一个 App,workflow 里每个 job 都会生成一个 check run,名字就是 job 名,归在同一个 check suite 下。

两套接口有一个共同点:结果都挂在提交上,而不是挂在 PR 上。 PR 页面只是去找“我的 head 提交上有哪些检查”,然后汇总显示。所以同一个提交出现在两个 PR 里,两个 PR 会看到同一组检查。

检查的两种来源,都挂在 head 提交上 左边是三个报告方:Actions 的 test job 和 preview job 通过 Checks API 生成 check run,模拟的外部 CI 通过 Statuses API 发 commit status。中间是 PR #159 的 head 提交 e796c53,上面有 6 个 check run(其中 2 个 skipped)和 1 个 status。右边是 PR 本身,只记录 head.sha 并读取这些结果;仓库没有规则时,它们只是展示。 报告方 结果挂在哪 · PR 的 head 提交 PR #159 读到的 Actions · job test Checks API → check run Actions · job preview check run + 部署记录 外部 CI(curl 模拟) Statuses API → status Actions:每个 job 一个 check run 外部系统:多半发 commit status commit e796c53 infosuccess testsuccess previewsuccess external-statussuccess after-mergeskipped on-pushskipped lab/outside-cistatus 前 6 行是 check run,最后一行是 status skipped 的 job 也会留下一条记录 PR #159 head.shae796c53 drafttrue mergeable_stateclean PR 只记住 head 提交, 再去提交上读检查结果。 没有任何规则时, 这些结果只是展示。
图 1 · 检查有两种来源,但结果都挂在提交上,PR 只是读取并汇总

既然检查只是挂在提交上的一组报告,那检查失败了,PR 还能不能合并?

二、检查为什么拦不住合并

现象

测试 PR #160 往分支里加了一个叫 FAIL 的文件,实验 workflow 的 test job 看到它就退出 1。检查变红以后,API 里这个 PR 的 mergeable_state 是 unstable。然后我直接调用合并接口,返回 Pull Request successfully merged,合并提交 77f0799 进了 lab/pr-gates-base。

原因:报告和关卡是两回事

unstable 的意思是“有检查没通过,但它们都不是必需的”2。GitHub 默认把所有检查都当作参考信息,合并框会把失败显示成红叉,按钮却照样能点。twwy.net 的 PR 一直能直接合并,原因也在这里:仓库里没有任何规则,检查本来也一条都没有。

要让检查真正拦住合并,得在分支规则里把它列为必需检查(required status checks)。分支规则有两种写法,分支保护规则和 rulesets,第六节再细说它们的区别,这里只看“必需检查”这一条开关怎么工作3。

按名字匹配。 规则里填的是检查的名字,比如 test。GitHub 拿这个名字去 head 提交上找同名的 check run 或 commit status。如果同名的 check 和 status 都存在,两个都得通过4。还可以把某个名字钉到指定的 App 上,这样别的来源发来的同名状态不算数,免得谁随手 curl 一条 success 就过关。

必须在最新提交上。 旧提交上的绿色不算。每推一次新提交,必需检查都要在新提交上重新报告。

没报告就一直等。 这一条最容易踩坑。如果 workflow 写了 paths: 过滤,这次改动没碰到那些路径,整个 workflow 就不会运行,对应的必需检查就一直显示 Waiting for status to be reported,PR 永远合不了。反过来,workflow 运行了、但某个 job 被 if 条件跳过,这个 job 的结论是 skipped,GitHub 按通过处理。图 1 里的 after-merge 和 on-push 就是这种情况。

下表把同一组检查结果在“没有规则”和“列为必需”两种情况下的结果放在一起。

同一个检查状态,有没有规则结果不同
head 提交上 test 的状态没有规则(twwy.net 现状)规则要求 test 必须通过
success可合并(clean)可合并
failure红叉,但可合并(unstable,#160 实测)不可合并(blocked)
还在跑可合并(unstable)不可合并,等结果
job 被 if 跳过(skipped)可合并算通过
workflow 被路径过滤,没运行可合并一直等待,永远合不了
只在旧提交上通过可合并不算数,要在新提交上重跑

“可合并”指检查这一项不拦;评审、冲突等其他条件另算。

图 2 · 检查本身只是报告,只有被规则点名的检查才会锁住合并按钮

规则里点名的是“head 提交上的检查”。可是检查跑的时候,跑的真是 head 提交吗?

三、检查跑的是哪个提交

现象

#159 的 info job 打印了几个环境变量:

event=pull_request action=opened
GITHUB_SHA=bef7b38723f09a4ef78fb74d9845f29b0ece00a5
GITHUB_REF=refs/pull/159/merge
pr_head_sha=e796c534c1291c1193570d0a3a00f7ddfbb4d347

GITHUB_SHA 不是 head 提交 e796c53。test job 里 actions/checkout 拉下来的提交是:

bef7b38 Merge e796c534c1291c1193570d0a3a00f7ddfbb4d347 into 236b111289f78077360070b6d102c59b5787202c

原因:测试合并提交

PR 一打开,GitHub 就在后台把 head 合进 base 试一次,生成一个测试合并提交,放在隐藏的引用 refs/pull/N/merge 上,同时用 refs/pull/N/head 指向 head 提交。pull_request 事件触发的 workflow 跑的是这个测试合并提交5,测的是“如果现在合并,主干会变成什么样”,而不只是分支本身。跑完以后,结果又挂回 head 提交,也就是图 1 里那样。

这也解释了为什么 PR 里改 workflow 文件会立刻生效:测试 PR #162 把 workflow 里的 WORKFLOW_VERSION=v1-base 改成了 v2-head,它的 info job 打印出来的就是 v2-head,因为 workflow 文件也是从测试合并提交里读的。

冲突时会怎样

测试合并提交需要能合并才生成得出来。测试 PR #161 新建了 lab-shared.txt,开 PR 时检查正常跑完。随后我在“主干”上也新建了一个内容不同的 lab-shared.txt(bb3a2c9),再往 #161 推了一个新提交 2035e4d。结果:

  • 这次推送没有触发任何 workflow,新 head 提交上 check run 是 0 个;
  • mergeable_state 先是 unknown,几秒后变成 dirty。

文档里写得很明白:PR 有合并冲突时,pull_request 事件不会运行 workflow5。没有测试合并提交,也就没有东西可测。冲突是唯一一个不需要任何规则、永远拦住合并按钮的卡点,而且它还会让检查停下来,如果规则里配了必需检查,合并框会同时显示“有冲突”和“检查在等待”。解决冲突、推上新提交以后,检查才会重新开始。

检查跑在测试合并提交上,冲突时它生成不出来 左边是 #159:主干提交 236b111,PR 的 head 提交 e796c53,GitHub 把两者合成测试合并提交 bef7b38,放在 refs/pull/159/merge 上,workflow 在它上面跑,结果挂回 e796c53。右边是 #161:主干前进到 bb3a2c9,与 head 提交 2035e4d 冲突,测试合并提交生成不出来,pull_request 事件不运行任何 workflow。 #159 · 能合并 #161 · 有冲突 236b111 主干 lab/pr-gates-base e796c53 refs/pull/159/head bef7b38 refs/pull/159/merge 虚线框:GitHub 算出来的测试合并 提交,workflow 在它上面跑 236b111 bb3a2c9 主干也新建了 lab-shared.txt d3705a7 2035e4d ✗ 合并不出来:没有测试合并提交, 推 2035e4d 后一个 workflow 都没跑 mergeable_state: dirty
图 3 · 检查测的是“合并后的样子”;合并不出来,检查就不会开始

测试合并提交是拿“当时的”主干算的。如果检查跑完以后主干又往前走了,这个绿色还作数吗?

四、主干前进以后

现象

这是实验里最有意思的一段。时间线如下:

  1. 01:24:31,#159 以 draft 打开,检查在 bef7b38(e796c53 合进 236b111)上通过。
  2. 01:25:21,带 FAIL 文件的 #160 合进了主干,主干变成 77f0799。
  3. 01:25:31,把 #159 转为 ready for review。这触发了一次新运行,但日志里的 GITHUB_SHA 仍然是 bef7b38,检查又通过了一次。
  4. 01:26:00,往 #159 推了一个新提交 8be4454。这次 checkout 出来的是 aeaa0d5 Merge 8be4454 into 77f0799,里面带着 #160 合进来的 FAIL 文件,test 失败。

#159 自己什么都没做错,它的改动只是一个文本文件。但在第 2 步到第 4 步之间,它挂着一个绿色的检查结果,而那个结果测的是一个已经不存在的主干。如果这时有人点了合并,主干上就会出现一个本该被检查拦下的问题。第 3 步还说明,测试合并提交是后台异步重算的,事件到来时不一定已经更新(这是根据日志推断的,文档没有写重算的时机)。

主干前进后,#159 的绿色结果过期了 时间从左到右:01:24:31 #159 打开,检查在 bef7b38 上通过;01:25:21 #160 合入,主干变成 77f0799;01:25:31 #159 转为 ready,检查仍在旧的 bef7b38 上通过;01:26:00 #159 推新提交,测试合并提交变成 aeaa0d5,基于 77f0799,检查失败。 01:24:31 01:25:21 01:25:31 01:26:00 主干 #159 的动作 测试结果 236b111 77f0799 #160(带 FAIL)合入 opened · e796c53 ready_for_review push · 8be4454 bef7b38 ✓ 还是 bef7b38 ✓ aeaa0d5 ✗ 虚线框:基于已过时的主干
图 4 · 绿色结果只对算它时的主干有效,主干一动它就可能过期

第一次尝试:要求分支与主干同步

必需检查的规则里有一个勾选项 Require branches to be up to date before merging,也就是文档里说的 strict 模式3。开了它,只要 PR 的分支落后于主干,合并框就显示 This branch is out-of-date with the base branch,状态是 behind,必须先点 Update branch,把主干合进分支(或 rebase),检查在新的组合上重跑,通过后才能合。

这能保证合进去的每一个提交都在“当时的主干”上测过。代价是串行化:主干每合进一个 PR,其他所有打开的 PR 都会变成 behind,都要更新、重跑、再等。PR 一多,大家就在抢“谁先跑完谁先合”,跑完一轮发现又落后了。

解决:合并队列

合并队列(merge queue)把“更新分支、重跑、合并”这件事从作者手里接了过去6。作者点的不再是 Merge,而是 Merge when ready,把 PR 放进队列。GitHub 依次为队列里的 PR 生成临时分支 gh-readonly-queue/main/...:第一个是“主干 + #1”,第二个是“主干 + #1 + #2”,以此类推,每个都触发 merge_group 事件跑检查。某一组通过了,它和它前面的 PR 一起合进主干;某一组失败了,对应的 PR 被踢出队列,排在后面的组去掉它重建。

合并队列为每个排队的 PR 测试“主干加前面所有 PR” 队列里有 #101、#102、#103 三个 PR。GitHub 生成三个临时分支:main 加 #101,main 加 #101 和 #102,main 加三个。第二组检查失败,#102 被移出队列,第三组去掉 #102 重建为 main 加 #101 和 #103 再测。 示意 · gh-readonly-queue/main/…(每行触发一次 merge_group) pr-101-… pr-102-… pr-103-… 重建 pr-103 main #101 ✓ 合入 main #101 #102 ✗ 移出 main #101 #102 #103 作废 main #101 #103 ✓ 合入 虚线:包含失败 PR 的组,结果作废
图 5 · 合并队列把“与主干同步再测”变成后台流水线,作者不用自己追着主干更新

合并队列有两个容易忽略的前提。第一,workflow 必须监听 merge_group 事件,否则队列里的临时分支上永远等不到必需检查,最后超时出队;外部 CI 则要对 gh-readonly-queue/ 前缀的分支推送做出响应。第二,它的可用范围比较窄:组织所有的公开仓库,或者 Enterprise Cloud 组织的私有仓库7。twwy.net 是个人账户下的私有仓库,用不了。

队列之外还有一个轻量的帮手,auto-merge:在 PR 上点 Enable auto-merge,等必需评审和检查都满足了自动合并8。它不会帮你更新分支,也不做组合测试,只是“条件满足时替你点按钮”。仓库要先在设置里打开 Allow auto-merge;我在实验里对 #159 调用开启接口,得到的是 Auto-merge is not enabled for this repository。

到这里,机器的检查讲完了。合并框里另一大类提示来自人:draft、评审请求、CODEOWNERS,它们又拦什么?

五、人的关卡:draft、评审与 CODEOWNERS

draft:不能合并,但检查照跑

很多人以为 draft PR 不会跑 CI,实验里并不是这样:#159 以 draft 打开,pull_request 事件的 action 是 opened,日志里 pr_draft=true,6 个 job 一个不少。转成 ready for review 时会再来一个 ready_for_review 事件,不过 pull_request 默认只监听 opened、synchronize、reopened 三种5,想在转 ready 时再跑一遍要在 types 里自己加上(实验 workflow 加了)。想让 draft 不跑,得在 job 上写 if: github.event.pull_request.draft == false。

draft 真正拦住的是两件事9:它不能合并,这一条不需要任何规则;它不会自动请求 code owner 评审,转 ready 时才发出请求。一个小细节:REST API 对 draft 状态的 #159 返回的 mergeable_state 是 clean 而不是 draft,判断能不能合并时要单独看 draft 字段。

评审:要几个人、要谁、什么时候作废

评审本身和检查一样,没有规则时只是展示:有人点了 Request changes,合并按钮照样能点。要让评审变成关卡,在分支规则里打开 Require a pull request before merging 和需要的批准人数3。它下面还有几个子开关,各自堵一个漏洞:

评审相关的规则开关,各堵哪个漏洞
开关不开会怎样开了以后
Required approvals(N 个)没人看也能合至少 N 个有写权限的人批准
Dismiss stale approvals批准后再推新代码,旧批准仍然有效改动后旧批准作废,要重新批准
Require review from Code OwnersCODEOWNERS 只负责发评审请求改到谁的文件,必须有对应 owner 批准
Require approval of the most recent push作者推完最后一下自己就能凑齐最后一次推送必须由推送者以外的人批准
Require conversation resolution评审意见没处理也能合所有评审对话标记为已解决才能合

管理员默认可以绕过,除非同时勾上 Do not allow bypassing the above settings。

图 6 · 评审规则的每个子开关都对应一种“批准了但其实没人看过最终代码”的情况

CODEOWNERS:先发请求,规则让它变成必需

CODEOWNERS 是放在 .github/、根目录或 docs/ 下的一个文件,按 gitignore 的写法把路径对应到人或团队,最后一条匹配的规则生效10。它本身只做一件事:PR 改到某些文件时,自动请求对应的人评审。只有在规则里打开了 Require review from Code Owners,这个请求才变成必须拿到的批准。

还有两点和前面的机制对得上。第一,GitHub 用的是 base 分支上的 CODEOWNERS,PR 里改 CODEOWNERS 不会影响这个 PR 自己的评审人,这和第三节 workflow 文件取自测试合并提交正好相反。第二,owner 必须对仓库有写权限,PR 作者本人不会被请求评审自己的 PR。实验里的 CODEOWNERS 把所有文件都给了 0cloud-ai,而测试 PR 的作者也是它,所以四个 PR 都没有出现评审请求。

前面反复提到“分支规则”。这些规则是写在哪里的,几套规则同时存在时听谁的?

六、规则从哪里来:分支保护与 rulesets

GitHub 有两种配置分支规则的地方,能设的项目大体相同,区别在组织方式11。

分支保护规则(branch protection rule) 是老办法:在设置里按分支名模式(如 main、release/*)建一条规则,把上面几节提到的开关勾上。一个分支同一时间只受一条分支保护规则管,多条模式都匹配时只取其中一条。

rulesets 是新办法:同一个分支可以同时被多个 ruleset 命中,它们没有优先级,而是叠加,同一条规则出现不同取值时取最严格的那个;它们也和分支保护规则叠加。ruleset 有启用状态,可以先停用而不删除;可以设置 bypass 列表,让某些角色、团队或 App 绕过;任何有读权限的人都能看到生效中的 ruleset,不用猜“为什么我合不了”。除了分支,ruleset 还能管标签,以及推送本身(比如禁止提交某些路径、超过某个大小的文件)。

多套规则叠加,同一项取最严格的 左边三个来源:分支保护规则要求 2 个批准和线性历史;ruleset A 要求 3 个批准和签名提交;ruleset B 已停用,要求 5 个批准。右边是 main 上实际生效的规则:3 个批准、签名提交、线性历史,停用的 ruleset B 不参与。 作用于 main 的规则来源(文档示例) main 上实际生效 分支保护规则 · main approvals: 2 · linear history ruleset A · Active approvals: 3 · signed commits ruleset B · Disabled approvals: 5 合并 main 需要 approvals3 signed commitsrequired linear historyrequired 虚线:停用的 ruleset 不参与叠加 同一项取最严格的:2 和 3 取 3
图 7 · 规则来源可以有多个,GitHub 把它们并起来,每一项取最严格的值

谁能用。 分支保护和 rulesets 在免费计划下只能用于公开仓库,私有仓库需要个人 Pro 或组织 Team 及以上(rulesets 文档的“谁能用”一栏写的是 Team 和 Enterprise,个人 Pro 的私有仓库能否用 rulesets,我没有逐项核对)311。合并队列的门槛更高,见上一节。twwy.net 是私有仓库,目前一条规则也没有配,所以第二节里 #160 检查失败也能合。

规则管的是“合并之前”。合并框里还会出现部署的状态,合并之后也还有工作流在跑。这些和合并是什么先后关系?

七、合并前后的部署与工作流

environment:拦的是部署,不是合并

workflow 的 job 上写一行 environment: lab-preview,这个 job 就成了一次“部署”:PR 页面会出现部署记录,job 能读取这个环境专属的 secret。环境不存在时,第一次运行会自动创建它12。实验里的 preview job 就是这样,没有配任何保护规则,所以它立刻运行了。

给环境加上保护规则以后(必需审批人、等待计时、只允许某些分支部署、App 提供的自定义规则),被卡住的是这个 job:它停在 waiting 状态,规则通过之前不开始,也拿不到环境的 secret。这和合并按钮没有直接关系。要让“部署成功”成为合并前提,得在分支规则里另开 Require deployments to succeed before merging,指定哪些环境必须部署成功3。常见的做法是:PR 阶段部署到 preview 或 staging,把 staging 列为合并前提;合并以后再部署 production,production 环境上挂人工审批。

合并之后:push 与 closed

合并本身也是一个事件源。#160 在 01:25:21 合入,同一秒触发了两次运行:

  • push:主干 lab/pr-gates-base 收到合并提交 77f0799,监听这个分支 push 的 workflow 运行,GITHUB_REF=refs/heads/lab/pr-gates-base。
  • pull_request 的 closed:PR 被关闭,github.event.pull_request.merged 为 true。实验里的 after-merge job 打印出 merge_commit_sha=77f0799、GITHUB_SHA=77f0799。只关闭不合并也会来 closed,所以要用 merged 判断5。

两者的区别在于前者只知道“主干变了”,后者知道“是哪个 PR、谁合的、带什么标签”。部署通常挂在 push 上,PR 相关的善后(发通知、删预览环境、打版本标签)挂在 closed 上。

pull_request_target:用的是主干上的 workflow

还有一个名字很像的事件 pull_request_target。它在 base 仓库默认分支的上下文中运行5,用的是默认分支上的 workflow 文件,带有可写的令牌和 secret,所以常用来给 fork 来的 PR 打标签、发评论。实验 workflow 里也监听了它,但四个测试 PR 一次都没有触发:这个 workflow 只存在于 lab/pr-gates-base,默认分支 main 上没有。对照 #162 里 pull_request 立刻用上了 PR 自己改过的 workflow,就能看出两者为什么安全性完全不同:pull_request_target 不让 PR 改写要执行的流程,代价是它也不会自动 checkout PR 的代码,自己 checkout 时就要小心运行了不可信的代码。另外,冲突的 PR 不会触发 pull_request,却仍然会触发 pull_request_target。

八、全景:一个 PR 依次经过的卡点

把前面几节按时间顺序排起来,就是一个 PR 从打开到合并以后会遇到的全部关卡。左列是 PR 经历的阶段,中间是这一步 GitHub 自动做的事,右列是卡点,以及是谁让它成为卡点的。强调色标出的两个卡点不需要任何规则,永远生效;其余的都要在分支规则里打开。

一个 PR 从打开到合并以后的卡点全景 按时间顺序八个阶段:打开 draft、转 ready、每次推送、评审、主干前进、进合并队列、合并、合并之后。每个阶段列出 GitHub 自动做的事和卡点。draft 不能合并、冲突不能合并这两条永远生效;必需检查、必需评审、与主干同步、合并队列、线性历史、部署成功等需要规则开启;环境保护规则拦的是部署 job 而不是合并。 阶段 GitHub 自动做的事 卡点 · 谁让它生效 打开(draft) 转 ready 每次推送 评审 主干前进 进合并队列 合并 合并之后 pull_request 跑在 refs/pull/N/merge 不请求 code owner ready_for_review 事件 按 base 上的 CODEOWNERS 请求评审 synchronize:重算测试合并,重跑检查 有冲突则不跑 Approve / Request changes 新推送可让旧批准作废 旧的绿色结果过期 PR 显示 behind(开了同步要求时) gh-readonly-queue/ 临时分支 merge_group 事件重跑检查 生成合并提交(或 squash / rebase) 默认分支收到 push push、pull_request closed + merged 部署 job 等 environment 规则 draft 不能合并 永远生效 — 评审请求不等于必须批准 冲突 · 必需检查 冲突永远生效;检查要规则点名 批准数 · Code Owner 规则:Require a pull request 与主干同步 规则:up to date(strict) 队列里的必需检查 规则:Require merge queue 线性历史 · 签名 · 部署成功 规则;管理员可否绕过也是规则 环境保护规则 拦部署 job,不拦合并 强调色:不需要任何规则就会拦住合并;其余都要在分支保护或 ruleset 里打开。
图 8 · 合并按钮能不能点,取决于两条永远生效的卡点,加上规则点名的那些

回到 twwy.net

现在回头看开头那个问题:twwy.net 的 PR 为什么永远是一个绿色按钮?因为三件事同时成立。仓库没有 workflow,也没有接外部 CI,所以 head 提交上没有任何检查;仓库没有分支保护规则或 ruleset,所以评审、同步、部署这些开关一个都没打开;PR 都不是 draft,也没有和主干冲突。剩下能拦住它的两条永远生效的卡点,一条都没碰上。

这对一个人写的博客大多数时候没问题,但第四节那种“各自是绿的、合在一起是红的”情况,这个仓库真的遇到过:PR #136 新增文章时还没有中图法类号的要求,#137 加上了这条构建校验,两个 PR 各自都能构建,先后合并以后 main 构建失败,后来用 #138 补上。如果想让主干“合进去就一定能构建”,最小的配置是:加一个跑 npm run build 的 workflow,在规则里把它列为必需检查,并勾上与主干同步。如果账户是免费计划,这还需要仓库改为公开,或者升级到 Pro。

小结

  1. 检查从哪里来? check run(GitHub App,Actions 每个 job 一个)和 commit status(任何持令牌的系统),结果都挂在 head 提交上,PR 只是读取汇总。
  2. 检查失败为什么还能合? 没有规则时检查只是报告,状态是 unstable。只有分支规则按名字点名的必需检查才拦合并;被路径过滤没运行的 workflow 会让必需检查永远等待。
  3. 检查跑的是哪个提交? pull_request 跑在 refs/pull/N/merge 这个测试合并提交上,测的是合并后的样子;有冲突时生成不出来,workflow 也就不跑。
  4. 主干前进后旧结果还算吗? 不一定算。实验里 #159 的检查在旧主干上是绿的,主干合进 #160 后同一个改动变红。要求与主干同步能堵住,但会串行化;合并队列在后台为“主干加前面所有 PR”逐组测试,解决排队。
  5. 人的关卡拦什么? draft 永远不能合并,但检查照跑;评审和 CODEOWNERS 只有规则打开后才是必需的,几个子开关分别防止批准过期、作者自批、owner 缺席。
  6. 规则写在哪里? 分支保护规则一个分支只生效一条;rulesets 可以多条叠加,取最严格的值。私有仓库需要付费计划。
  7. 合并前后的部署和工作流? environment 规则拦的是部署 job,要让部署成为合并前提需另开规则;合并后有 push 和 pull_request closed + merged 两个入口;pull_request_target 用默认分支上的 workflow。

如果想自己复现,实验 workflow 很短:在一个临时分支上放下面这个文件,开几个指向它的 PR,分别让检查失败、制造冲突、在 PR 里改 workflow,再看 Actions 页面和合并框的变化。

YAML
name: lab-gates
on:
pull_request:
branches: [lab/pr-gates-base]
types: [opened, synchronize, reopened, ready_for_review, closed]
pull_request_target:
branches: [lab/pr-gates-base]
push:
branches: [lab/pr-gates-base]
permissions:
contents: read
statuses: write
jobs:
info:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
steps:
- run: |
echo "event=${{ github.event_name }} action=${{ github.event.action }}"
echo "GITHUB_SHA=$GITHUB_SHA GITHUB_REF=$GITHUB_REF"
echo "pr_head_sha=${{ github.event.pull_request.head.sha }}"
test:
if: github.event_name == 'pull_request' && github.event.action != 'closed'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: git log --oneline -1 && test ! -f FAIL
after-merge:
if: github.event.action == 'closed' && github.event.pull_request.merged
runs-on: ubuntu-latest
steps:
- run: echo "merged at ${{ github.event.pull_request.merge_commit_sha }}"

参考资料

Footnotes

  1. About status checksGitHub Docsdocs.github.com ↗检查分两种:commit status(Statuses API)和 check run(Checks API,只有 GitHub App 能写,Actions 就是这样上报的)。https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collecting-information-about-changes-in-a-project/about-status-checks ↩

  2. GraphQL enum MergeStateStatusGitHub Docsdocs.github.com ↗BEHIND、BLOCKED、CLEAN、DIRTY、DRAFT、HAS_HOOKS、UNKNOWN、UNSTABLE;REST 的 mergeable_state 是同一组值的小写。https://docs.github.com/en/graphql/reference/enums#mergestatestatus ↩

  3. About protected branchesGitHub Docsdocs.github.com ↗必需评审、必需检查(strict 要求与 base 同步)、会话解决、签名提交、线性历史、合并队列、部署成功、锁定分支、不许绕过等全部开关。https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches ↩ ↩2 ↩3 ↩4 ↩5

  4. Troubleshooting required status checksGitHub Docsdocs.github.com ↗被路径或分支过滤掉的 workflow,对应的必需检查一直 Pending;同名的 check 和 status 都要通过;必需检查要在最新提交上通过;if 跳过的 job 算 Success。https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/troubleshooting-required-status-checks ↩

  5. Events that trigger workflowsGitHub Docsdocs.github.com ↗pull_request 默认类型 opened、synchronize、reopened;GITHUB_REF 为 refs/pull/N/merge;PR 有冲突时 pull_request 不运行;pull_request_target 在默认分支的上下文运行;合并后用 closed + merged 判断。https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows ↩ ↩2 ↩3 ↩4 ↩5

  6. Managing a merge queueGitHub Docsdocs.github.com ↗入队的 PR 和最新 base 及前面排队的 PR 合在一起,推到 gh-readonly-queue/ 临时分支测试;workflow 必须监听 merge_group;失败、超时、冲突就出队,后面的重建。https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue ↩

  7. Pull request merge queue is now generally availableGitHub Changelog · 2023-07-12github.blog ↗可用范围:组织所有的公开仓库,以及 Enterprise Cloud 组织的私有仓库。https://github.blog/changelog/2023-07-12-pull-request-merge-queue-is-now-generally-available/ ↩

  8. Automatically merging a pull requestGitHub Docsdocs.github.com ↗仓库先开 Allow auto-merge;只在 PR 不能立即合并时出现;等必需评审和检查都通过后自动合并。https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/automatically-merging-a-pull-request ↩

  9. About pull requests(Draft pull requests 一节)GitHub Docsdocs.github.com ↗Draft pull requests cannot be merged, and code owners are not automatically requested to review them.https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests ↩

  10. About code ownersGitHub Docsdocs.github.com ↗用 base 分支上的 CODEOWNERS;最后匹配的模式优先;draft 不请求 code owner;只有开了 Require review from Code Owners 才是必需的。https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners ↩

  11. About rulesetsGitHub Docsdocs.github.com ↗多个 ruleset 可同时作用于同一分支,没有优先级,同一条规则取最严格的;和分支保护规则叠加;可设 bypass 列表和启用状态。https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets ↩ ↩2

  12. Managing environments for deploymentGitHub Docsdocs.github.com ↗必需审批人(最多 6 个)、禁止自审、等待计时、限定分支、App 自定义规则;job 引用不存在的环境会自动创建;规则通过前 job 不开始,也拿不到环境 secret。https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-deployments/managing-environments-for-deployment ↩