# 在 GitHub Action 中修復 Dependabot 在 pnpm 上修不了的安全性警告 — GitHub 認證方式決定了解決方案

> Dependabot 沒辦法更新 pnpm 的間接相依。這篇記錄如何寫出修補用的 GitHub Action，以及認證方式的選擇為什麼決定了整個設計。

- Source: https://oharu121.com/zh-tw/blog/dependabot-transitive-pnpm-advisories-github-action-cloud-agent/
- Published: 2026-09-04T23:45:21+09:00
- Tags: pnpm, GitHub Actions, 安全, 自動化, Node.js, Claude Code

---
## 引言

Dependabot 對這個部落格的 GitHub repo 回報了**四筆高風險的安全性警告**，我很納悶它為什麼從來沒有開出帶著安全性修補的 Pull Request。我猜原因是因為那個有問題的套件是間接相依，而 Dependabot 沒辦法把它的版本升上去。

這個猜測是正確的，但背後其實有一個結構性的原因：**Dependabot 完全不支援在 pnpm 上更新間接相依。** 如果我用的是 npm，這個問題根本不會出現。放著不管的話，這些警告會永遠留著。

我試過幾種做法，包含 Claude routine，但最後採用的是一個**GitHub App 機器人**，按排程去修補它們。這篇文章涵蓋了這次的追查過程，以及我是怎麼做出最後決定的。

*Image: repo 的 Dependabot 警告頁面以 is:closed 篩選後的畫面，顯示 0 筆 Open、6 筆 Closed。四則 fast-uri 安全性警告在前一天以 fixed 關閉，另外 nanoid 與 path-to-regexp 在三週前以 fixed 關閉。每一列都標示 High，偵測來源為 pnpm-lock.yaml*

*四則 fast-uri 安全性警告在前一天以 fixed 關閉，另外 nanoid 與 path-to-regexp 在三週前以 fixed 關閉。它們全部都是在 `pnpm-lock.yaml` 中偵測到的。*

## Dependabot 為什麼從來沒開過 Pull Request

```bash
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](https://github.com/dependabot/dependabot-core/issues/13177) 這個 issue 還開著：Dependabot 會直接報錯，說它不支援在 pnpm 上更新間接相依。[pnpm#12744](https://github.com/pnpm/pnpm/issues/12744) 記錄了 `pnpm update <dep>@<version>` 對間接相依來說是個 no-op，而這正是 Dependabot 的更新程式時所執行的指令。

*Figure — TransitiveReach: 在 npm 上，Dependabot 會把上層套件升到能容納已修復的下層套件版本。pnpm 則卡在更新程式本身的指令上，什麼都不會發生。*

> **小心喔**
>
> **沒有警告不代表安全。** [dependabot-core#10534](https://github.com/dependabot/dependabot-core/issues/10534) 記錄了 pnpm v9 的 lockfile 在升級之後悄悄退出 Dependabot 的偵測範圍，而且完全沒有告知這件事。

## repo 裡早就有修法

`pnpm update` 對間接相依來說是個 no-op，這個我們修復不了。而之前採用的修法是覆寫。

```yaml title="pnpm-workspace.yaml"
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 的版本而壞掉。

> **話說**
>
> **這篇文章是以 pnpm 11 為前提撰寫的。** 覆寫該寫在哪個檔案，在 pnpm 不同版本之間並不一樣。所以參考之前請先確認自己的版本。在 pnpm 11 中，`package.json` 裡的 `pnpm.overrides` 只會輸出一行 `[WARN]` 然後被忽略，而 pnpm install 依然顯示「Already up to date」。

## 第一次嘗試：排程執行的 Claude 雲端 session

*Image: 雲端 routine 的編輯對話框，名稱是 pnpm transitive advisory patcher。指示內容要求它找出 Dependabot 無法修復的間接相依安全性警告，並開出修補用的 Pull Request，背景說明引用了 dependabot-core#13177，並指示忽略直接相依。它綁定的 repo 是 oharu121/oharu-tech-blog，模型為 Sonnet 5，設定成每天 10:07 JST 執行*

*第一次嘗試：一個每日執行的雲端 session，把整套稽核流程用自然語言寫給模型照著做。*

這個方法可以奏效。第一次執行就開出一個 Pull Request，以正確版本範圍的 key 覆寫、符合 repo 既有風格的註解區塊，以及一段準確轉寫稽核結果的說明文字。整個過程大約花了十五分鐘。

不過有一件事讓我很在意。它每次執行的時候都會印出這行：

```text
[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`](https://github.com/oharu121/oharu-tech-blog/blob/main/.github/workflows/advisory-patch.yml) 裡的一個步驟，那兩個「需要」的列則變成了 issue 起票。依序為五個步驟：

1. **安裝相依**，好讓稽核跑在 repo 實際解析出來的那棵樹上。
2. **偵測並套用。** `advisory-patch.ts` 讀取 `pnpm audit --json`，跳過 `package.json` 裡已宣告的套件，從安全性警告的內容推導出 `pkg@<X: ^X`，寫進 `pnpm-workspace.yaml`，然後 install。
3. **把需要判斷的呈報出來**，開 issue 而不是 Pull Request。這就是那兩個「需要」的列：影響執行環境的安全性警告，以及無法解決的覆寫。
4. **驗證修補。** 再稽核一次，確認覆寫確實生效。
5. **開 Pull Request**，說明文字直接轉寫稽核的欄位。

步驟 2 和步驟 4 都呼叫同一個函式 `classify()`。它讓每個被稽核的套件依序通過四道判斷，並決定修補它、呈報它，或是不動它。

*Figure — ClassifyFunnel: 順序是有意義的。直接相依不會走到覆寫那道判斷，而無法讀取的 patched 範圍也不會走到執行環境那道判斷。*

當單一套件像 `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，就會因為自己觸發自己而陷入無窮循環。

> **話說**
>
> **Pull Request 其實差一點就成功了。** 用 `GITHUB_TOKEN` *建立*的 Pull Request 確實會把檢查排進佇列，但會停在「Approve workflows to run」按鈕後面的待核准狀態。對完全自動化來說這無疑是一條死路。

*Figure — ProducerConsumer: 自動合併只是讀取 CI 早就做出的判定，所以就算 push 不會觸發任何檢查也沒關係。修補安全性警告的機器人位於上游，必須自己製造出能觸發 CI 的 push。*

Dependabot 擁有一個獨立的應用程式身分而並不是用 `GITHUB_TOKEN`，所以不受 GitHub 為了防止遞迴所設的限制。這也是為什麼它開的 Pull Request 能讓 CI 檢查跑起來。而我們該遵循的正是這個模式。

最初嘗試的是個人存取 token，但這帶來了幾個問題。PAT 借用的是我自己的身分，所以 `author.login` 沒辦法將這個自動化和我本人區分開來。自動合併只去比對一個任何人都有可能開出來的分支名稱。

## 最終採用的解決方法：GitHub App

GitHub App 因為擁有自己的身分解決了這個問題。它的安裝用 token 是每次執行時才發出，任務結束就失效，於是合併的把關又重新變成一個真正的作者身分確認。

*Image: GitHub App 的公開頁面，名稱是 Oharu Advisory Patcher，說明寫著它會針對 Dependabot 在 pnpm 上無法修補的間接相依安全性警告開出 Pull Request，並註明撤銷這個 App 會讓自動化停止*

*這段說明是寫給半年後找到這個頁面的人看的，包括撤銷這個 App 之後會帶來什麼後果。*

建立一個 GitHub App 需要四個步驟，沒有一個是 session 能代替你做的：

1. **註冊 GitHub App。** 從 Settings → Developer settings → GitHub Apps，填寫 Contents、Pull requests 和 Issues 的寫入權限。
2. **生成私鑰。** 下載一個 `.pem` 檔案。
3. **把這個 GitHub App 安裝到 repo 上。** 註冊和安裝是兩件不同的事，一個註冊了但沒安裝的 App 會在第一次 API 呼叫就失敗。
4. **設定兩個 repo secret**：GitHub App 的 Client ID，以及整份 `.pem`，包含開頭和結尾那兩行。

*Image: Oharu Advisory Patcher 的 GitHub App 安裝頁面，顯示帳號 oharu121，旁邊是一個變灰的 Installed 按鈕和設定齒輪*

*步驟 3。在 repo 成功安裝 GitHub App 之後會變成灰色的 Installed 按鈕。*

這兩個 secret 會在同一個步驟裡跟工作流程被使用，而那個步驟會發行後面所有東西都要用的 token：

```yaml title=".github/workflows/advisory-patch.yml"
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 檢查都不會觸發，而那正是這整個章節都在避免的事。

> **話說**
>
> **登入名稱是 `app/<slug>`，而不是 `<slug>[bot]`。** `gh pr list --json author` 會把應用程式機器人的登入名稱正規化，所以 Dependabot 的原始值其實是 `app/dependabot`。`[bot]` 這種寫法和網頁介面以及 `git log` 顯示出來的樣子是一樣的。

## 上線的成果

這個工作流程於每天 10:17 JST 執行。沒有安全性警告的情況它會 install、稽核、什麼都沒找到，大約一分鐘就會結束。

2026 年 9 月 5 日，它找到了這篇文章開頭的 `fast-uri` 安全性警告，寫入覆寫，在 Node 24 上驗證，然後開出 Pull Request。**自動合併接受了它並做了 squash，全程不需要任何人參與。**

*Image: 標題為 fix(deps): patch fast-uri advisory 的 Pull Request 272，標示為 Merged，由 github-actions bot 從 fix/advisory-dev-fast-uri 分支合併進 main。留言者是 oharu-advisory-patcher 並帶有 Bot 標籤*

*建立者是 GitHub App 機器人，合併的是自動合併的工作流程。兩個各自獨立的身分。*

## 總結

我們知道了 Dependabot 完全不支援在 pnpm 上更新間接相依。這就是為什麼 Dependabot 從來沒有在我的 repo 上開過任何有關安全性的 Pull Request。

用 `GITHUB_TOKEN` 推送不會觸發任何工作流程執行。所以要觸發工作流程就需要一個不是 `GITHUB_TOKEN` 的憑證。

對我來說最大的收穫不是這個修法本身，而是工具的選擇。我們最後落腳在 GitHub App，它最符合這個情境的需求。這是我第一次建立 GitHub App，之後想再深入研究。

## 參考連結

- [dependabot-core#13177，追蹤 pnpm 間接相依支援的 open issue](https://github.com/dependabot/dependabot-core/issues/13177)
- [pnpm#12744，確認 `pnpm up dep@version` 對間接相依無效的 issue](https://github.com/pnpm/pnpm/issues/12744)
- [dependabot-core#10534，pnpm v9 的 lockfile 悄悄終止 Dependabot 警告偵測](https://github.com/dependabot/dependabot-core/issues/10534)
- [pnpm 的 `overrides` 文件，包含以版本範圍為 key 的選擇器形式](https://pnpm.io/settings#overrides)
- [actions/create-github-app-token，`app-id` 已被 `client-id` 取代並標為棄用](https://github.com/actions/create-github-app-token)
