﻿---
title: "PR 合并前卡在哪：检查、评审、分支规则和合并队列的关系"
description: "一个 PR 的合并框里会出现检查、评审、冲突、过期、部署等各种提示。顺着一个 PR 从打开到合并后的顺序往下追：检查从哪来、为什么失败了还能合并，检查跑的是哪个提交，主干前进后旧的绿色为什么不算数，合并队列怎么解决排队，draft、评审和 CODEOWNERS 各拦什么，分支保护和 rulesets 怎么叠加，合并前后的部署和工作流又在什么时候触发。文中的现象都在 twwy.net 仓库的临时分支上实测过。"
pubDate: 2026-10-28
tags: ["GitHub", "Pull Request", "CI", "GitHub Actions"]
hero: "./hero.svg"
clc: "TP311.5"
refs:
  - id: status-checks
    title: "About status checks"
    url: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collecting-information-about-changes-in-a-project/about-status-checks
    site: GitHub Docs
    note: "检查分两种：commit status（Statuses API）和 check run（Checks API，只有 GitHub App 能写，Actions 就是这样上报的）。"
  - id: events
    title: "Events that trigger workflows"
    url: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
    site: GitHub Docs
    note: "`pull_request` 默认类型 opened、synchronize、reopened；GITHUB_REF 为 `refs/pull/N/merge`；PR 有冲突时 `pull_request` 不运行；`pull_request_target` 在默认分支的上下文运行；合并后用 closed + `merged` 判断。"
  - id: protected
    title: "About protected branches"
    url: https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches
    site: GitHub Docs
    note: "必需评审、必需检查（strict 要求与 base 同步）、会话解决、签名提交、线性历史、合并队列、部署成功、锁定分支、不许绕过等全部开关。"
  - id: troubleshoot
    title: "Troubleshooting required status checks"
    url: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/troubleshooting-required-status-checks
    site: GitHub Docs
    note: "被路径或分支过滤掉的 workflow，对应的必需检查一直 Pending；同名的 check 和 status 都要通过；必需检查要在最新提交上通过；`if` 跳过的 job 算 Success。"
  - id: rulesets
    title: "About rulesets"
    url: https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets
    site: GitHub Docs
    note: "多个 ruleset 可同时作用于同一分支，没有优先级，同一条规则取最严格的；和分支保护规则叠加；可设 bypass 列表和启用状态。"
  - id: merge-queue
    title: "Managing a merge queue"
    url: https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue
    site: GitHub Docs
    note: "入队的 PR 和最新 base 及前面排队的 PR 合在一起，推到 `gh-readonly-queue/` 临时分支测试；workflow 必须监听 `merge_group`；失败、超时、冲突就出队，后面的重建。"
  - id: mq-ga
    title: "Pull request merge queue is now generally available"
    url: https://github.blog/changelog/2023-07-12-pull-request-merge-queue-is-now-generally-available/
    site: GitHub Changelog
    date: 2023-07-12
    note: "可用范围：组织所有的公开仓库，以及 Enterprise Cloud 组织的私有仓库。"
  - id: codeowners
    title: "About code owners"
    url: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
    site: GitHub Docs
    note: "用 base 分支上的 CODEOWNERS；最后匹配的模式优先；draft 不请求 code owner；只有开了 Require review from Code Owners 才是必需的。"
  - id: draft
    title: "About pull requests（Draft pull requests 一节）"
    url: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests
    site: GitHub Docs
    note: "Draft pull requests cannot be merged, and code owners are not automatically requested to review them."
  - id: auto-merge
    title: "Automatically merging a pull request"
    url: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/automatically-merging-a-pull-request
    site: GitHub Docs
    note: "仓库先开 Allow auto-merge；只在 PR 不能立即合并时出现；等必需评审和检查都通过后自动合并。"
  - id: environments
    title: "Managing environments for deployment"
    url: https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-deployments/managing-environments-for-deployment
    site: GitHub Docs
    note: "必需审批人（最多 6 个）、禁止自审、等待计时、限定分支、App 自定义规则；job 引用不存在的环境会自动创建；规则通过前 job 不开始，也拿不到环境 secret。"
  - id: merge-state
    title: "GraphQL enum MergeStateStatus"
    url: https://docs.github.com/en/graphql/reference/enums#mergestatestatus
    site: GitHub Docs
    note: "BEHIND、BLOCKED、CLEAN、DIRTY、DRAFT、HAS_HOOKS、UNKNOWN、UNSTABLE；REST 的 `mergeable_state` 是同一组值的小写。"
---

打开一个 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 上的“检查”其实来自两套接口[^status-checks]。

**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 会看到同一组检查。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="prg-src-title prg-src-desc">
<title id="prg-src-title">检查的两种来源，都挂在 head 提交上</title>
<desc id="prg-src-desc">左边是三个报告方：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 并读取这些结果；仓库没有规则时，它们只是展示。</desc>
<defs>
<marker id="prg-src-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="prg-src-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="380" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="232" height="348" rx="8"/>
<rect class="dd-zone" x="264" y="16" width="272" height="348" rx="8"/>
<rect class="dd-zone" x="552" y="16" width="232" height="348" rx="8"/>
<text class="t-zone" x="32" y="44">报告方</text>
<text class="t-zone" x="280" y="44">结果挂在哪 · PR 的 head 提交</text>
<text class="t-zone" x="568" y="44">PR #159 读到的</text>
<path class="dd-edge" d="M232 94 H248 Q256 94 256 102 V127 Q256 135 264 135 H272" marker-end="url(#prg-src-arrow)"/>
<path class="dd-edge" d="M232 170 H248 Q256 170 256 166 Q256 161 264 161 H272" marker-end="url(#prg-src-arrow)"/>
<path class="dd-edge" d="M232 246 H248 Q256 246 256 254 V257 Q256 265 264 265 H272" marker-end="url(#prg-src-arrow)"/>
<path class="dd-edge-acc" d="M520 80 H536 Q544 80 544 88 V101 Q544 109 552 109 H560" marker-end="url(#prg-src-arrow-acc)"/>
<rect class="dd-card" x="32" y="64" width="200" height="60" rx="6"/>
<text class="t-name" x="48" y="88">Actions · job test</text>
<text class="t-mono" x="48" y="112">Checks API → check run</text>
<rect class="dd-card" x="32" y="140" width="200" height="60" rx="6"/>
<text class="t-name" x="48" y="164">Actions · job preview</text>
<text class="t-mono" x="48" y="188">check run + 部署记录</text>
<rect class="dd-card" x="32" y="216" width="200" height="60" rx="6"/>
<text class="t-name" x="48" y="240">外部 CI（curl 模拟）</text>
<text class="t-mono" x="48" y="264">Statuses API → status</text>
<text class="t-say" x="32" y="312">Actions：每个 job 一个 check run</text>
<text class="t-say" x="32" y="334">外部系统：多半发 commit status</text>
<rect class="dd-card" x="280" y="64" width="240" height="218" rx="6"/>
<path class="dd-card-head" d="M286 64 H514 Q520 64 520 70 V96 H280 V70 Q280 64 286 64 Z"/>
<text class="t-name" x="296" y="85">commit</text>
<text class="t-mono" x="504" y="85" text-anchor="end"><tspan class="t-acc">e796c53</tspan></text>
<text class="t-mono" x="296" y="113">info</text><text class="t-val" x="504" y="113" text-anchor="end">success</text>
<line class="dd-hair" x1="280" y1="122" x2="520" y2="122"/>
<text class="t-mono" x="296" y="139">test</text><text class="t-val" x="504" y="139" text-anchor="end">success</text>
<line class="dd-hair" x1="280" y1="148" x2="520" y2="148"/>
<text class="t-mono" x="296" y="165">preview</text><text class="t-val" x="504" y="165" text-anchor="end">success</text>
<line class="dd-hair" x1="280" y1="174" x2="520" y2="174"/>
<text class="t-mono" x="296" y="191">external-status</text><text class="t-val" x="504" y="191" text-anchor="end">success</text>
<line class="dd-hair" x1="280" y1="200" x2="520" y2="200"/>
<text class="t-mono" x="296" y="217">after-merge</text><text class="t-note" x="504" y="217" text-anchor="end">skipped</text>
<line class="dd-hair" x1="280" y1="226" x2="520" y2="226"/>
<text class="t-mono" x="296" y="243">on-push</text><text class="t-note" x="504" y="243" text-anchor="end">skipped</text>
<line class="dd-hair" x1="280" y1="252" x2="520" y2="252"/>
<text class="t-mono" x="296" y="269">lab/outside-ci</text><text class="t-val" x="504" y="269" text-anchor="end">status</text>
<text class="t-say" x="280" y="312">前 6 行是 check run，最后一行是 status</text>
<text class="t-say" x="280" y="334">skipped 的 job 也会留下一条记录</text>
<rect class="dd-card" x="568" y="64" width="200" height="110" rx="6"/>
<path class="dd-card-head" d="M574 64 H762 Q768 64 768 70 V96 H568 V70 Q568 64 574 64 Z"/>
<text class="t-name" x="584" y="85">PR #159</text>
<text class="t-mono" x="584" y="117">head.sha</text><text class="t-mono" x="752" y="117" text-anchor="end"><tspan class="t-acc">e796c53</tspan></text>
<line class="dd-hair" x1="568" y1="126" x2="768" y2="126"/>
<text class="t-mono" x="584" y="143">draft</text><text class="t-val" x="752" y="143" text-anchor="end">true</text>
<line class="dd-hair" x1="568" y1="152" x2="768" y2="152"/>
<text class="t-mono" x="584" y="166">mergeable_state</text><text class="t-val" x="752" y="166" text-anchor="end">clean</text>
<text class="t-say" x="568" y="216">PR 只记住 head 提交，</text>
<text class="t-say" x="568" y="238">再去提交上读检查结果。</text>
<text class="t-say" x="568" y="282">没有任何规则时，</text>
<text class="t-say" x="568" y="304">这些结果只是展示。</text>
</svg>
</div>
<figcaption>图 1 · 检查有两种来源，但结果都挂在提交上，PR 只是读取并汇总</figcaption>
</figure>

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

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

### 现象

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

### 原因：报告和关卡是两回事

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

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

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

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

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

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

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="prg-req-title">同一个检查状态，有没有规则结果不同</div>
<div class="dt-scroll">
<table class="dt" aria-labelledby="prg-req-title">
<colgroup><col style="width:34%"><col><col class="hl"></colgroup>
<thead>
<tr><th scope="col">head 提交上 <code>test</code> 的状态</th><th scope="col">没有规则（twwy.net 现状）</th><th scope="col" class="acc">规则要求 <code>test</code> 必须通过</th></tr>
</thead>
<tbody>
<tr><th scope="row">success</th><td class="good">可合并（clean）</td><td class="good">可合并</td></tr>
<tr><th scope="row">failure</th><td class="warn">红叉，但可合并（unstable，#160 实测）</td><td class="bad">不可合并（blocked）</td></tr>
<tr><th scope="row">还在跑</th><td class="warn">可合并（unstable）</td><td class="bad">不可合并，等结果</td></tr>
<tr><th scope="row">job 被 <code>if</code> 跳过（skipped）</th><td>可合并</td><td class="good">算通过</td></tr>
<tr><th scope="row">workflow 被路径过滤，没运行</th><td>可合并</td><td class="bad">一直等待，永远合不了</td></tr>
<tr><th scope="row">只在旧提交上通过</th><td>可合并</td><td class="bad">不算数，要在新提交上重跑</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">“可合并”指检查这一项不拦；评审、冲突等其他条件另算。</p>
</div>
<figcaption>图 2 · 检查本身只是报告，只有被规则点名的检查才会锁住合并按钮</figcaption>
</figure>

规则里点名的是“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 跑的是这个测试合并提交[^events]，测的是“如果现在合并，主干会变成什么样”，而不只是分支本身。跑完以后，结果又挂回 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` 事件不会运行 workflow[^events]。没有测试合并提交，也就没有东西可测。冲突是唯一一个不需要任何规则、永远拦住合并按钮的卡点，而且它还会让检查停下来，如果规则里配了必需检查，合并框会同时显示“有冲突”和“检查在等待”。解决冲突、推上新提交以后，检查才会重新开始。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="prg-merge-title prg-merge-desc">
<title id="prg-merge-title">检查跑在测试合并提交上，冲突时它生成不出来</title>
<desc id="prg-merge-desc">左边是 #159：主干提交 236b111，PR 的 head 提交 e796c53，GitHub 把两者合成测试合并提交 bef7b38，放在 refs/pull/159/merge 上，workflow 在它上面跑，结果挂回 e796c53。右边是 #161：主干前进到 bb3a2c9，与 head 提交 2035e4d 冲突，测试合并提交生成不出来，pull_request 事件不运行任何 workflow。</desc>
<defs>
<marker id="prg-merge-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="prg-merge-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="340" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="376" height="308" rx="8"/>
<rect class="dd-zone" x="408" y="16" width="376" height="308" rx="8"/>
<text class="t-zone" x="32" y="44">#159 · 能合并</text>
<text class="t-zone" x="424" y="44">#161 · 有冲突</text>
<path class="dd-edge" d="M40 256 H368"/>
<path class="dd-edge" d="M96 240 V176 Q96 168 104 168 H120"/>
<path class="dd-edge-acc" d="M188 168 H252 Q260 168 260 176 V184" marker-end="url(#prg-merge-arrow-acc)"/>
<path class="dd-edge-acc" d="M132 256 H252 Q260 256 260 248 V232" marker-end="url(#prg-merge-arrow-acc)"/>
<rect class="dd-card" x="60" y="240" width="72" height="32" rx="6"/>
<text class="t-mono" x="96" y="261" text-anchor="middle">236b111</text>
<text class="t-note" x="40" y="296">主干 lab/pr-gates-base</text>
<rect class="dd-card" x="120" y="152" width="68" height="32" rx="6"/>
<text class="t-mono" x="154" y="173" text-anchor="middle">e796c53</text>
<text class="t-note" x="120" y="140">refs/pull/159/head</text>
<rect class="dd-derived" x="220" y="188" width="80" height="40" rx="6"/>
<text class="t-mono" x="260" y="213" text-anchor="middle"><tspan class="t-acc">bef7b38</tspan></text>
<text class="t-note" x="236" y="140">refs/pull/159/merge</text>
<text class="t-say" x="200" y="88">虚线框：GitHub 算出来的测试合并</text>
<text class="t-say" x="200" y="108">提交，workflow 在它上面跑</text>
<path class="dd-edge" d="M432 256 H760"/>
<path class="dd-edge" d="M488 240 V176 Q488 168 496 168 H512"/>
<path class="dd-edge" d="M580 168 H604"/>
<path class="dd-edge" d="M672 168 H708 Q716 168 716 176 V184"/>
<path class="dd-edge" d="M664 256 H708 Q716 256 716 248 V232"/>
<rect class="dd-card" x="452" y="240" width="72" height="32" rx="6"/>
<text class="t-mono" x="488" y="261" text-anchor="middle">236b111</text>
<rect class="dd-card" x="592" y="240" width="72" height="32" rx="6"/>
<text class="t-mono" x="628" y="261" text-anchor="middle">bb3a2c9</text>
<text class="t-note" x="432" y="296">主干也新建了 lab-shared.txt</text>
<rect class="dd-card" x="512" y="152" width="68" height="32" rx="6"/>
<text class="t-mono" x="546" y="173" text-anchor="middle">d3705a7</text>
<rect class="dd-card" x="604" y="152" width="68" height="32" rx="6"/>
<text class="t-mono" x="638" y="173" text-anchor="middle">2035e4d</text>
<rect class="dd-derived" x="680" y="188" width="72" height="40" rx="6"/>
<text class="t-mono" x="716" y="213" text-anchor="middle">✗</text>
<text class="t-say" x="432" y="88">合并不出来：没有测试合并提交，</text>
<text class="t-say" x="432" y="108">推 2035e4d 后一个 workflow 都没跑</text>
<text class="t-note" x="512" y="140">mergeable_state: dirty</text>
</svg>
</div>
<figcaption>图 3 · 检查测的是“合并后的样子”；合并不出来，检查就不会开始</figcaption>
</figure>

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

## 四、主干前进以后

### 现象

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

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 步还说明，测试合并提交是后台异步重算的，事件到来时不一定已经更新（这是根据日志推断的，文档没有写重算的时机）。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 316" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="prg-stale-title prg-stale-desc">
<title id="prg-stale-title">主干前进后，#159 的绿色结果过期了</title>
<desc id="prg-stale-desc">时间从左到右：01:24:31 #159 打开，检查在 bef7b38 上通过；01:25:21 #160 合入，主干变成 77f0799；01:25:31 #159 转为 ready，检查仍在旧的 bef7b38 上通过；01:26:00 #159 推新提交，测试合并提交变成 aeaa0d5，基于 77f0799，检查失败。</desc>
<defs>
<marker id="prg-stale-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="316" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="284" rx="8"/>
<text class="t-note" x="232" y="48" text-anchor="middle">01:24:31</text>
<text class="t-note" x="392" y="48" text-anchor="middle">01:25:21</text>
<text class="t-note" x="552" y="48" text-anchor="middle">01:25:31</text>
<text class="t-note" x="704" y="48" text-anchor="middle">01:26:00</text>
<text class="t-name" x="32" y="92">主干</text>
<text class="t-name" x="32" y="168">#159 的动作</text>
<text class="t-name" x="32" y="244">测试结果</text>
<line class="dd-hair" x1="32" y1="124" x2="768" y2="124"/>
<line class="dd-hair" x1="32" y1="200" x2="768" y2="200"/>
<path class="dd-edge" d="M268 88 H356"/>
<path class="dd-edge-acc" d="M428 88 H760"/>
<path class="dd-edge-acc" d="M428 100 Q440 100 440 112 V212 Q440 236 464 236 H632" marker-end="url(#prg-stale-arrow-acc)"/>
<rect class="dd-card" x="196" y="72" width="72" height="32" rx="6"/>
<text class="t-mono" x="232" y="93" text-anchor="middle">236b111</text>
<rect class="dd-card" x="356" y="72" width="72" height="32" rx="6"/>
<text class="t-mono" x="392" y="93" text-anchor="middle"><tspan class="t-acc">77f0799</tspan></text>
<text class="t-note" x="476" y="80">#160（带 FAIL）合入</text>
<rect class="dd-card" x="164" y="148" width="136" height="32" rx="6"/>
<text class="t-mono" x="232" y="169" text-anchor="middle">opened · e796c53</text>
<rect class="dd-card" x="484" y="148" width="136" height="32" rx="6"/>
<text class="t-mono" x="552" y="169" text-anchor="middle">ready_for_review</text>
<rect class="dd-card" x="636" y="148" width="136" height="32" rx="6"/>
<text class="t-mono" x="704" y="169" text-anchor="middle">push · 8be4454</text>
<rect class="dd-card" x="164" y="220" width="136" height="32" rx="6"/>
<text class="t-mono" x="232" y="241" text-anchor="middle">bef7b38 ✓</text>
<rect class="dd-derived" x="484" y="252" width="136" height="32" rx="6"/>
<text class="t-mono" x="552" y="273" text-anchor="middle">还是 bef7b38 ✓</text>
<rect class="dd-card" x="636" y="220" width="136" height="32" rx="6"/>
<text class="t-mono" x="704" y="241" text-anchor="middle"><tspan class="t-acc">aeaa0d5</tspan> ✗</text>
<text class="t-say" x="164" y="284">虚线框：基于已过时的主干</text>
</svg>
</div>
<figcaption>图 4 · 绿色结果只对算它时的主干有效，主干一动它就可能过期</figcaption>
</figure>

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

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

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

### 解决：合并队列

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

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="prg-mq-title prg-mq-desc">
<title id="prg-mq-title">合并队列为每个排队的 PR 测试“主干加前面所有 PR”</title>
<desc id="prg-mq-desc">队列里有 #101、#102、#103 三个 PR。GitHub 生成三个临时分支：main 加 #101，main 加 #101 和 #102，main 加三个。第二组检查失败，#102 被移出队列，第三组去掉 #102 重建为 main 加 #101 和 #103 再测。</desc>
<rect class="dd-frame" width="800" height="300" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="268" rx="8"/>
<text class="t-zone" x="32" y="44">示意 · gh-readonly-queue/main/…（每行触发一次 merge_group）</text>
<text class="t-mono" x="32" y="89">pr-101-…</text>
<text class="t-mono" x="32" y="137">pr-102-…</text>
<text class="t-mono" x="32" y="185">pr-103-…</text>
<text class="t-mono" x="32" y="249">重建 pr-103</text>
<rect class="dd-card" x="160" y="68" width="96" height="32" rx="6"/><text class="t-mono" x="208" y="89" text-anchor="middle">main</text>
<rect class="dd-card" x="264" y="68" width="80" height="32" rx="6"/><text class="t-mono" x="304" y="89" text-anchor="middle">#101</text>
<text class="t-mono" x="680" y="89">✓ 合入</text>
<rect class="dd-card" x="160" y="116" width="96" height="32" rx="6"/><text class="t-mono" x="208" y="137" text-anchor="middle">main</text>
<rect class="dd-card" x="264" y="116" width="80" height="32" rx="6"/><text class="t-mono" x="304" y="137" text-anchor="middle">#101</text>
<rect class="dd-card" x="352" y="116" width="80" height="32" rx="6"/><text class="t-mono" x="392" y="137" text-anchor="middle"><tspan class="t-acc">#102</tspan></text>
<text class="t-mono" x="680" y="137"><tspan class="t-acc">✗ 移出</tspan></text>
<rect class="dd-derived" x="160" y="164" width="96" height="32" rx="6"/><text class="t-mono" x="208" y="185" text-anchor="middle">main</text>
<rect class="dd-derived" x="264" y="164" width="80" height="32" rx="6"/><text class="t-mono" x="304" y="185" text-anchor="middle">#101</text>
<rect class="dd-derived" x="352" y="164" width="80" height="32" rx="6"/><text class="t-mono" x="392" y="185" text-anchor="middle"><tspan class="t-acc">#102</tspan></text>
<rect class="dd-derived" x="440" y="164" width="80" height="32" rx="6"/><text class="t-mono" x="480" y="185" text-anchor="middle">#103</text>
<text class="t-note" x="680" y="185">作废</text>
<line class="dd-hair" x1="32" y1="212" x2="768" y2="212"/>
<rect class="dd-card" x="160" y="228" width="96" height="32" rx="6"/><text class="t-mono" x="208" y="249" text-anchor="middle">main</text>
<rect class="dd-card" x="264" y="228" width="80" height="32" rx="6"/><text class="t-mono" x="304" y="249" text-anchor="middle">#101</text>
<rect class="dd-card" x="352" y="228" width="80" height="32" rx="6"/><text class="t-mono" x="392" y="249" text-anchor="middle">#103</text>
<text class="t-mono" x="680" y="249">✓ 合入</text>
<text class="t-say" x="452" y="234">虚线：包含失败 PR 的组，结果作废</text>
</svg>
</div>
<figcaption>图 5 · 合并队列把“与主干同步再测”变成后台流水线，作者不用自己追着主干更新</figcaption>
</figure>

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

队列之外还有一个轻量的帮手，**auto-merge**：在 PR 上点 Enable auto-merge，等必需评审和检查都满足了自动合并[^auto-merge]。它不会帮你更新分支，也不做组合测试，只是“条件满足时替你点按钮”。仓库要先在设置里打开 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 三种[^events]，想在转 ready 时再跑一遍要在 `types` 里自己加上（实验 workflow 加了）。想让 draft 不跑，得在 job 上写 `if: github.event.pull_request.draft == false`。

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

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

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

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="prg-review-title">评审相关的规则开关，各堵哪个漏洞</div>
<div class="dt-scroll">
<table class="dt" aria-labelledby="prg-review-title">
<colgroup><col style="width:34%"><col><col></colgroup>
<thead>
<tr><th scope="col">开关</th><th scope="col">不开会怎样</th><th scope="col">开了以后</th></tr>
</thead>
<tbody>
<tr><th scope="row">Required approvals（N 个）</th><td>没人看也能合</td><td>至少 N 个有写权限的人批准</td></tr>
<tr><th scope="row">Dismiss stale approvals</th><td>批准后再推新代码，旧批准仍然有效</td><td>改动后旧批准作废，要重新批准</td></tr>
<tr><th scope="row">Require review from Code Owners</th><td>CODEOWNERS 只负责发评审请求</td><td>改到谁的文件，必须有对应 owner 批准</td></tr>
<tr><th scope="row">Require approval of the most recent push</th><td>作者推完最后一下自己就能凑齐</td><td>最后一次推送必须由推送者以外的人批准</td></tr>
<tr><th scope="row">Require conversation resolution</th><td>评审意见没处理也能合</td><td>所有评审对话标记为已解决才能合</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">管理员默认可以绕过，除非同时勾上 Do not allow bypassing the above settings。</p>
</div>
<figcaption>图 6 · 评审规则的每个子开关都对应一种“批准了但其实没人看过最终代码”的情况</figcaption>
</figure>

### CODEOWNERS：先发请求，规则让它变成必需

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

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

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

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

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

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

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

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="prg-layer-title prg-layer-desc">
<title id="prg-layer-title">多套规则叠加，同一项取最严格的</title>
<desc id="prg-layer-desc">左边三个来源：分支保护规则要求 2 个批准和线性历史；ruleset A 要求 3 个批准和签名提交；ruleset B 已停用，要求 5 个批准。右边是 main 上实际生效的规则：3 个批准、签名提交、线性历史，停用的 ruleset B 不参与。</desc>
<defs>
<marker id="prg-layer-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="300" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="400" height="268" rx="8"/>
<rect class="dd-zone" x="432" y="16" width="352" height="268" rx="8"/>
<text class="t-zone" x="32" y="44">作用于 main 的规则来源（文档示例）</text>
<text class="t-zone" x="448" y="44">main 上实际生效</text>
<path class="dd-edge" d="M384 92 H416 Q424 92 424 100 V136 Q424 144 432 144 H456" marker-end="url(#prg-layer-arrow)"/>
<path class="dd-edge" d="M384 168 H456" marker-end="url(#prg-layer-arrow)"/>
<rect class="dd-card" x="32" y="64" width="352" height="56" rx="6"/>
<text class="t-name" x="48" y="86">分支保护规则 · main</text>
<text class="t-mono" x="48" y="108">approvals: 2 · linear history</text>
<rect class="dd-card" x="32" y="140" width="352" height="56" rx="6"/>
<text class="t-name" x="48" y="162">ruleset A · Active</text>
<text class="t-mono" x="48" y="184">approvals: <tspan class="t-acc">3</tspan> · signed commits</text>
<rect class="dd-derived" x="32" y="216" width="352" height="56" rx="6"/>
<text class="t-name" x="48" y="238">ruleset B · Disabled</text>
<text class="t-mono" x="48" y="260">approvals: 5</text>
<rect class="dd-card" x="456" y="96" width="312" height="110" rx="6"/>
<path class="dd-card-head" d="M462 96 H762 Q768 96 768 102 V128 H456 V102 Q456 96 462 96 Z"/>
<text class="t-name" x="472" y="117">合并 main 需要</text>
<text class="t-mono" x="472" y="149">approvals</text><text class="t-mono" x="752" y="149" text-anchor="end"><tspan class="t-acc">3</tspan></text>
<line class="dd-hair" x1="456" y1="158" x2="768" y2="158"/>
<text class="t-mono" x="472" y="175">signed commits</text><text class="t-val" x="752" y="175" text-anchor="end">required</text>
<line class="dd-hair" x1="456" y1="184" x2="768" y2="184"/>
<text class="t-mono" x="472" y="200">linear history</text><text class="t-val" x="752" y="200" text-anchor="end">required</text>
<text class="t-say" x="456" y="244">虚线：停用的 ruleset 不参与叠加</text>
<text class="t-say" x="456" y="266">同一项取最严格的：2 和 3 取 3</text>
</svg>
</div>
<figcaption>图 7 · 规则来源可以有多个，GitHub 把它们并起来，每一项取最严格的值</figcaption>
</figure>

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

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

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

### environment：拦的是部署，不是合并

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

给环境加上保护规则以后（必需审批人、等待计时、只允许某些分支部署、App 提供的自定义规则），**被卡住的是这个 job**：它停在 waiting 状态，规则通过之前不开始，也拿不到环境的 secret。这和合并按钮没有直接关系。要让“部署成功”成为合并前提，得在分支规则里另开 **Require deployments to succeed before merging**，指定哪些环境必须部署成功[^protected]。常见的做法是：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` 判断[^events]。

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

### pull_request_target：用的是主干上的 workflow

还有一个名字很像的事件 `pull_request_target`。它在 base 仓库默认分支的上下文中运行[^events]，用的是默认分支上的 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 自动做的事，右列是卡点，以及是谁让它成为卡点的。强调色标出的两个卡点不需要任何规则，永远生效；其余的都要在分支规则里打开。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 552" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="prg-map-title prg-map-desc">
<title id="prg-map-title">一个 PR 从打开到合并以后的卡点全景</title>
<desc id="prg-map-desc">按时间顺序八个阶段：打开 draft、转 ready、每次推送、评审、主干前进、进合并队列、合并、合并之后。每个阶段列出 GitHub 自动做的事和卡点。draft 不能合并、冲突不能合并这两条永远生效；必需检查、必需评审、与主干同步、合并队列、线性历史、部署成功等需要规则开启；环境保护规则拦的是部署 job 而不是合并。</desc>
<defs>
<marker id="prg-map-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="552" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="176" height="488" rx="8"/>
<rect class="dd-zone" x="208" y="16" width="288" height="488" rx="8"/>
<rect class="dd-zone" x="512" y="16" width="272" height="488" rx="8"/>
<text class="t-zone" x="32" y="44">阶段</text>
<text class="t-zone" x="224" y="44">GitHub 自动做的事</text>
<text class="t-zone" x="528" y="44">卡点 · 谁让它生效</text>
<path class="dd-edge" d="M104 96 V116" marker-end="url(#prg-map-arrow)"/>
<path class="dd-edge" d="M104 152 V172" marker-end="url(#prg-map-arrow)"/>
<path class="dd-edge" d="M104 208 V228" marker-end="url(#prg-map-arrow)"/>
<path class="dd-edge" d="M104 264 V284" marker-end="url(#prg-map-arrow)"/>
<path class="dd-edge" d="M104 320 V340" marker-end="url(#prg-map-arrow)"/>
<path class="dd-edge" d="M104 376 V396" marker-end="url(#prg-map-arrow)"/>
<path class="dd-edge" d="M104 432 V452" marker-end="url(#prg-map-arrow)"/>
<rect class="dd-card" x="32" y="60" width="144" height="36" rx="6"/><text class="t-name" x="104" y="83" text-anchor="middle">打开（draft）</text>
<rect class="dd-card" x="32" y="116" width="144" height="36" rx="6"/><text class="t-name" x="104" y="139" text-anchor="middle">转 ready</text>
<rect class="dd-card" x="32" y="172" width="144" height="36" rx="6"/><text class="t-name" x="104" y="195" text-anchor="middle">每次推送</text>
<rect class="dd-card" x="32" y="228" width="144" height="36" rx="6"/><text class="t-name" x="104" y="251" text-anchor="middle">评审</text>
<rect class="dd-card" x="32" y="284" width="144" height="36" rx="6"/><text class="t-name" x="104" y="307" text-anchor="middle">主干前进</text>
<rect class="dd-card" x="32" y="340" width="144" height="36" rx="6"/><text class="t-name" x="104" y="363" text-anchor="middle">进合并队列</text>
<rect class="dd-card" x="32" y="396" width="144" height="36" rx="6"/><text class="t-name" x="104" y="419" text-anchor="middle">合并</text>
<rect class="dd-card" x="32" y="452" width="144" height="36" rx="6"/><text class="t-name" x="104" y="475" text-anchor="middle">合并之后</text>
<text class="t-note" x="224" y="76">pull_request 跑在 refs/pull/N/merge</text>
<text class="t-note" x="224" y="92">不请求 code owner</text>
<text class="t-note" x="224" y="132">ready_for_review 事件</text>
<text class="t-note" x="224" y="148">按 base 上的 CODEOWNERS 请求评审</text>
<text class="t-note" x="224" y="188">synchronize：重算测试合并，重跑检查</text>
<text class="t-note" x="224" y="204">有冲突则不跑</text>
<text class="t-note" x="224" y="244">Approve / Request changes</text>
<text class="t-note" x="224" y="260">新推送可让旧批准作废</text>
<text class="t-note" x="224" y="300">旧的绿色结果过期</text>
<text class="t-note" x="224" y="316">PR 显示 behind（开了同步要求时）</text>
<text class="t-note" x="224" y="356">gh-readonly-queue/ 临时分支</text>
<text class="t-note" x="224" y="372">merge_group 事件重跑检查</text>
<text class="t-note" x="224" y="412">生成合并提交（或 squash / rebase）</text>
<text class="t-note" x="224" y="428">默认分支收到 push</text>
<text class="t-note" x="224" y="468">push、pull_request closed + merged</text>
<text class="t-note" x="224" y="484">部署 job 等 environment 规则</text>
<text class="t-mono" x="528" y="76"><tspan class="t-acc">draft 不能合并</tspan></text>
<text class="t-note" x="528" y="92">永远生效</text>
<text class="t-mono" x="528" y="132">—</text>
<text class="t-note" x="528" y="148">评审请求不等于必须批准</text>
<text class="t-mono" x="528" y="188"><tspan class="t-acc">冲突</tspan> · 必需检查</text>
<text class="t-note" x="528" y="204">冲突永远生效；检查要规则点名</text>
<text class="t-mono" x="528" y="244">批准数 · Code Owner</text>
<text class="t-note" x="528" y="260">规则：Require a pull request</text>
<text class="t-mono" x="528" y="300">与主干同步</text>
<text class="t-note" x="528" y="316">规则：up to date（strict）</text>
<text class="t-mono" x="528" y="356">队列里的必需检查</text>
<text class="t-note" x="528" y="372">规则：Require merge queue</text>
<text class="t-mono" x="528" y="412">线性历史 · 签名 · 部署成功</text>
<text class="t-note" x="528" y="428">规则；管理员可否绕过也是规则</text>
<text class="t-mono" x="528" y="468">环境保护规则</text>
<text class="t-note" x="528" y="484">拦部署 job，不拦合并</text>
<text class="t-say" x="32" y="530">强调色：不需要任何规则就会拦住合并；其余都要在分支保护或 ruleset 里打开。</text>
</svg>
</div>
<figcaption>图 8 · 合并按钮能不能点，取决于两条永远生效的卡点，加上规则点名的那些</figcaption>
</figure>

### 回到 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 }}"
```

## 参考资料
