Multibranch 與 PR 觸發:讓每個分支都有自己的 pipeline

· tech

#jenkins#ci-cd#branching

📑 目錄

前面七篇談的都是「一條 pipeline」。但真實的 repo 從來不只有一條路:main 上有人在合、三個 feature branch 在跑、還有兩個 PR 等著 review。

這篇處理的就是這件事——怎麼讓每個分支與 PR 都有自己的 pipeline,以及為什麼這件事做好之後,你才真的擁有第 1 篇說的那個「頻繁合回主幹」。

Multibranch Pipeline:一個 repo,一群 pipeline

Multibranch 做的事很單純:掃描 repo,凡是有 Jenkinsfile 的分支與 PR,自動長出一個 job

payment-api(repo) main feature/refund feature/webhook PR #12 PR #15 webhook掃描 Multibranch有 Jenkinsfile 的就自動長一個 job job: main跑 main 的 Jenkinsfile job: feature/refund跑「它自己那版」 job: feature/webhook跑「它自己那版」 job: PR-12合併前先驗 job: PR-15合併前先驗 每個分支跑「它自己那一版」Jenkinsfile,不是共用一份 所以改 pipeline 的 PR,是用改過之後的版本跑的——建置流程的變更,合併前就被驗過了 分支刪掉,對應的 job 也要跟著消失(孤兒 job 清理策略)——否則就是下一批要清的堆積
這張圖最值得記住的是中間那句:PR 是用它自己那版 Jenkinsfile 跑的。這代表「改建置流程」跟「改業務程式碼」享有完全一樣的待遇——寫進 PR、被 review、在合併前先跑一次證明它會動。第 2 篇說「pipeline 是程式碼」,multibranch 才讓這句話真正閉環

設定本身也該是程式碼(第 6 篇的立場一路延伸到這裡),用 JCasC 寫大概長這樣:

# jenkins.yaml —— Multibranch,連掃描規則與孤兒清理都寫進 git
jobs:
  - script: >
      multibranchPipelineJob('payment-api') {
        branchSources { github { id('payment-api'); repoOwner('acme'); repository('payment-api') } }
        orphanedItemStrategy {                  // 分支刪了,job 也清掉
          discardOldItems { numToKeep(10) }
        }
        factory { workflowBranchProjectFactory { scriptPath('Jenkinsfile') } }
      }

如果整個 organization 都用同一套慣例,還可以再上一層用 Organization Folder:掃整個 GitHub org,任何 repo 只要放了 Jenkinsfile 就自動接上 CI——新專案不用「找人幫忙開 job」。

Webhook vs Polling

Jenkins 怎麼知道有新 commit?兩條路:

Polling(定時去問)Webhook(它主動通知)
延遲平均是掃描間隔的一半——設 5 分鐘,平均白等 2.5 分鐘幾秒
成本repo 一多就是持續的無效流量,controller 一直在跟 git 講話事件才有動作
網路Jenkins 連得出去就行git 服務要連得到 Jenkins(內網要處理)

能用 webhook 就用 webhook。 polling 是很典型的 toil:重複、可自動化、而且量會隨規模線性長大——一百個 repo 每兩分鐘掃一次,那是三萬次無效請求。

唯一合理留著 polling 的情況,是 Jenkins 在內網、git 服務打不進來。這時折衷做法是把間隔拉長(例如 15 分鐘)當保險,主要靠開發者手動觸發,或改用 GitHub App / 反向代理把 webhook 送進來。

PR check:把「記得跑測試」變成「不需要記得」

Multibranch 會為每個 PR 跑一次 build,並把結果回報成 GitHub 上的 status check。真正的價值在下一步:把它設成 required

測試沒過的 PR PR #123 個檔案 ✓ build ✗ test ✓ lint 👤 1 approval Merge(按不下去) 有人核准也沒用 修好之後 PR #124 個檔案 ✓ build ✓ test 👤 1 approval Merge ✓ 機制放行,不靠自律 不是不信任人——是不讓「忘記」有機會變成事故。另外:PR build 跑的是外部帶進來的程式碼,別給它部署憑證
Required check 把品質從「每個人都要記得」變成「系統擋著」。這是我認為投報率最高的一次性設定之一:設定一次,之後每一個 PR 都自動享有

同一份 Jenkinsfile 要能分辨自己現在跑在哪種情境,靠的是分支條件:

stages {
  stage('Verify') {                      // 所有分支與 PR 都跑,要快
    steps { sh './scripts/ci-verify.sh' }
  }
  stage('Full IT') {
    when { beforeAgent true; not { changeRequest() } }   // PR 不跑重的
    steps { sh './scripts/it.sh' }
  }
  stage('Deploy staging') {
    when { beforeAgent true; branch 'main' }             // 只有 main 才部署
    steps { deployApp(env: 'staging', image: "app:${env.GIT_COMMIT}") }
  }
  stage('Release') {
    when { beforeAgent true; buildingTag() }             // 打 tag 才發版
    steps { deployApp(env: 'production', image: "app:${env.TAG_NAME}") }
  }
}

beforeAgent true 的重要性在第 5 篇講過:沒有它,Jenkins 會先配好 agent、拉完程式碼,才發現這個 stage 不用跑。

但 trunk-based 不是叫工程師勤勞一點

技術設定到這裡就齊了:每個分支有 pipeline、PR 有擋得住的門、main 才部署。但這些都不會自動讓分支變短——因為分支活多久,常常根本不是工程師決定的。

第 1 篇提過這條線,這裡把它講完。關鍵是分清楚兩件被綁在一起的事:

  • 合併(merge):程式碼進不進得了主幹——這是工程決定。
  • 發布(release):功能給不給使用者看見——這是商業決定。
程式碼:每天進主幹、照常上線 每 1~2 天一次小合併 + 部署 使用者看到的:旗標決定 flag = off —— 使用者什麼都沒看到 flag = on 不用部署、不用合併 程式碼進主幹、甚至已經在線上跑 ≠ 使用者看得到 兩條線分開,工程節奏與商業節奏就不必互相等待——這才是 trunk-based 真正的前提
上面那排綠點是整合風險被攤平的樣子:每次只進來一點點,錯了退一小塊。下面那條線則讓商業端保有完整的決定權——上線時間點沒有被工程進度綁架,反而更可控

解開的手段有三階,選最便宜的那個就好:

手段什麼時候用代價
不接線新類別、新 API、新頁面——合進去但不掛路由與選單幾乎零
Branch by abstraction要替換既有實作:介面與新實作先合,舊的照跑,最後切換多一層抽象,切完要記得拆
Feature flag真的需要執行期分流:灰度、A/B、按客戶開關最貴,而且會弄髒程式碼

大部分「還不能合」其實只要不接線就解決了。 旗標是最貴的一把,別拿它當萬用解。

而如果真的要用旗標,就要連清理紀律一起買——這三條我在 review 會直接要求:

  1. 每個旗標上線時就寫下擁有者與預計移除日期,寫在程式碼註解或旗標系統裡都行,但要寫。
  2. 旗標只做開關,不做業務邏輯的分岔點——一旦 if (flag) 底下長出第二套流程,它就不是旗標了,是一個沒人維護的分支。
  3. 定期盤點,過期的要嘛清掉、要嘛重新說明為什麼還在。

做不到這三條,我不會建議一個團隊為了追求 trunk-based 而大量導入旗標——那真的會換來比長命分支更糟的東西。

但如果改的是「現有功能」呢?

上面那張三階梯有個前提我要講明:它涵蓋的是「新增」。 一旦你改的是既有行為,「不接線」直接失效——那段程式碼合進去就會生效,使用者馬上感受得到。

這時候先做一次分類,因為四種「優化」的答案完全不同:

類型例子要不要等商業決策
外部行為不變查詢改寫、加快取、重構、換演算法但輸出一樣不用——這是技術變更,不是產品變更
行為變但使用者無感錯誤訊息更精準、日誌、後台欄位通常知會就好
使用者看得到的變更流程三步變兩步、預設值改掉、版面改版,這是產品變更
不可逆的變更資料格式、對外 API、計費邏輯要,而且要拆成 expand-contract 分批

判準只有一句:這個改動需要跟使用者說明嗎? 需要,它就是產品變更;不需要,它就是技術變更,不該塞進商業排期。

我看過太多團隊把第一類當第三類在等——效能優化排進「下個版本」等一個月。那不是謹慎,是浪費:行為沒變的東西,沒有什麼可以「決策」的。它的風險是技術風險,該用測試與漸進發布去管,不是用審批去管。

真的是行為變更時,有兩個手段比「開個旗標」更適合:

一、kill switch 不等於 feature flag。 差別在預設值,而預設值決定了清理壓力:

feature flagkill switch
預設off,等商業決定何時打開on,新行為直接生效
舊路徑長期並存,測試矩陣加倍只在事故時走,可以設「兩週後刪」
適合新功能優化型改動

優化型的改動九成只需要一個「壞了可以關掉」的開關,不需要一個「等人決定何時打開」的旗標。

二、把 PR 拆開。 這是我最常用的動作——「優化現有功能」的 PR 通常混了兩種東西:

原本一個 PR:重構 + 改行為        → 整包卡住等決策

拆成:
  PR 1  純重構、抽出介面、補測試   → 行為不變,今天就能合
  PR 2  切換到新行為               → 薄薄一層,等決策

拆完之後,等待的表面積從整包縮成幾十行。PR 1 立刻進主幹,不會跟別人衝突;PR 2 只剩切換那一刀,review 快、回滾也乾淨。這就是 branch by abstraction 用在「修改」而不是「新增」上。

還有一種閘門,不在使用者身上,在合約上

上面談的閘門都是「使用者何時看得到」。但有一種常見情境完全在另一條軸上:委外與接案。

甲方委託乙方優化一支 SQL,價錢還沒談定;或是乙方自己發現了很好的優化,打算「先做起來再去談」。技術上這是最單純的第一類(輸出一樣、行為不變),照理今天就能合。但這裡的閘門不是曝光,是這段工作何時被承認、被計價

而它有個殘酷的性質:一旦 merge 進甲方的 repo,議價槓桿就歸零了。 多數委外合約裡,交付物的權利在交付當下就轉移;「先做起來再談價」在工程上很自然,在商務上等於先把牌打出去再開價。

我看過一種很糟的因應方式:程式碼合進去,但用旗標關著,當作「還沒交付」。 三個問題:法律上站不住(它已經在對方的 repo 裡了)、關係上很傷(被發現一次,信任就沒了)、技術上留垃圾(一個永遠不會被清的旗標,連「擁有者與到期日」都寫不出來,前面那三條紀律直接破功)。

閘門在合約,就在合約處理;不要拿旗標當談判籌碼。 具體上:

  • 乙方主動發現的優化 → 先給證據,不給程式碼。 對 SQL 優化來說,證據比程式碼更有議價力:執行計畫 before/after、「這支 query 每天跑 4,000 次,P95 從 8 秒降到 300 毫秒」、預估省下的成本。給程式碼是給勞動,給量化的 before/after 是給價值——後者才談得動價錢,而且你既然看得出怎麼改,做這份評估花不了多少時間。
  • 已經委託、只是價錢沒定 → 那是報價流程沒走完,不是工程問題。 工程端不該用技術手段去兜商務流程的缺口。
  • 現實就是「已經先做了」→ 放在自己的分支,不進對方主幹。 這條分支會活很久,而這正是第 1 篇那句話的極端版:分支活很久是組織層級的題目——只是這次那個「組織」跨越了兩家公司,工程端更沒有話語權。這種時候我不假裝 CI 紀律能解決它,只確保兩件事:定期把主幹合進來(降低最後那次合併的痛),以及一次只押一個未計價的優化,不要越積越多。

順帶一個技術提醒:「SQL 優化不改變行為」這個假設要驗證。加索引通常安全,但改寫 query 很容易踩到 join 造成的重複列、NULL 語意、沒有 ORDER BY 時的隱含順序、DISTINCTGROUP BY 改寫後的邊界。所以就算談定了要合,也該做結果比對:同一批輸入跑新舊兩版,逐列比對輸出。這件事該進 pipeline(下一篇的主題),而且它在委外情境裡一魚兩吃——既是技術保險,也是驗收時最有說服力的交付證據。

反思

Required check 是我做過投報率最高的一次性設定

我在幾個團隊都做過同一件事:把 build、test、lint 設成 required,並開啟「分支落後主幹時要先更新才能合」。設定大概花二十分鐘,之後每一個 PR 都自動享有。

它真正改變的不是品質,是對話的內容。在那之前,review 常常在講「你這個有跑測試嗎」「記得補一下 lint」;之後這些話題完全消失了,因為機器已經講完了,人可以專心討論設計。把機器能檢查的事交給機器,人的注意力才有機會放在機器檢查不了的地方——這是我認為 required check 最被低估的價值。

有一點要提醒:PR build 跑的是外部帶進來的程式碼,所以它不該拿得到任何部署憑證,理由在第 6 篇講過。

我看過 polling 把一台 Jenkins 拖垮

有個環境累積到大約兩百個 job,每個都設 H/2 * * * *(每兩分鐘 poll 一次)。結果 controller 幾乎所有時間都在跟 git 講話,執行緒池被 polling 佔滿,真正的 build 反而排不進去——大家的體感是「Jenkins 好慢」,但沒有人想到慢的原因是它在忙著問「有沒有新東西」。

改成 webhook 之後,那些請求歸零,順帶把觸發延遲從平均一分鐘變成幾秒。這件事讓我學到一個更一般的教訓:輪詢的成本會隨規模線性成長,但它平常完全不痛——它不會壞、不會報錯,只會安靜地吃掉你的容量,直到某天你以為自己需要加機器。

分支保護是要保護主幹,不是為難開發者

反過來的失敗我也看過:某個團隊把六個 check 全設成 required,其中兩個各要跑十幾分鐘。結果不是品質變好,是大家開始想辦法繞過去——小修改直接推 main(因為有管理員權限)、或是把 check 設成 optional 之後忘了改回來。

我現在的原則是:每個 required check 都要能回答「它擋掉過什麼」。擋過真實問題的,留著;從來沒紅過、或紅了大家都直接 re-run 的,那不是關卡,是儀式——該修好它、加快它,或乾脆拿掉。門檻設得比團隊承受能力高,人不會變乖,只會找路繞——而繞過去之後,你連他們繞了都不知道。

下一篇進第三批,談品質關卡本身:測試報告、覆蓋率與靜態分析,怎麼從「有跑」變成「擋得住」,以及為什麼 flaky test 是這道門最大的敵人。