# pnpm 的版本釘選不再需要 Corepack — packageManager、devEngines，以及 GitHub 讀不到相依套件的鎖定檔

> 設定 packageManager 後，pnpm 12 會在同一個 pnpm-lock.yaml 裡用 --- 分隔出兩份 YAML 文件。本文會說明如何用 pmOnFail 保住 GitHub 的相依關係圖，以及移除 Corepack 的具體流程。

- Source: https://oharu121.com/zh-tw/blog/pnpm-12-version-pin-corepack-devengines-lockfile-dependency-graph/
- Published: 2026-10-10T09:07:11+09:00
- Tags: pnpm, GitHub, Corepack

---
## 引言

這個部落格原本使用的是 pnpm 11.3.0。升級到 pnpm 12 本身很簡單，只需要改 `package.json` 裡 `packageManager` 欄位這一行。安裝在 26ms 內就結束，還顯示了 `Already up to date`，但是我發現 `pnpm-lock.yaml` 莫名其妙多了 158 行。

現在的 pnpm-lock.yaml 裡有兩份 YAML 文件：第一份是 pnpm 12 新加的環境文件，用來釘選 pnpm 的版本；第二份則是和以前一樣，記錄套件的相依關係。**GitHub 的相依關係圖只會讀取第一份文件**，所以 GitHub 雖然看得到 pnpm 的指定版本，卻看不到任何相依套件，這個儲存庫的 Dependabot 警示也會被當成已修復而關閉。

解決方法是在 `pnpm-workspace.yaml` 裡添加一個設定：**`pmOnFail: ignore`**，它能讓鎖定檔維持單一文件。代價是 pnpm 不會再自行切換到釘選的版本，而這原本是 Corepack 負責的工作。本文會回顧版本釘選從 Corepack、pnpm 到 `devEngines` 的演變，並說明為什麼即使如此，我們還是可以拿掉 Corepack，以及最後如何改用 `devEngines`。

## 鎖定檔裡出現了兩份 YAML 文件

**如果把版本釘選在 pnpm 12，`pnpm-lock.yaml` 就會以記錄 pnpm 本身的環境文件開頭，相依關係則記錄在第二份文件中**。這個部落格的鎖定檔多出來的 158 行，就是這份環境文件：

```yaml title="pnpm-lock.yaml"
---
lockfileVersion: '9.0'

importers:

  .:
    configDependencies: {}
    packageManagerDependencies:
      pnpm:
        specifier: 12.10.1
        version: 12.10.1

packages:

  '@pnpm/exe.android-arm64@12.10.1':
    resolution: {integrity: sha512-…}
    cpu: [arm64]
    os: [android]
  # … 每個平台的執行檔各一筆，接著記錄相依關係的第二份文件從下面開始
```

寫入環境文件的條件，記載在 [pnpm 的鎖定檔說明文件](https://pnpm.io/lockfile)裡。專案裡只要宣告了任何版本的 `devEngines.packageManager`，或是透過舊有的 `packageManager` 欄位釘選 pnpm 12 以上的版本時，就會寫入環境文件。

這就是為什麼這個部落格釘選 `pnpm@11.3.0` 時**只有一份文件，釘選 `pnpm@12.10.1` 時卻變成了兩份**。

*Figure — TwoDocumentLockfile: GitHub 的剖析器讀完第一份文件就會停下來。但那份文件只列出 pnpm 和它各平台的執行檔，沒有任何專案相依套件的資訊。*

**GitHub 的相依關係圖用的 YAML 剖析器只會載入第一份文件**，因此拿到的只有環境文件，沒有任何相依關係的資訊。

在 [dependabot/dependabot-core#15904](https://github.com/dependabot/dependabot-core/issues/15904) 中，某個儲存庫把含有兩份文件的鎖定檔合併進預設分支後，一天內 SBOM（軟體物料清單）匯出就從 652 個套件掉到 14 個。剩下的 14 個是 pnpm、它的 `@pnpm/exe.*` 執行檔、workflow 用到的 action，以及儲存庫本身。另外，最初的回報也提到，Dependabot 警示在沒有任何相依更新的情況下就關閉了。

> **話說**
>
> **這個問題目前兩邊都沒有修好**
>
> dependabot-core 已在 2026-09-02 合併了自家剖析器的修正，但這個 issue 仍未關閉：2026-09-08 和 2026-09-23 的回報顯示，GitHub 的相依關係圖在修正後還是只讀第一份文件。pnpm 則在 [pnpm/pnpm#13805](https://github.com/pnpm/pnpm/issues/13805) 回應工具本來就該讀取每一份 YAML 文件，沒打算修改格式。

即使把釘選改到 `devEngines.packageManager`（`package.json` 裡較新的釘選欄位，可以指定版本範圍）**也解決不了這個問題**。我做了以下測試：

| 釘選方式 | `pnpm-lock.yaml` 裡 `---` 分隔線的數量 |
| --- | --- |
| `"packageManager": "pnpm@12.8.2"` | 2 |
| `"packageManager": "pnpm@12.8.2"` 加上 `pmOnFail: ignore` | 0 |
| `devEngines.packageManager` 精確指定 `12.8.2` | 2 |

## 這個部落格的 pnpm 升級：package.json 裡的 `packageManager`

**升級已經在使用 pnpm 11 的儲存庫，只需在 `package.json` 更改釘選、並在 `pnpm-workspace.yaml` 添加一個設定即可**。

```json title="package.json" del={1} ins={2}
  "packageManager": "pnpm@11.3.0",
  "packageManager": "pnpm@12.10.1",
```

```yaml title="pnpm-workspace.yaml" ins={1}
pmOnFail: ignore
```

[這個儲存庫的安全性警告修補工具](/zh-tw/blog/dependabot-transitive-pnpm-advisories-github-action-cloud-agent/)會解析 `pnpm audit --json`，所以 pnpm 改用 Rust 重寫之後，這個指令的輸出格式也必須維持不變。經過測試後沒有問題。

升級之後，GitHub 匯出的 SBOM 仍然列出 **621 個套件，而且沒有任何 `@pnpm/exe` 執行檔**，代表 `pmOnFail: ignore` 確實有效。GitHub 仍然只讀第一份文件，但現在鎖定檔裡只剩記錄相依套件的那一份（也就是原本的第二份文件），所以讀得到全部的相依套件；如果環境文件（第一份文件）還在，那清單上就只會剩下 pnpm 和 `@pnpm/exe` 執行檔。

## pnpm 12 改了什麼

pnpm 12 是用 Rust 重寫後的版本，基本上沿用了 pnpm 11 的指令、設定和鎖定檔格式。[官方的說法](https://pnpm.io/blog/releases/12.0)是「Upgrading should not feel like a migration」（升級感覺起來應該不像是 migration）。

官方的[基準測試](https://pnpm.io/benchmarks)顯示，在一個由大量小套件組成的測試專案上，從零開始安裝所需的時間從 pnpm 11 的 7.36 秒縮短到了 4.29 秒；在快取、鎖定檔和 `node_modules` 都已就緒的情況下，所需時間則從 396ms 縮短到了 15ms。

另外，pnpm 從 12.4.0 起也能管理 Python 套件（在 `pnpm-workspace.yaml` 設定 `python.enabled: true`），不過[官方文件](https://pnpm.io/python)仍標示為實驗性功能。

pnpm 12 在 2026-08-26 發布，這個部落格升級時最新版本是 12.10.1。主要的破壞性變更有：

- `pnpm install --frozen-lockfile false` 不再被接受，需要改寫成 `--no-frozen-lockfile`。
- `pnpm-workspace.yaml` 裡出現不支援的 key 時，會以 `ERR_PNPM_UNRECOGNIZED_WORKSPACE_SETTINGS` 失敗。
- 從 Git 主機取得的相依（例如 `github:owner/repo` 或 `git+ssh` 的寫法）一律改走主機的 HTTPS 位址，所以鎖定檔裡不會再出現 SSH 位址。

執行檔本身的形式也變了。pnpm 12 可以單獨安裝成**不需要 Node.js 的原生執行檔**。不過透過 npm 安裝時，仍然需要 Node.js 22.13 以上的版本。

真正重大的變更發生在 pnpm 11。它把設定從 `package.json` 和 `.npmrc` 搬走，並用 `allowBuilds` 取代了 `onlyBuiltDependencies`。從 pnpm 10 以前的版本升級的話會碰上這些變更，所以本文把它們整理在最後的「陷阱」章節。

## 讓 pnpm-lock.yaml 維持單一文件

**設定了 `pmOnFail: ignore` 後，pnpm 就不會在鎖定檔裡記錄自己的環境文件（第一份文件）**。[`pmOnFail`](https://pnpm.io/settings/cli#pmonfail) 是 pnpm 11 加入的設定，決定執行中的 pnpm 和釘選版本不同時要怎麼做。可以設定的值有 `download`（預設）、`error`、`warn`、`ignore`。在 `ignore` 下，pnpm 不再檢查自己的版本是否符合釘選，也不會切換版本，所以也就沒有東西需要記錄。

**只有 `ignore` 能讓鎖定檔維持單一文件**。這裡的「fail」指的是執行中的 pnpm 和專案釘選的版本不一致。不過要注意，就算版本一致，只要 `pmOnFail` 不是 `ignore`，pnpm 還是會寫入環境文件。為了讓執行中的版本和釘選不一致，我在測試時用獨立安裝的 pnpm 12.10.1，在釘選 `pnpm@12.8.2` 的專案裡執行；版本一致的那一欄，則是改成釘選 `pnpm@12.10.1` 的結果：

| `pmOnFail` | 執行中的 pnpm 和釘選版本不同時 | 版本不同時 `---` 分隔線的數量 | 版本一致時 `---` 分隔線的數量 |
| --- | --- | --- | --- |
| `download`（預設） | 切換到 12.8.2 | 2 | 2 |
| `error` | 以 `ERR_PNPM_BAD_PM_VERSION` 失敗 | —（安裝失敗） | 2 |
| `warn` | 顯示 `[WARN] This project is configured to use 12.8.2 of pnpm. Your current pnpm is v12.10.1`，然後用 12.10.1 執行 | 2 | 2 |
| `ignore` | 用 12.10.1 執行，什麼都不顯示 | 0 | 0 |

如果鎖定檔裡已經有環境文件，之後才加入這個設定，**環境文件並不會消失**。`pnpm install` 會顯示 `Already up to date`，而鎖定檔裡仍會有兩份文件。解決方法是把鎖定檔還原到修改釘選之前的版本，再重新安裝：

```bash
git checkout main -- pnpm-lock.yaml && pnpm install
# Already up to date
# Done in 34ms using pnpm v12.10.1
```

```bash
grep -c '^---$' pnpm-lock.yaml
# 0
```

代價是 pnpm 會失去自動切換版本的能力。假設電腦上安裝了獨立版本的 pnpm 12.10.1，在釘選 `pnpm@12.8.2` 的專案裡執行 `pnpm --version`。設定 `pmOnFail: ignore` 之前，它會回報專案釘選的版本；設定之後，回報的則是電腦上運行的版本 12.10.1：

```bash title="before pmOnFail: ignore"
pnpm --version
# 12.8.2
```

```bash title="after pmOnFail: ignore"
pnpm --version
# 12.10.1
```

**在 `ignore` 下，`PATH` 上的是哪個 pnpm，執行的就是哪個 pnpm**。只有在 pnpm 以外的工具讀取釘選的地方，釘選才會生效，例如 CI 上的 `pnpm/action-setup` 會安裝釘選的版本。

## 誰在讀取釘選？從 Corepack、pnpm 到 devEngines 的演變

Corepack 是 Node.js 16.9 到 24 內建的小工具，用來解決「每個專案該用哪個版本的套件管理工具」的問題：它會讀取專案宣告的版本，自動下載並執行對應的 Yarn 或 pnpm，這樣一來使用者就不需要事先安裝。

`packageManager` 原本就是 Corepack 的欄位，後來 pnpm 也能自己讀取。`devEngines.packageManager` 則是 pnpm 現在推薦、跨工具通用的欄位。要先弄清楚哪個工具會讀取哪個欄位、並以此切換版本，才能理解 `pmOnFail: ignore` 背後的代價。

*Figure — PinTimeline: 版本釘選一開始是 Corepack 的欄位。後來 pnpm 自行管理了版本切換，`devEngines` 則成為了跨工具通用的新欄位。*

Corepack 在 2021 年 9 月與 Node.js 16.9.0 同捆推出，`packageManager` 欄位也隨之出現。它的值是精確的 `pnpm@x.y.z`，後面可以再加上雜湊值。實際讀取這個欄位的，是 Corepack 放在 `PATH` 上、名叫 `pnpm` 的 shim（代理真正 pnpm 的轉接指令）。

pnpm 9.7.0 在 2024 年 8 月加入 `manage-package-manager-versions`，讓 pnpm 獨立於 Corepack 自己也能做同樣的切換。pnpm 10.0.0 在 2025 年 1 月把它改成預設開啟。

`devEngines` 是 OpenJS Foundation 在討論套件中繼資料互通性時提倡的欄位。npm 在 2024 年 10 月的 10.9.0 實作了它，pnpm 11.0.0 則在 2026 年 4 月加入 `devEngines.packageManager`，可以指定版本範圍，也能為每個欄位分別設定 `onFail`。

pnpm 的說明文件現在把 `packageManager` 稱為**舊有欄位**。從 pnpm 11.23.0 起，`pnpm init` 會同時寫入兩個欄位：可以指定版本範圍的 `devEngines.packageManager`，以及為了兼容 Corepack 所保留，精確指定版本的 `packageManager`。

*Figure — PinReaders: 每個工具讀取版本釘選的方式都有些不同。除非把 `onFail` 或 `pmOnFail` 設成 `ignore`，否則不管用哪一個欄位釘選，都會寫入環境文件，讓 GitHub 的相依關係圖看不到任何相依套件。*

在這台機器上實測的結果：

- 不透過 Corepack、直接執行的 pnpm 11.3.0 執行檔會自行切換到 `packageManager` 指定的版本，直到 `pmOnFail: ignore` 阻止它為止。
- `package.json` 裡只用 `devEngines.packageManager` 寫了精確版本時，Corepack 0.34.6 照樣執行了 pnpm 12.8.2。
- Corepack **拒絕了版本範圍**：`Invalid package manager specification in package.json (pnpm@^12.8.0); expected a semver version`。

其餘的部分是從原始碼確認的。`pnpm/action-setup` v6.1.0 會依序讀取 `version` 輸入、`devEngines.packageManager`、`packageManager`。Dependabot 會讀取兩個欄位來決定要執行哪個 pnpm，以 `packageManager` 優先，但不會幫這兩個欄位升級 pnpm 的版本（[#4830](https://github.com/dependabot/dependabot-core/issues/4830)、[#13722](https://github.com/dependabot/dependabot-core/issues/13722)）。

### 執行環境、套件管理工具、相依套件：三個層級，三種釘選

**執行環境、套件管理工具和相依套件是三個層級，各自由不同的東西釘選**。鎖定檔由 pnpm 讀取，而 pnpm 是在 Node.js 上執行，所以等到 pnpm 開始剖析鎖定檔時，Node.js 和 pnpm 的版本早就已經決定好了。

*Figure — VersionLayers: 鎖定檔無法選擇剖析它自己的程式，所以版本釘選才會放在 package.json 裡。*

這個部落格在本機上並沒有釘選執行環境這一層。`"engines": { "node": ">=24" }` 只會檢查 Node.js 的版本是否 `>=24`，**因此像我的電腦使用的是 Node.js 26 也會被放行**。執行 `pnpm build` 時出現了這則警告：

```text
00:30:29 [WARN] [@astrojs/vercel]
	The local Node.js version (26) is not supported by Vercel Serverless Functions.
	Your project will use Node.js 24 as the runtime instead.
	Consider switching your local version to 24.
```

CI 透過 `actions/setup-node` 釘選 Node.js。本機如果要套用相同的版本，只能靠 mise、asdf 或 nvm 這類會讀取版本檔的工具。Corepack 從來都沒有管理過 Node.js。

pnpm 12 的環境文件，等於讓鎖定檔超出了原本自己負責的那一層，反過來往上去釘選 pnpm 本身，而 GitHub 的相依關係圖還沒跟上這個變化。

## 移除 Corepack

**對 pnpm 使用者來說，Corepack 已經不再需要了**：

1. pnpm 從 10.0.0 起預設會自行切換到釘選的版本。
2. Corepack 不支援 `devEngines` 版本範圍的設計。
3. Node.js 經過 2025 年 3 月的 [TSC 投票](https://github.com/nodejs/TSC/pull/1697)後，在 2025-10-15 發布的 25.0.0 不再同捆發佈 Corepack。

Corepack 本身仍在維護。它發布在 npm 上，2026-08-28 時的版本是 0.36.0，維護者表示至少會維護到 Node.js 24 終止支援為止。從 Node.js 25 開始，必須另外單獨安裝，無法透過安裝 Node.js 取得。

在這台機器上，`pnpm` 指令**其實一直都是 Corepack 的 shim**。shim 是一個同樣叫做 `pnpm` 的小程式：執行它時，它會先讀取專案的釘選、下載對應版本的 pnpm，再把指令轉交給那個版本執行。下面的 `ls` 結果顯示，`pnpm` 指向的正是 Corepack 裡的檔案：

```bash
ls -la $(which pnpm)
# lrwxr-xr-x  1 root  wheel  41 Mar  3  2026 /usr/local/bin/pnpm -> ../lib/node_modules/corepack/dist/pnpm.js
```

同樣執行 `pnpm --version`，在部落格目錄裡執行時回報 12.10.1、但在家目錄裡卻回報 11.3.0，這就是由 shim 造成的。在 macOS 上，把 shim 更換成真正的 pnpm 分成三個步驟：

1. 從官方網站安裝獨立版本的 pnpm。獨立安裝腳本會讀取 `PNPM_VERSION`，把 pnpm 12 的原生執行檔（arm64 版，40 MB）放到 `~/Library/pnpm`：

   ```bash
   curl -fsSL https://get.pnpm.io/install.sh | PNPM_VERSION=12.10.1 sh -
   ```

   安裝腳本最後會列出因為 `PATH` 順序而優先被執行、自己無法取代的 shim：

   ```text
   Warning: "pnpm" on PATH is /usr/local/bin/pnpm (a Corepack shim), which comes before ~/Library/pnpm/bin.
   Your shell keeps running that pnpm, not the one pnpm installed to ~/Library/pnpm/bin.
   To finish switching, run "corepack disable pnpm" or move ~/Library/pnpm/bin ahead of /usr/local/bin in PATH.
   ```

2. 移除 shim。**Corepack 全域安裝在 `/usr/local` 底下時，shim 的擁有者是 root**，所以 `corepack disable pnpm` 需要用 `sudo` 來執行：

   ```bash out={2}
   corepack disable pnpm
   Internal Error: EACCES: permission denied, unlink '/usr/local/bin/pnpm'
   ```

   `sudo corepack disable pnpm` 移除了 `pnpm` 和 `pnpx`，Corepack 的 `yarn` shim 則保留了下來。

3. 確認 shell 找到的是哪個 pnpm：

   ```bash out={2-3}
   which pnpm && pnpm --version
   ~/Library/pnpm/bin/pnpm
   12.10.1
   ```

> **小心喔**
>
> **沒有 Corepack，`ignore` 什麼都不強制**
>
> pnpm 會用已安裝的版本執行，就算和釘選的版本不一致也不會顯示任何警告訊息。只有 CI 上的 `pnpm/action-setup` 仍會安裝釘選的版本。

步驟 3 印出的 12.10.1 剛好和這個儲存庫的釘選一致，但這只是因為安裝時指定了 `PNPM_VERSION=12.10.1`。在 `pmOnFail: ignore` 下 pnpm 不會自己切換，所以之後如果把釘選改成新的版本，電腦上的 pnpm 仍會停在 12.10.1，而且不會有任何提示。

**`pnpm self-update <version>` 能讓兩者保持一致**：在專案裡執行時，它會改寫 `packageManager`，並把已安裝的 pnpm 更新到同一個版本。在設定了 `pmOnFail: ignore`、釘選 `pnpm@12.10.1` 的專案裡改成 12.10.0 時，輸出如下，鎖定檔也仍然只有一份文件：

```bash
pnpm self-update 12.10.0
# The current project has been updated to use pnpm v12.10.0
# Successfully updated pnpm to v12.10.0
```

## 改用 `devEngines` 釘選版本

**拿掉 Corepack 之後，`packageManager` 這個 Corepack 的欄位就沒有保留的理由了**。pnpm 12 推薦的是 `devEngines.packageManager`，所以這個部落格最後把釘選搬了過去：

```json title="package.json" del={1} ins={2-8}
  "packageManager": "pnpm@12.10.1",
  "devEngines": {
    "packageManager": {
      "name": "pnpm",
      "version": "12.10.1",
      "onFail": "ignore"
    }
  },
```

`devEngines.packageManager` 有自己的 `onFail` 欄位，作用和 `pmOnFail` 相同，所以 `pnpm-workspace.yaml` 裡的 `pmOnFail: ignore` 也一起拿掉了。用只相依一個套件的暫存專案實測，結果如下：

| 釘選方式 | `pnpm-lock.yaml` 裡 `---` 分隔線的數量 |
| --- | --- |
| `devEngines` 精確指定 `12.10.1` | 2 |
| 再加上 `pnpm-workspace.yaml` 的 `pmOnFail: ignore` | 0 |
| 改用 `devEngines` 裡的 `"onFail": "ignore"` | 0 |

`pnpm self-update <version>` 一樣會改寫 `devEngines.packageManager.version`，鎖定檔也維持單一文件。這個部落格的鎖定檔在切換前後完全沒有變動。

## 從 pnpm 10 升級到 11 的陷阱

以下來自第二個儲存庫：一個使用 Docker 建置和 GitHub Actions 的 monorepo，從 pnpm 10 直接升級到了 pnpm 12，所以 pnpm 11 的所有變更都在這次升級中一口氣碰上了。

### 陷阱 1：package.json 和 .npmrc 裡的設定被默默忽略

pnpm 11 以後不再讀取 `package.json` 的 `"pnpm"` 欄位，從 `.npmrc` 也只讀取驗證和 registry 設定。`package.json` 裡的 `overrides` 區塊只會產生一則警告：

```text
[WARN] The "pnpm" field in package.json is no longer read by pnpm. The following keys were ignored: "pnpm.overrides". See https://pnpm.io/settings for the new home of each setting.
```

`.npmrc` 裡的 `shamefully-hoist=true` 則**連一則訊息都沒有**。在 pnpm 12.10.1 上試著安裝後，`node_modules/.modules.yaml` 裡記錄的是 `"publicHoistPattern": []`，代表這個設定根本沒有生效。原本靠 hoisting 才解析得到的 import（幽靈相依）在執行時就會失效。`overrides` 和 `shamefully-hoist` 都需要改成 camelCase，搬到 `pnpm-workspace.yaml`：

```yaml title="pnpm-workspace.yaml"
overrides:
  '@faker-js/faker': ^8.4.1

shamefullyHoist: true
```

就算 pnpm 沒有讀到這些設定，安裝仍會回報成功，所以要主動去找警告，而不是等到執行時出錯後才發現。

### 陷阱 2：pnpm 10 略過的建置腳本，現在會讓安裝失敗

**`allowBuilds` 取代了 `onlyBuiltDependencies`，而 `strictDepBuilds` 現在預設為 true**。有建置腳本、但沒有列在 `allowBuilds` 裡的相依套件會讓安裝失敗；pnpm 10 則會一聲不響地略過這些腳本：

```text
Error: ERR_PNPM_IGNORED_BUILDS

  × installing dependencies
  ╰─▶ Ignored build scripts: @firebase/util@1.14.0, classic-level@1.4.1, core-
      js@3.48.0, protobufjs@7.5.4, typeorm-fixtures-cli@3.1.0
  help: Run "pnpm approve-builds" to pick which dependencies should be allowed
        to run scripts.
```

把每一個都列成 `false`，就能完整保留 pnpm 10 的行為，也就是只有原本允許清單裡的套件會執行建置腳本：

```yaml title="pnpm-workspace.yaml"
allowBuilds:
  grpc-tools: true
  sharp: true
  '@firebase/util': false
  classic-level: false
  core-js: false
  protobufjs: false
  typeorm-fixtures-cli: false
```

### 陷阱 3：新加的版本釘選讓 `--frozen-lockfile` 失敗

在 pnpm 12 的專案加上 `packageManager`，會改變鎖定檔必須包含的內容。有兩個子專案各自有鎖定檔，但是我只加了版本釘選而沒有重新安裝，結果 CI 用 `--frozen-lockfile` 安裝時被拒絕了：

```text
  × resolve package manager dependencies
  ╰─▶ Cannot update packageManagerDependencies with "frozen-lockfile" because
      the lockfile is not up to date
```

要讓 CI 的 `--frozen-lockfile` 安裝通過，有兩個做法可以做到：

1. **在每個專案執行一次不加任何 flag，單純的 `pnpm install`**，這會讓 pnpm 把環境文件寫進鎖定檔，接著再把更新後的鎖定檔提交到儲存庫。
2. **設定 `pmOnFail: ignore`，或在 `devEngines.packageManager` 裡設定 `"onFail": "ignore"`**。這樣 pnpm 就不會把環境文件寫進鎖定檔，因此這個錯誤從一開始就不會發生；這個部落格最後採用的是 `onFail` 的做法。

不過選擇 `pmOnFail: ignore` 也有代價：`--frozen-lockfile` 原本能幫忙檢查 pnpm 版本，現在這項檢查也跟著消失了。鎖定檔不再記錄 pnpm 的版本，所以**之後就算提高釘選的版本，`pnpm-lock.yaml` 也不會改變**，`--frozen-lockfile` 也就沒有可以比對的東西。`pnpm self-update` 過去曾在 `ignore` 下把環境文件寫回去（[pnpm/pnpm#12228](https://github.com/pnpm/pnpm/issues/12228)，已修正），所以每次修改釘選之後，都需要再算一次分隔線：

```bash
grep -c '^---$' pnpm-lock.yaml
# 0
```

### 陷阱 4：pnpm/action-setup@v4 無法安裝 pnpm 12

**`pnpm/action-setup` 從 v6.0.0 開始支援 pnpm 11，從 v6.1.0 開始支援 pnpm 12**。升級到 `@v6` 並刪除 `version:` 輸入，action 就會從 `package.json` 讀取釘選。如果同時保留 `version:` 輸入和 `package.json` 的釘選，一旦兩邊的版本不一致，就會以 `Multiple versions of pnpm specified` 失敗。

Docker 映像檔的處理則和刪除 `version:` 輸入相反：要**用 `npm install -g pnpm@12.8.2` 精確指定版本**，而不是寫 `pnpm@12`。這個指令是透過 npm 執行的，而 npm 不會讀取 pnpm 的 `minimumReleaseAge`。只指定主要版本的話，可能會裝到幾分鐘前才剛發布的版本。

### 陷阱 5：Vercel 的建置映像檔只到 pnpm 10

[Vercel 的套件管理工具說明文件](https://vercel.com/docs/package-managers)列出 pnpm 6 到 10，並依照 `lockfileVersion` 選擇版本。只有在用 `ENABLE_EXPERIMENTAL_COREPACK=1` 啟用 Corepack 時，Vercel 才會讀取 `packageManager`。要求支援 pnpm 11 和 12 的 [vercel/vercel#17434](https://github.com/vercel/vercel/issues/17434) 仍未關閉。

這個部落格沒有遇到這個問題：`vercel build` 是在 GitHub Actions 裡、`pnpm/action-setup` 之後才執行，所以 Vercel CLI 會直接用 `PATH` 上已有的 pnpm 安裝相依套件。pnpm 12 第一次部署到 production 環境時的記錄同時顯示了兩件事：Vercel 依 `lockfileVersion` 判斷為 pnpm@10.x，實際執行的卻是 `PATH` 上的 pnpm 12.10.1：

```text
Detected `pnpm-lock.yaml` version 9 generated by pnpm@10.x with package.json#packageManager pnpm@12.10.1
Installing dependencies...
? Verifying lockfile against supply-chain policies (616 entries)...
✓ Lockfile passes supply-chain policies (616 entries in 2.3s)
Lockfile is up to date, resolution step is skipped
Done in 2.6s using pnpm v12.10.1
```

由 Vercel 本身的 Git 整合建置的專案，**用的則是映像檔裡的 pnpm**，也就是最多只到 pnpm 10。

## 總結

- pnpm 12 沿用了 pnpm 11 的設定和鎖定檔格式，已經在用 pnpm 11 的儲存庫只要在 `package.json` 修改釘選、並在 `pnpm-workspace.yaml` 加上一個設定就能升級。
- **pnpm 12 的釘選會在 `pnpm-lock.yaml` 的相依關係記錄前面寫入環境文件，而 GitHub 的相依關係圖只會讀第一份文件**。`devEngines.packageManager` 也會觸發同樣的情況。
- `pmOnFail: ignore` 能讓鎖定檔維持單一文件；`download`、`error`、`warn` 都會寫出兩份，就算版本一致也一樣。事後才加入的話，既有的環境文件不會消失，要還原先前的鎖定檔再重新安裝。
- 這個設定會關掉 pnpm 的自行切換，而那原本是 Corepack 的工作。`packageManager` 一開始是 Corepack 的欄位，`devEngines.packageManager` 則是 pnpm 現在推薦、跨工具通用的欄位。
- 對 pnpm 使用者來說，Corepack 已經沒有必要，而且 Node.js 25 以後不再內建。沒有 Corepack 又設定了 `pmOnFail: ignore` 時，只有 CI 會強制執行釘選，而 `pnpm self-update <version>` 能讓已安裝的 pnpm 和釘選保持一致。
- 這個部落格最後把釘選改成 `devEngines.packageManager`，並用它自己的 `onFail: "ignore"` 取代 `pmOnFail`。
- 從 pnpm 10 升級時，要把 `package.json` 和 `.npmrc` 的設定搬到 `pnpm-workspace.yaml`、在 `allowBuilds` 列出每一個建置腳本、加上釘選後重新產生鎖定檔（或改用 `pmOnFail: ignore`），並把 `pnpm/action-setup` 升級到 v6.1.0。

## 參考連結

- [pnpm 12.0 發布文章](https://pnpm.io/blog/releases/12.0)
- [pnpm 11.0 發布文章](https://pnpm.io/blog/releases/11.0)
- [pnpm 官方基準測試](https://pnpm.io/benchmarks)
- [pnpm 的 Python 相依支援（實驗性功能）](https://pnpm.io/python)
- [從 pnpm v10 遷移，包含 v11 的設定 codemod](https://pnpm.io/migration)
- [pnpm 鎖定檔說明文件，包含寫入環境文件的條件](https://pnpm.io/lockfile)
- [pnpm 設定：pmOnFail](https://pnpm.io/settings/cli#pmonfail)
- [pnpm 的 package.json 說明文件：devEngines.packageManager 與舊有的 packageManager 欄位](https://pnpm.io/package_json)
- [dependabot/dependabot-core#15904: pnpm v12 env lockfile document is parsed as an empty dependency graph](https://github.com/dependabot/dependabot-core/issues/15904)
- [pnpm/pnpm#13805: the env lockfile document makes pnpm-lock.yaml unreadable to single-document consumers](https://github.com/pnpm/pnpm/issues/13805)
- [Node.js TSC 停止散布 Corepack 的投票](https://github.com/nodejs/TSC/pull/1697)
- [Corepack README](https://github.com/nodejs/corepack#readme)
- [pnpm/action-setup 的發布紀錄](https://github.com/pnpm/action-setup/releases)
- [Vercel 的套件管理工具文件](https://vercel.com/docs/package-managers)
