在 GitHub Action 中修復 Dependabot 在 pnpm 上修不了的安全性警告 — GitHub 認證方式決定了解決方案
Dependabot 沒辦法更新 pnpm 的間接相依。這篇記錄如何寫出修補用的 GitHub Action,以及認證方式的選擇為什麼決定了整個設計。

本頁目錄
引言
Dependabot 對這個部落格的 GitHub repo 回報了四筆高風險的安全性警告,我很納悶它為什麼從來沒有開出帶著安全性修補的 Pull Request。我猜原因是因為那個有問題的套件是間接相依,而 Dependabot 沒辦法把它的版本升上去。
這個猜測是正確的,但背後其實有一個結構性的原因:Dependabot 完全不支援在 pnpm 上更新間接相依。 如果我用的是 npm,這個問題根本不會出現。放著不管的話,這些警告會永遠留著。
我試過幾種做法,包含 Claude routine,但最後採用的是一個GitHub App 機器人,按排程去修補它們。這篇文章涵蓋了這次的追查過程,以及我是怎麼做出最後決定的。

pnpm-lock.yaml 中偵測到的。Dependabot 為什麼從來沒開過 Pull Request
輸入gh api repos/oharu121/<repo-name>/automated-security-fixes輸出{"enabled":true,"paused":false}透過這個指令可以確認 Dependabot 安全性功能本身是開啟的。即便如此,Dependabot 從來沒有開出任何安全性 Pull Request 。其實這不是我第一次碰到這種問題。前兩次的間接相依警告,nanoid 和 path-to-regexp,都是手動修補的。
原因出在 pnpm,而不是間接相依本身。dependabot-core#13177 這個 issue 還開著:Dependabot 會直接報錯,說它不支援在 pnpm 上更新間接相依。pnpm#12744 記錄了 pnpm update <dep>@<version> 對間接相依來說是個 no-op,而這正是 Dependabot 的更新程式時所執行的指令。
repo 裡早就有修法
pnpm update 對間接相依來說是個 no-op,這個我們修復不了。而之前採用的修法是覆寫。
overrides: nanoid@<3.3.18: ^3.3.18 path-to-regexp@<6.3.0: ^6.3.0覆寫定義在 pnpm-workspace.yaml 裡。冒號的左邊是關鍵,兩種寫法表達的意義完全不同:
| 寫法 | 會匹配什麼 | 存續期間 |
|---|---|---|
nanoid@<3.3.18: ^3.3.18 | 只匹配解析結果低於 3.3.18 的情況 | 版本超過 3.3.18 之後就失效 |
nanoid: ^3.3.18 | 每一個 nanoid | 永久 |
這個 key 是篩選器,不是單純的名稱。如果 以版本範圍當 key ,則只有在當前版本低於修補版本的時候才生效,高於修補版本後就會自動退場;而 單純寫套件名稱 會把相依永久鎖死,等到某天依存需要 nanoid 4.x 時,它會被強制拉回 3.x 的版本而壞掉。
第一次嘗試:排程執行的 Claude 雲端 session

這個方法可以奏效。第一次執行就開出一個 Pull Request,以正確版本範圍的 key 覆寫、符合 repo 既有風格的註解區塊,以及一段準確轉寫稽核結果的說明文字。整個過程大約花了十五分鐘。
不過有一件事讓我很在意。它每次執行的時候都會印出這行:
[WARN] Unsupported engine: wanted: {"node":">=24"} (current: {"node":"v22.22.2","pnpm":"11.3.0"})Cloud session 跑的是 Node 22,而這個 repo 要求 node >=24。也就是說,就算 pnpm check 和 pnpm build 在 Cloud session 都是綠燈,也不代表在實際上線環境能得到一樣的結果。需要再重新跑一次 CI 才能真的驗證。
接著我問了一個改變整個設計方向的問題:這裡真的需要 AI 嗎,還是可以做成完全機械化的流程?
| 步驟 | 需要人為判斷嗎? |
|---|---|
執行 pnpm audit --json | 不需要 |
跳過 package.json 裡已經宣告的套件 | 不需要,只是查表 |
導出 pkg@<X: ^X | 不需要,版本本來就是 JSON 裡的一個欄位 |
| 寫入覆寫、install、check、build | 不需要 |
| 寫 Pull Request 的說明文字 | 不需要,只是把稽核結果轉寫出來 |
| 判斷影響執行環境的覆寫是否需要額外的確認 | 需要 |
| 診斷一個無法解決的覆寫 | 需要 |
需要判斷的那兩項很少發生,而這套設計原本就沒打算讓 Agent 直接動手處理任何一項。換句話說,它每天做的都是文書性的工作,為了那極少見的例外待命。而就算例外出現,Agent 自己也無法解決,需要呈報給人類。
工作流程怎麼跑
上面表格裡每一個「不需要」的列都成了 advisory-patch.yml 裡的一個步驟,那兩個「需要」的列則變成了 issue 起票。依序為五個步驟:
- 安裝相依,好讓稽核跑在 repo 實際解析出來的那棵樹上。
- 偵測並套用。
advisory-patch.ts讀取pnpm audit --json,跳過package.json裡已宣告的套件,從安全性警告的內容推導出pkg@<X: ^X,寫進pnpm-workspace.yaml,然後 install。 - 把需要判斷的呈報出來,開 issue 而不是 Pull Request。這就是那兩個「需要」的列:影響執行環境的安全性警告,以及無法解決的覆寫。
- 驗證修補。 再稽核一次,確認覆寫確實生效。
- 開 Pull Request,說明文字直接轉寫稽核的欄位。
步驟 2 和步驟 4 都呼叫同一個函式 classify()。它讓每個被稽核的套件依序通過四道判斷,並決定修補它、呈報它,或是不動它。
當單一套件像 fast-uri 那樣同時帶著四則安全性警告時,覆寫必須一次解決全部四則,所以 classify() 取的是這一組裡最高的修補版本下限,而不是最先讀到的那一個。
會改變這條判斷鏈的只有步驟 4,原因就在覆寫那道判斷。步驟 4 執行的時候,步驟 2 早就把這個套件寫進 pnpm-workspace.yaml 了,於是那道判斷會把步驟 4 正在詢問的那個套件排除掉。不管 pnpm install 實際解析出的是什麼,步驟 4 都會回報「一切正常」。 用 --verify 執行這個腳本只會跳過覆寫那道判斷,其餘三道照常運作。
執行工作流程時憑證為什麼重要
在這裡 GitHub Action 很明顯比 Claude 的 cloud session 來得更好。它用的是 NODE_VERSION: "24"和 CI 一樣,所以它的驗證結果和實際環境跑的結果是一樣的。它幾秒鐘就能跑完。而它的邏輯放在一個 .ts 檔案裡。pnpm check 會對它做型別檢查任何人都能在本機直接執行。
不過困難的地方在於,這個工作流程必須自己開出自己能被 CI 檢查的 Pull Request,而 GitHub Actions 不允許一個工作流程去啟動另一個工作流程:用 GITHUB_TOKEN 去 push 不會觸發任何工作流程被執行。 Pull Request 還是會開起來,沒有發生的是那個負責檢查它的執行。GitHub 這樣設計,是因為一個由 push 觸發的工作流程如果自己也 push,就會因為自己觸發自己而陷入無窮循環。
Dependabot 擁有一個獨立的應用程式身分而並不是用 GITHUB_TOKEN,所以不受 GitHub 為了防止遞迴所設的限制。這也是為什麼它開的 Pull Request 能讓 CI 檢查跑起來。而我們該遵循的正是這個模式。
最初嘗試的是個人存取 token,但這帶來了幾個問題。PAT 借用的是我自己的身分,所以 author.login 沒辦法將這個自動化和我本人區分開來。自動合併只去比對一個任何人都有可能開出來的分支名稱。
最終採用的解決方法:GitHub App
GitHub App 因為擁有自己的身分解決了這個問題。它的安裝用 token 是每次執行時才發出,任務結束就失效,於是合併的把關又重新變成一個真正的作者身分確認。

建立一個 GitHub App 需要四個步驟,沒有一個是 session 能代替你做的:
- 註冊 GitHub App。 從 Settings → Developer settings → GitHub Apps,填寫 Contents、Pull requests 和 Issues 的寫入權限。
- 生成私鑰。 下載一個
.pem檔案。 - 把這個 GitHub App 安裝到 repo 上。 註冊和安裝是兩件不同的事,一個註冊了但沒安裝的 App 會在第一次 API 呼叫就失敗。
- 設定兩個 repo secret:GitHub App 的 Client ID,以及整份
.pem,包含開頭和結尾那兩行。

這兩個 secret 會在同一個步驟裡跟工作流程被使用,而那個步驟會發行後面所有東西都要用的 token:
permissions: contents: read
jobs: patch: runs-on: ubuntu-latest steps: - uses: actions/create-github-app-token@v3.2.0 id: app-token with: client-id: ${{ secrets.ADVISORY_APP_CLIENT_ID }} private-key: ${{ secrets.ADVISORY_APP_PRIVATE_KEY }} permission-contents: write permission-pull-requests: write permission-issues: write
- uses: actions/checkout@v7 with: token: ${{ steps.app-token.outputs.token }}這段裡有三個地方很容易出錯。第一,是 client-id,不是 app-id,因為 v3 已經把app-id標為棄用。第二,工作流程層級的 permissions 要維持在 contents: read,因為寫入權限屬於 App 的 token;寫在這裡只會把權限發給一個沒有任何步驟會用到的 GITHUB_TOKEN。第三,checkout 必須明確設定成工作流生成的 token。它的預設值是 GITHUB_TOKEN,如果用了預設值則後面的 push 就什麼 CI 檢查都不會觸發,而那正是這整個章節都在避免的事。
上線的成果
這個工作流程於每天 10:17 JST 執行。沒有安全性警告的情況它會 install、稽核、什麼都沒找到,大約一分鐘就會結束。
2026 年 9 月 5 日,它找到了這篇文章開頭的 fast-uri 安全性警告,寫入覆寫,在 Node 24 上驗證,然後開出 Pull Request。自動合併接受了它並做了 squash,全程不需要任何人參與。

總結
我們知道了 Dependabot 完全不支援在 pnpm 上更新間接相依。這就是為什麼 Dependabot 從來沒有在我的 repo 上開過任何有關安全性的 Pull Request。
用 GITHUB_TOKEN 推送不會觸發任何工作流程執行。所以要觸發工作流程就需要一個不是 GITHUB_TOKEN 的憑證。
對我來說最大的收穫不是這個修法本身,而是工具的選擇。我們最後落腳在 GitHub App,它最符合這個情境的需求。這是我第一次建立 GitHub App,之後想再深入研究。




