# 兩個檢查全綠的 GitHub pull request 弄壞了 main — 如何用 Require branches to be up to date before merging 來避免

> 為什麼所有檢查都通過的 GitHub pull request 仍可能弄壞 main、Require branches to be up to date before merging 如何擋下它，以及 main 該設定的規則。

- Source: https://oharu121.com/zh-tw/blog/github-require-up-to-date-branches-stale-pr-checks/
- Published: 2026-10-03T00:44:50+09:00
- Tags: GitHub, GitHub Actions

---
## 引言

我的習慣是等 pull request 上的檢查全部變綠才合併，所以當我的一個 pull request 在一支它根本沒動過的測試檔上 CI 失敗時，我很意外。

這個失敗跟我的改動完全無關。另一個 pull request 在所有檢查都通過的情況下合併後，**main 本身就變紅了**，於是每個還開著的 pull request 都因為跟自己無關的原因跟著變紅。壞掉的 main 背後還藏著一個真正的 bug，而且還沒有任何測試跑到它。

被合併的那個 pull request 的檢查結果沒有錯，只是過時了。那些檢查是在另一個 pull request 合併之前跑的，而被合併的這個 pull request，正好弄壞了另一個 pull request 新增的程式碼。**GitHub 的 _Require branches to be up to date before merging_ 會讓這種合併等到 CI 重新跑過**。

本文說明兩個綠燈的 pull request 如何疊出一個紅燈的 main，以及除了這項設定之外，main 還值得加上哪些規則。

## 兩個綠燈的 pull request 如何合併出紅燈的 main

**綠燈的檢查，只是針對 main 某一個快照的結果**。對於 `pull_request` 事件，GitHub Actions 測試的是 pull request 分支與基底分支的合併提交，這個合併提交在工作流程開始的那一刻建立。之後基底分支即使再往前推進，這次測試也不會自動重跑。

於是就可能發生下面這種情況：

1. Pull request A 跑 CI 並變綠。CI 測試的是把 A 合併進當下的 main 的結果。
2. A 等待審查期間，pull request B 合併進了 main。
3. A 合併。它的綠燈結果是對一個已經不存在的 main 算出來的，而 A 和 B 的組合在兩者都進了 main 之前，從來沒有跑過 CI。

*Figure — MergeTimeline: 每個 pull request 在受測當時的 main 上都是綠燈，兩者的組合卻要等到已經進了 main 才第一次被測試。*

大多數時候，A 和 B 不會互相影響，過時的結果依然正確，但這次偏偏互相影響了。Pull request A 在一個共用介面上新增了四個方法，也更新了實作這個介面的測試用假物件。平行開發的 pull request B 新增了一支測試，裡面帶著自己的一個假物件，但用的是舊的方法。

兩者改的是不同的檔案，所以 **Git 合併時沒有任何衝突**，結果 main 上的型別檢查失敗了（以下錯誤訊息中的類別名稱經過簡化）：

```text
error: Argument "connector" to "Pipeline" has incompatible type "FakeConnector"; expected "FileConnector"  [arg-type]
```

型別錯誤背後還藏著第二個問題：pull request B 也改了一個共用的錯誤輔助函式，讓儲存 API 回傳的 404 變成專案自己的 `NotFoundError`。Pull request A 新增的 `restore()` 方法卻仍然捕捉原始的 404 並回傳 `False`。所以兩者都合併後，還原一個已永久刪除的檔案時會拋出例外。

正好測這個情境的測試其實已經存在了，但**它從來沒有執行過**。CI 作業在型別檢查就因為錯誤而停下來了，沒走到該測試步驟。這個測試需要等到型別錯誤修好之後才會被執行。

## 什麼是分支保護

分支保護是一組規則，GitHub 會在變更進入某個分支之前強制執行。常見的規則包括：要求透過 pull request 而不是直接推送到 main、要求一定數量的核准審查、要求狀態檢查通過，以及禁止對該分支 force-push 和刪除分支。

GitHub 提供兩種設定方式。**分支保護規則**位於 Settings → Branches，每個分支模式只能有一條規則。**規則集**位於 Settings → Rules → Rulesets，是具名的規則清單，而且同一個分支可以套用好幾個規則集。

規則集之間互相重疊，或規則集與分支保護規則重疊時，以最嚴格的設定為準。任何有讀取權限的人都能看到 repo 目前生效的規則集，所以開發者不必問管理員，就能確認 main 要求了什麼。

必要的狀態檢查有兩種模式：用 GitHub 的說法，**strict** 檢查要求分支在合併前必須跟基底分支保持最新，**loose** 檢查則不要求。我之前把 repo 的必要檢查設成 loose 模式，而這次的錯誤正是從這個漏洞鑽過去的。

## Require branches to be up to date before merging

**開啟這項設定後，落後基底分支的 pull request 就無法合併**。GitHub 提供一個 Update branch 按鈕，會把最新的 main 帶進 pull request 分支並重新觸發 CI，合併則要等這次新的執行通過。

*Image: GitHub pull request 的合併區塊。Review required 標著紅色叉號，All checks have passed 帶著綠色勾號並顯示 5 successful checks。下方的 This branch is out-of-date with the base branch 附有 Update branch 按鈕。Merging is blocked 以必要的核准審查為理由，Merge pull request 按鈕呈灰色無法點選。*

*所有檢查都是綠燈，但分支仍然落後 main。按下 Update branch 才會讓分支跟上最新狀態，並重新跑 CI。*

套用到上面的時間線，B 合併之後，pull request A 就會被擋下，重新跑的 CI 會撞上型別錯誤，main 也就會保持綠燈。

代價是重跑：每一次合併進 main，都會讓其他所有開著的 pull request 落後，每一個都要先更新、再跑一次 CI 才能合併。

如果 CI 大約一分半鐘就跑完，團隊又小，每次合併多等的時間很短；但在繁忙的 repo 上，這些更新會越積越多。合併佇列就是為了解決這個問題。

### 設定在哪裡

它不是一條獨立的規則。在規則集裡，啟用 **Require status checks to pass** 之後，它才會出現在底下。在傳統的分支保護規則裡，它是縮排在 **Require status checks to pass before merging** 下方的核取方塊，要先勾選上層選項才會顯示。

*Image: GitHub 規則集的分支規則。Require status checks to pass 已勾選，在展開的額外設定裡，Require branches to be up to date before merging 已勾選，Do not require status checks on creation 則未勾選。下方的面板顯示 No required checks，Add checks 下拉選單已展開，裡面有 Search for checks 搜尋框。更下方的 Block force pushes 已勾選。*

*在規則集裡，這項設定位於 Require status checks to pass 的額外設定之中。*

不論哪一種方式，都至少要選一個「必要檢查」。如果沒有選擇「必要檢查」，就沒有東西可以重跑。

### 只會「建議」的核取方塊

Settings → General → Pull Requests 裡有一個名稱相近、效果卻弱得多的設定：

> **Always suggest updating pull request branches**
> Whenever there are new changes available in the base branch, present an "update branch" option in the pull request.

*Image: GitHub repo 一般設定中的 pull request 分支更新設定。在 Control how and when users are prompted to update their branches if there are new changes available in the base branch 這行說明下方，有一個未勾選的核取方塊，寫著 Always suggest updating pull request branches。*

*這個 repo 層級的核取方塊決定的是 Update branch 按鈕會不會出現，而不是合併要不要等它。*

它只會加上那個按鈕，其他什麼都不做。**即使開啟它，落後的 pull request 仍然可以合併**，所以它擋不住檢查過時的 Pull Request 合併進 main。

## main 的基本規則，以及每條規則擋下什麼

下面這些規則，是多人合併 pull request 的 repo 替 main 設定時的起點。每一列都寫出該規則擋下什麼；如果某種失敗你可以接受，就拿掉對應的規則。

| 規則 | 擋下什麼 |
| --- | --- |
| Require a pull request before merging | 跳過審查與 CI、直接推送到 main |
| Require approvals，並在推送新提交時撤銷過時的核准 | 合併未經審查的程式碼，以及核准之後又有新改動，核准卻仍然有效 |
| Require status checks to pass，並逐一指定檢查 | 合併 CI 為紅燈的 pull request |
| Require branches to be up to date before merging | 用針對舊 main 算出的綠燈結果合併 |
| Require conversation resolution before merging | 審查討論串還沒解決就合併 |
| Block force pushes，並限制刪除 | 改寫或刪除 main 的歷史 |
| Do not allow bypassing the above settings（傳統規則），或讓規則集的略過清單保持空白 | 管理員繞過上面所有規則直接合併 |

這個故事裡的 repo 只對非管理員套用規則，所以即使規則設得再完整，也擋不住管理員自己做的合併。

### 必要檢查必須指名正確的作業

必要檢查是靠名稱比對的。在這個 repo 裡，有兩個工作流程各有一個名為 `check` 的作業，一個負責應用程式的程式碼，一個負責基礎設施的程式碼。分支規則卻只列了一個叫 `check` 的必要檢查。光看這份清單，無從判斷它指的是哪個工作流程。

**讓每個作業的名稱在所有工作流程之間都不重複**，例如 `app-check` 和 `infra-check`，並用各自的名稱把它們設為必要檢查。

#### 重新命名已設為必要的作業

重新命名一個必要的作業，需要照順序來。負責改名的 pull request 不會再回報舊名稱，所以只要舊名稱還是必要檢查，這個 pull request 就會一直等一個永遠不會出現的檢查。

先移除舊的必要設定雖然能讓它合併，但在加上新名稱之前，main 會處於沒有任何必要檢查的狀態。

讓 main 全程都受到保護的順序如下：

1. 開出改名的 pull request，讓它的 CI 跑一次，新的檢查名稱才會存在。設定頁面只會列出它看過的檢查，搜尋框上寫著 "Search for status checks in the last week for this repository"。
2. 把舊的必要檢查換成新的名稱，並在同一次編輯中開啟 **Require branches to be up to date before merging**。
3. 合併改名的 pull request。它的 CI 已經回報了新名稱，所以能通過新的要求。
4. 更新其他開著的 pull request。在它們的 CI 重新跑之前，新的檢查會顯示為 "Expected — Waiting for status to be reported"，合併也會一直被擋住。

*Figure — RenameRequiredCheck: 改名的 pull request 在新名稱成為必要檢查之前就已回報過它們，所以替換過程中 main 從未失去必要檢查。*

#### 排除有路徑篩選的工作流程

帶有 `paths:` 篩選的工作流程不能當成必要檢查。GitHub 的疑難排解指南寫道，工作流程因路徑篩選被略過時，它的檢查會 "stay in a 'Pending' state and block merging"。這樣一來，只改文件的 pull request 就永遠無法合併。像映像建置這類帶路徑篩選的工作流程，就不要放進必要檢查清單。

### 合併佇列，以及何時可以使用

合併佇列把「更新再重測」的循環自動化了：不必由每位作者各自按下 Update branch 再等待，佇列會用最新的 main 加上排隊中的 pull request 建立一個暫時分支，在上面跑 CI，只有通過時才合併。GitHub 的說明是，它提供的保證與要求分支保持最新相同。

使用上有兩個條件。**合併佇列只能用在組織擁有的公開 repo，以及 GitHub Enterprise Cloud 上的私有 repo**，所以 Team 方案的私有 repo 用不了。另外，凡是有產生必要檢查的工作流程，都必須由 `merge_group` 事件觸發，否則佇列會一直等待永遠不會開始的檢查：

```yaml title=".github/workflows/ci.yml"
on:
  pull_request:
  merge_group:
```

沒有合併佇列的話，要求分支保持最新也能提供同樣的保護，只是更新要手動做。

## 總結

Pull request 上的綠燈檢查，代表的是這個 pull request 在 CI 執行當下的 main 上能正常運作。兩個 pull request 可以各自對自己的快照都是綠燈，合在一起卻弄壞 main。只要它們改的是不同檔案，Git 不會給你任何警告。

**Require branches to be up to date before merging** 把這道檢查挪到合併的時候：落後 main 的 pull request 必須先更新，並再次通過 CI。這項設定位在必要狀態檢查規則底下，而名稱相近的 "Always suggest updating pull request branches" 只會多加一個按鈕。

這項設定最好搭配其他規則一起使用：要求透過 pull request 合併並經過審查、讓必要檢查的名稱不重複，以及不讓管理員略過規則。

## 參考連結

- [About protected branches (GitHub Docs)](https://docs.github.com/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches)
- [About rulesets (GitHub Docs)](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets)
- [Available rules for rulesets (GitHub Docs)](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets)
- [Troubleshooting required status checks, including skipped path-filtered workflows (GitHub Docs)](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/troubleshooting-required-status-checks)
- [Managing a merge queue (GitHub Docs)](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue)
- [Events that trigger workflows: pull_request and merge_group (GitHub Docs)](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows)
