標籤
白色卡片上的藍色機器人頭像,白色面罩內有兩隻圓眼,頂端有短天線、兩側有方形耳朵,下方為近黑色的小寫 dependabot 字樣

Dependabot 的最小發布經過天數與自動合併 — 私有儲存庫裡的陷阱

私有儲存庫的相依關係圖預設關閉,所以 Dependabot 一個 npm PR 都沒開,而自動合併的工作流程在 CI 完成前 45 秒就合併了。

本頁目錄

引言

我在看這個部落格儲存庫的 pull request 清單時,發現少了某樣東西:沒有任何相依套件的版本更新。不是有一個過期的,也不是有一個失敗的,而是一個都沒有。我幾週前才寫好 Dependabot 的政策,連最小發布經過天數與自動合併的工作流程都寫進去了,卻舉不出它實際做過的任何一件事。

結果有兩套機制是停擺的,而且每一套都被某個看起來健康的東西遮住了。npm 生態系從來沒有運作過,因為私有儲存庫的相依關係圖預設是關閉的,而 Dependabot 需要它才能解析宣告檔。自動合併的工作流程從來沒有等待過任何東西,因為 gh pr merge --auto 等的是必要檢查,而這個儲存庫的方案根本無法把任何檢查標成必要。

本文整理這兩件事,以及它們底下的政策問題:最小發布經過天數實際上濾掉什麼、濾不掉什麼,以及為什麼這兩個答案湊在一起,才讓人能夠不魯莽地省掉 semver 關卡。

我原本就有的政策

cooldown 那段是我寫的,早在這一切之前就存在了。它會把提議的更新延後,直到該版本公開滿一定天數為止,天數依版號跳躍的大小而定。

.github/dependabot.yml
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 7
semver-major-days: 14
semver-minor-days: 7
semver-patch-days: 3
open-pull-requests-limit: 10
ignore:
# TypeScript 7 (the native compiler) does not expose the programmatic API
# that `astro check` relies on, so upgrading breaks `pnpm check`.
- dependency-name: "typescript"
update-types: ["version-update:semver-major"]

關於這段設定,有兩件事值得直接講清楚,因為兩件都很容易弄錯。

cooldown 不適用於安全性更新。 這是 GitHub 刻意的設計:針對現行資安通報的修補,不該在佇列裡等上一週。這同時也表示,上面那段從來沒有在保護安全性這條路徑,而這件事比我當時以為的更重要。

它下面還有一段 github-actions,那段只有 default-days: 3 這個生態系會忽略 semver-*-days 這幾個鍵,所以寫在那裡會看起來像政策,實際上什麼都不做。

npm 的 PR 一個都沒開過

設定看起來是對的,所以我改從證據下手。

終端機視窗
gh pr list --author "app/dependabot" --state all --limit 20
39 build(deps): bump pnpm/action-setup from 6.0.9 to 6.0.10 MERGED 2026-08-10
2 build(deps): bump pnpm/action-setup from 6 to 6.0.9 MERGED 2026-08-03

兩個 PR,都合併了,也都來自 github-actions 生態系。來自 npm 的一個也沒有。而 pnpm outdated 說有事情要做:astro 停在 7.1.6,7.2.2 已經出了。

在這裡,兩個綠色的相依更新 PR 是最糟糕的證據,因為它們正好回答了你以為自己在問的那個問題。Dependabot 確實在運作。它開過 PR、PR 合併了、標籤也對。所有看得見的東西都說這套系統是好的。

相依關係圖是關的,而一個生態系把它遮住了

跟我一起工作的智能體去看的是儲存庫本身的狀態,而不是設定檔,答案藏在三個 API 呼叫的深處。

終端機視窗
gh api repos/{owner}/{repo}/dependency-graph/sbom --jq '.sbom.packages | length'
# 404
gh api repos/{owner}/{repo}/vulnerability-alerts -i | head -1
# HTTP/2.0 404 Not Found
gh api graphql -f query='{ repository(owner:"…", name:"…") {
dependencyGraphManifests { totalCount } } }'
# {"dependencyGraphManifests":{"totalCount":0}}

宣告檔 0 個。相依關係圖是停用的,而這正是私有儲存庫的預設值,Dependabot 需要它才能讀 package.jsonpnpm-lock.yamlgithub-actions 生態系則不需要:它在更新工作內部解析 .github/workflows/*.yml,完全不碰那張圖,而這正是在其他一切都死掉時,只有它持續運作的原因。

只有一個 Dependabot 生態系需要相依關係圖兩條路線。上方路線從 npm 的宣告檔經過相依關係圖,而該圖在私有儲存庫預設關閉,因此不會產生任何 pull request。下方路線從工作流程檔案直接進入更新工作內的解析,完全繞過相依關係圖,持續產生 pull request。能運作的下方路線,正是掩蓋了壞掉的上方路線的原因。npm 生態系package.json、pnpm-lock.yaml相依關係圖私有儲存庫預設關閉完全不會有 PRgithub-actions 生態系.github/workflows/*.yml在更新工作內解析PR 持續送來能運作的那一半,掩蓋了壞掉的另一半
能運作的那一半,掩蓋了不能運作的另一半。兩個已合併的 action 更新讀起來就是「Dependabot 有在動」,而所有以宣告檔為基礎的生態系都停擺了。

我啟用了警示,這會連帶把相依關係圖打開:

終端機視窗
gh api -X PUT repos/{owner}/{repo}/vulnerability-alerts
gh api -X PUT repos/{owner}/{repo}/automated-security-fixes

圖上的宣告檔從 0 個變成 5 個,SBOM 從 404 變成 600 個套件。這個儲存庫在此之前也從來沒有安全性更新,而它們一開就立刻產出了真正的工作:nanoidpath-to-regexp 這兩個間接相依的資安通報,兩件都在同一天修掉。

私有儲存庫的陷阱一句話就講完了。沒有任何東西會警告你。 沒有橫幅、沒有失敗的檢查、也沒有一個空狀態告訴你「把圖打開這件事就會動」。設定檔是有效的,排程有觸發,而更新工作只是找不到任何可以更新的東西。

為什麼我沒有加上 semver 關卡

npm 的更新即將開始流動,接下來理所當然的問題就是要不要只自動合併修補版。我說不要,而理由正是這整段作業替我釐清的東西。

不良的發布有兩種類型,各自需要不同的工具。

兩種失效類型,各自對應一道過濾器兩個欄位。左欄是對所有人都是壞的發布:發布數小時後被撤回、隔天早上出修正版、遭入侵的發布。它的過濾器是最小發布經過天數,因為單靠時間就能攔下這三種。右欄是只對你是壞的發布:在別處都能正常建置、只弄壞你會呼叫的那個 API、到第 30 天仍然是壞的。它的過濾器是你自己的 CI,因為等待完全看不出這些問題。對所有人都是壞的發布數小時後被撤回隔天早上就出修正版遭入侵的發布最小發布經過天數dependabot.yml 中的 cooldown:時間會濾掉它。等待本身就是機制。只對你是壞的在別處都能正常建置只弄壞你會呼叫的那個 API到第 30 天仍然是壞的你自己的 CI一道真的會執行的關卡只有你的建置看得到。等待什麼也看不出來。
最小發布經過天數與建置,並不是同一道過濾器的兩種強度。它們看到的失敗不同,而且誰也取代不了誰。

對所有人都是壞的發布,會被時間抓到。 它在幾小時內被撤回、隔天早上出修正版,或者是一個遭入侵的發布在有人發現後被拉掉。等待本身就是機制,而 cooldown 正是那個工具。在偵測這一類的問題上,我的建置沒有任何理由比整個生態系更強。

只對我是壞的發布,時間看不到。 它在別處都能正常建置,它會留著,而第 30 天看起來跟第 1 天一模一樣。唯一看得到它的是我自己的建置,因為壞掉的地方位在那個版本與這份程式碼的交集上。

semver 關卡是第二類的替代品,用在 CI 覆蓋薄弱或緩慢的地方。它說的是「次版本比修補版危險」,這句話平均而言為真,卻完全沒說這個次版本會不會弄壞這個儲存庫。如果 CI 真的會跑、也真的會擋,那麼這個替代品並沒有補上任何真實量測尚未給出的東西。

那個「如果」承擔了很重的份量,而在這裡它並不成立。

自動合併的工作流程從來沒有擋過任何東西

這個工作流程讀起來像一道關卡:

.github/workflows/dependabot-auto-merge.yml
on:
pull_request_target:
types: [opened, synchronize, reopened]
jobs:
auto-merge:
if: github.actor == 'dependabot[bot]'
steps:
# …
- name: Enable auto-merge for Dependabot PR
run: gh pr merge --auto --squash "$PR_URL"

--auto 是請 GitHub 在必要檢查通過後合併。陷阱就在那個形容詞上。這個儲存庫用的是免費的個人方案,所以分支保護與 rulesets 都回一個錯誤:

Upgrade to GitHub Pro or make this repository public to enable this feature.

沒有必要檢查,就等於沒有東西要等,所以 gh 一看到就合併了。 最近一次相依更新的時間戳把這件事講得很具體:

PR #39 在建置完成前 45 秒就合併了一條約一分鐘的時間軸。Pull request 在第 0 秒開啟,第 13 秒合併,而 Check & build 工作到第 58 秒才完成。合併與建置完成之間以網底標示,代表變更已在 main 上、卻沒有任何建置結果的 45 秒。0s15s30s45s60s從 pull request 開啟後經過的秒數PR 開啟合併Check & build 完成在 main 上但沒有建置結果的 45 秒
PR #39 在 16:35:56 開啟、16:36:09 合併。它的 Check & build 工作到 16:36:54 才結束,也就是分支已經在 main 上之後 45 秒。

有一個值得知道的二階效應,因為它改變了這件事實際上有多糟。用 GITHUB_TOKEN 做的合併不會觸發任何工作流程執行,證據就在提交本身:由機器人合併的 8fce42b 完全沒有任何檢查執行,而由人合併的 67c3d57 有一次成功的 CI push 執行。也就是說,相依更新的合併從來沒有觸發過正式環境的部署工作。一個壞更新造成的損害是一個壞掉的 main,會在某人下一次 push 時浮現,而不是一個壞掉的網站。

理所當然的修法會等到自己

任何人第一個伸手去拿的修法,都是讓工作流程去等:保留 pull_request_target,把 --auto 換成 gh pr checks --watch,然後再合併。智能體提的正是這個,接著找出了它為什麼行不通。

終端機視窗
gh pr checks 39
Deploy to Vercel (production) skipping 0
Check & build pass 50s
Vercel pass 0
auto-merge pass 9s

auto-merge 就在那份清單裡。pull_request_target 的工作本身就是 PR 上的一個檢查,所以放在它內部、等待所有檢查完成的步驟,等的是它自己正在執行的那個工作。

監看步驟位在它所監看的工作內部由三個步驟構成的循環。以 pull_request_target 觸發的自動合併工作,會以檢查的身分出現在 pull request 上。接著它等待該 pull request 上所有檢查完成,而這個集合包含它自己,所以箭頭又回到這個工作。沒有任何東西能完成,這次執行要到六小時的工作逾時才結束。自動合併工作on: pull_request_target會以檢查的身分出現在 PR 上等待所有檢查完成等待自己。六小時逾時後,該次執行失敗。gh pr checks 39 會把 auto-merge 與 Check & build 並列
循環回到它出發的那個方框。沒有任何東西能完成,這次執行不是以合併作結,而是以六小時的工作逾時作結。

--required 也不是出口:這個方案上根本沒有必要檢查,那只是原本的問題換了一張臉。

有效的做法:CI 結束之後的 workflow_run

我採用了 workflow_run 這條路。它在 CI 執行結束之後才觸發,在基底儲存庫的脈絡下帶著寫入權杖運作,不會在 PR 上掛任何檢查,也不會把執行器的時間花在空等。

.github/workflows/dependabot-auto-merge.yml
on:
workflow_run:
workflows: ["CI"]
types: [completed]
concurrency:
group: dependabot-auto-merge-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: false
jobs:
auto-merge:
if: >-
github.event.workflow_run.event == 'pull_request'
&& github.event.workflow_run.conclusion == 'success'

在這個條件下會跑四個步驟:找出 PR、拒絕在已經失敗的檢查之上合併、核准、合併。

其中兩個步驟承擔了容易在細節上出錯的部分。

head SHA 必須與 CI 測過的那個提交一致。 Dependabot 會 rebase 它的分支,而 gh pr merge 合併的是指令執行當下分支所指向的東西,不是 workflow_run 事件描述的那個。在找出 PR 的步驟裡檢查是必要的,但並不充分,因為中間還隔著兩個 API 來回:

終端機視窗
gh pr merge --squash --match-head-commit "$HEAD_SHA" "$NUMBER"

待處理中的檢查是刻意忽略的。 預覽的冒煙測試跑在部署商的時間軸上而不是 CI 的,對某個分支甚至可能永遠不會出現,所以這個步驟拒絕 failcancel 兩種 bucket,但從不等待 pending。失敗的樣子會是「沒看到冒煙結果就合併了」,這是可以補救的;「卡到逾時」則不行。

那個會讓一切變成空轉的 permissions 區塊

合併前的程式碼審查找到了一個東西,本機的試跑與 CI 都抓不到。

工作流程宣告了它需要寫入的權限:

permissions:
contents: write
pull-requests: write

只要宣告了任何一項權限,沒有列出來的每一個範圍都會變成 none。也就是說 checksstatusesactions 全都是零,而 gh pr checks 並不是一個 REST 呼叫:它送出一支固定的 GraphQL rollup 查詢,永遠會選取狀態脈絡、檢查執行,以及每個檢查套組背後的工作流程。--json 只在用戶端過濾,無法縮減實際送出去的內容。這個呼叫每一次都會失敗:

GraphQL: Resource not accessible by integration

它是失敗時擋下,所以不會有錯的東西被合併。對的東西也一樣不會被合併。

permissions:
contents: write
pull-requests: write
checks: read
statuses: read
actions: read

我的本機驗證為什麼漏掉這一點,值得記下來。我在一個持有全範圍權杖的 shell 裡跑了 gh pr checks 39 --json name,bucket,看著它回傳合理的 JSON。那證明的只是輸出的形狀,對於工作流程自己的權杖則什麼都沒有證明。 被限縮的憑證是另一份憑證。

跟著它一起出現的還有兩個比較小的發現。沒有 shell: 鍵的 run: 步驟會在沒有 pipefailbash -e {0} 下執行,所以 gh … | head -1 拿到的是 head 的結束狀態,會把一個被限流的 API 呼叫,在一次綠色的執行裡報告成「找不到 Dependabot 的 PR」。另外,被取消的檢查落在 gh 的 cancel bucket 而不是 fail,所以拒絕用的過濾條件必須把兩者都點名。

綠燈實際證明了什麼

這個儲存庫的 CI 只跑 pnpm checkpnpm build,沒有別的。這個故事裡的每一個缺陷,都是讀出來的,不是靠某個徽章變紅發現的。

新工作流程第一次真正受到考驗,會是那個等次版本 cooldown 到期後才符合資格的 astro 更新。確認只需要一道指令:

終端機視窗
gh pr view <n> --json mergedAt,statusCheckRollup

mergedAt 必須晚於 Check & build 那一項的 completedAt。這個先後顛倒正是當初讓缺陷現形的東西,所以拿它來對修正做斷言是對的。

總結

私有儲存庫的陷阱不在於東西比較難設定,而在於預設值不一樣,而且失敗是安靜的。相依關係圖是關的,所以以宣告檔為基礎的生態系從來不會運作。必要檢查根本無法存在,所以任何建立在 --auto 之上的東西,都會在看起來像在等待的同時立刻合併。

從這件事長出來的政策,比我以前會寫的那一套更單純:

問題 答案
對所有人都是壞的(撤回、修正版、遭入侵) cooldown,依 semver 距離調整
只對這個儲存庫是壞的 CI,建立在一道真的會擋的關卡上
修補版、次版本、主版本之分 不是過濾器,是薄弱 CI 的替代品
資安通報 絕不延後。cooldown 不適用

另外,對任何有 Dependabot 設定卻沒有 Dependabot 動靜的儲存庫,有兩個值得跑一次的確認:

終端機視窗
gh api repos/{owner}/{repo}/dependency-graph/sbom --jq '.sbom.packages | length'
gh pr view <n> --json mergedAt,statusCheckRollup

第一個告訴你 Dependabot 看不看得到你的相依套件。第二個告訴你你的合併關卡到底是不是一道關卡。

參考連結

分享這篇文章