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

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

近黑色卡片上的 pnpm 標誌,白色外框的正方形分割成 3x3 網格,由橘色與深灰色方塊組成,其中一格留白,旁邊是白色的 pnpm 字樣
本頁目錄

引言

這個部落格原本使用的是 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 行,就是這份環境文件:

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 的鎖定檔說明文件裡。專案裡只要宣告了任何版本的 devEngines.packageManager,或是透過舊有的 packageManager 欄位釘選 pnpm 12 以上的版本時,就會寫入環境文件。

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

兩份文件構成的 pnpm-lock.yaml,以及 GitHub 讀取的範圍鎖定檔被畫成一個包含兩份 YAML 文件的檔案。第一份是環境文件,只記錄 pnpm 與各平台的執行檔;第二份則是相依關係記錄。從 GitHub 相依關係圖出發的箭頭只指向第一份,結果是看到 0 個相依套件,警示也隨之關閉;第二份文件則標示為不會被讀取。pnpm-lock.yaml---文件 1:環境packageManagerDependencies: pnpm 12.10.1@pnpm/exe.* 各平台的執行檔---文件 2:相依關係記錄importers、packages、snapshots專案的所有相依套件不會被讀取GitHub 相依關係圖讀取看到 0 個相依套件警示被當成已修復而關閉
GitHub 的剖析器讀完第一份文件就會停下來。但那份文件只列出 pnpm 和它各平台的執行檔,沒有任何專案相依套件的資訊。

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

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

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

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

這個部落格的 pnpm 升級:package.json 裡的 packageManager

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

package.json
"packageManager": "pnpm@11.3.0",
"packageManager": "pnpm@12.10.1",
pnpm-workspace.yaml
pmOnFail: ignore

這個儲存庫的安全性警告修補工具會解析 pnpm audit --json,所以 pnpm 改用 Rust 重寫之後,這個指令的輸出格式也必須維持不變。經過測試後沒有問題。

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

pnpm 12 改了什麼

pnpm 12 是用 Rust 重寫後的版本,基本上沿用了 pnpm 11 的指令、設定和鎖定檔格式。官方的說法是「Upgrading should not feel like a migration」(升級感覺起來應該不像是 migration)。

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

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

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 是 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.222
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 執行22
ignore用 12.10.1 執行,什麼都不顯示00

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

終端機視窗
輸入
git checkout main -- pnpm-lock.yaml && pnpm install
輸出
Already up to date
Done in 34ms using pnpm v12.10.1
終端機視窗
輸入
grep -c '^---$' pnpm-lock.yaml
輸出
0

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

before pmOnFail: ignore
輸入
pnpm --version
輸出
12.8.2
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 背後的代價。

pnpm 版本釘選的時間軸由上而下排列七個附日期的事件。2021 年 9 月,Node.js 16.9.0 內建 Corepack 與 packageManager 欄位。2024 年 8 月,pnpm 9.7.0 在手動開啟時能自行切換到釘選的版本。2024 年 10 月,npm 10.9.0 實作 devEngines。2025 年 1 月,pnpm 10.0.0 預設會自行切換版本。2025 年 10 月,Node.js 25.0.0 不再內建 Corepack。2026 年 4 月,pnpm 11 加入 devEngines.packageManager 與 pmOnFail,並把 packageManager 稱為舊有欄位。2026 年 8 月,pnpm 12 的釘選版本會在鎖定檔寫入環境文件。2021-09Node.jsNode.js 16.9.0 內建 Corepack 與 packageManager 欄位2024-08pnpmpnpm 9.7.0 能自行切換到釘選的版本(需手動開啟)2024-10npmnpm 10.9.0 實作 devEngines2025-01pnpmpnpm 10.0.0 預設會自行切換版本2025-10Node.jsNode.js 25.0.0 不再內建 Corepack2026-04pnpmpnpm 11:devEngines.packageManager 與 pmOnFail,packageManager 改稱舊有欄位2026-08pnpmpnpm 12:釘選版本會在鎖定檔寫入環境文件
版本釘選一開始是 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。

哪些工具讀取哪個 pnpm 釘選欄位表格的列是讀取方,欄是 packageManager 欄位、固定版本的 devEngines 與版本範圍的 devEngines。Corepack 讀取 packageManager,沒有時改用固定版本的 devEngines,遇到版本範圍則會失敗。pnpm 與 pnpm/action-setup 三種都會讀取,並以 devEngines 優先;除非 pmOnFail 或 devEngines 自己的 onFail 設成 ignore,否則 pnpm 會切換到釘選的版本。Dependabot 不論哪一種都會用釘選的 pnpm 執行,但不會更新它。GitHub 相依關係圖那一列橫跨所有欄位:它只讀取鎖定檔的第一份文件,因此任何會寫入環境文件的釘選方式,都會讓 GitHub 看不到任何相依套件。packageManagerdevEngines(固定版本)devEngines(版本範圍)Corepack讀取後下載該版本的 pnpm沒有 packageManager時才會使用失敗:expected asemver versionpnpm除非 pmOnFail: ignore,否則會切換版本除非 onFail: ignore,否則會切換版本解析版本範圍後記錄到鎖定檔pnpm/action-setup沒有 version 輸入時安裝該版本優先於packageManager把版本範圍交給pnpm self-updateDependabot用該版本 pnpm 執行,但不會更新它用該版本 pnpm 執行,但不會更新它用最新的符合版本執行,但不會更新它GitHub 相依關係圖只讀取鎖定檔的第一份文件,因此任何會寫入環境文件的釘選方式,都會讓 GitHub 看不到任何相依套件
每個工具讀取版本釘選的方式都有些不同。除非把 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、#13722)。

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

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

三個層級,以及各自由誰釘選三個層級由上而下堆疊。相依套件由 pnpm-lock.yaml 裡的相依關係記錄釘選,並由 pnpm 讀取。套件管理工具 pnpm 由 packageManager 或 devEngines.packageManager 釘選,從 pnpm 12 起也由同一個 pnpm-lock.yaml 裡的環境文件釘選,圖中用括號標出這兩者是同一個檔案。pnpm 在執行環境 Node.js 上執行,Node.js 在 CI 上由 actions/setup-node 釘選,本機則由 mise、asdf 或 nvm 釘選,engines 只會檢查版本範圍。由誰釘選相依套件astro、sharp 等pnpm-lock.yaml 的相依關係記錄套件管理工具pnpm 12.10.1packageManager 或 devEngines.packageManagerpnpm-lock.yaml 的環境文件pnpm 12 新增執行環境Node.js 24CI 上由 actions/setup-node本機由 mise、asdf 或 nvmengines 只會檢查版本範圍pnpm 讀取鎖定檔pnpm 在 Node.js 上執行同一個 pnpm-lock.yaml
鎖定檔無法選擇剖析它自己的程式,所以版本釘選才會放在 package.json 裡。

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

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 投票後,在 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 裡的檔案:

終端機視窗
輸入
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:

    終端機視窗
    curl -fsSL https://get.pnpm.io/install.sh | PNPM_VERSION=12.10.1 sh -

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

    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 來執行:

    終端機視窗
    輸入
    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:

    終端機視窗
    輸入
    which pnpm && pnpm --version
    輸出
    ~/Library/pnpm/bin/pnpm
    12.10.1

步驟 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 時,輸出如下,鎖定檔也仍然只有一份文件:

終端機視窗
輸入
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,所以這個部落格最後把釘選搬了過去:

package.json
"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.12
再加上 pnpm-workspace.yaml 的 pmOnFail: ignore0
改用 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 區塊只會產生一則警告:

[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:

pnpm-workspace.yaml
overrides:
'@faker-js/faker': ^8.4.1
shamefullyHoist: true

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

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

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

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 的行為,也就是只有原本允許清單裡的套件會執行建置腳本:

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 安裝時被拒絕了:

× 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,已修正),所以每次修改釘選之後,都需要再算一次分隔線:

終端機視窗
輸入
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 的套件管理工具說明文件列出 pnpm 6 到 10,並依照 lockfileVersion 選擇版本。只有在用 ENABLE_EXPERIMENTAL_COREPACK=1 啟用 Corepack 時,Vercel 才會讀取 packageManager。要求支援 pnpm 11 和 12 的 vercel/vercel#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:

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。

參考連結

分享這篇文章