Cholate
← 回首頁

2026-07-10 · 8 min

pnpm 封鎖了 sharp,我的 CI 就死了

pnpm 預設封鎖依賴的安裝期腳本。本機沒事而雲端 build 直接死掉,多半是因為你本機那次 install 根本沒安裝。這篇講清楚 allowBuilds 該怎麼填。

為什麼#

那天我只是加了一個 wrangler 到 devDependencies,想把靜態站部署到 Cloudflare。本機 pnpm install 跑完,一片祥和。commit、push,然後雲端 build 在依賴安裝那一步就掛了——連 build 指令都還沒輪到。

[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: sharp@0.34.5, sharp@0.35.3

Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.

我第一個念頭是「本機明明好好的,雲端憑什麼不一樣」。後來我把設定改回出事的狀態、在本機反覆重跑,得到一個很不舒服的結果:同一份設定,pnpm install 有時 exit 0,有時 exit 1

pnpm install --frozen-lockfile        # 有時:Already up to date → exit 0
pnpm install --frozen-lockfile        # 有時:ERR_PNPM_IGNORED_BUILDS → exit 1

差別不在本機與雲端,而在你的 node_modules 當下處於什麼狀態。那道閘門檢查的是「有沒有待建置的安裝期腳本」。如果那些原生檔在你機器上早就建好、狀態也記錄過了,pnpm 就沒事可做,印一行 Already up to date 便走人——閘門根本沒被觸發。

而 CI 通常是全新的 clone、node_modules 從零開始,於是每次都得真的安裝、每次都得決定要不要跑那些腳本,也就每次都撞上閘門。(我原本以為雲端一定乾淨。後來才知道 Cloudflare 會還原 node_modules 快取——那讓我在另一件事上踩了坑。所以真正的規則不是「雲端比較嚴格」,而是只要那次安裝有待建置的腳本,閘門就會觸發。)

這比「本機寬鬆、雲端嚴格」更值得記住:你本機的綠燈,可能只是因為它什麼都沒做。

sharp 是誰?為什麼有兩個版本號?我查了一下:

pnpm why sharp
# sharp@0.35.3  ← astro 與我自己的縮圖腳本都用它(直接依賴)
# sharp@0.34.5  └─ miniflare ← wrangler(間接依賴)

一個是我自己裝的,一個是 wrangler 透過 miniflare 帶進來的,順便還帶了 workerd。我只加了一個依賴,卻多出兩個要審核的東西——而且其中一個我從來沒打算用。

這不是 bug,是設計#

sharp 這種處理原生二進位檔的套件,會宣告安裝期的生命週期腳本(它宣告的是 build,不是 postinstall——這個區別待會會變得很重要)。而 npm 長年的預設行為是:你裝的每一個依賴,連同它的所有間接依賴,安裝期腳本一律無條件執行。方便,但那也正是供應鏈攻擊最愛的入口——一個你沒聽過的四層間接依賴,可以在你的機器上跑任意程式碼。(這個預設後來也變了:現行 npm 的文件寫著 Dependency install scripts are blocked by default,改用 allowScripts 欄位放行。所以這已經不是 pnpm 獨有的立場,而是整個生態的方向。)

pnpm 有一個叫 strictDepBuilds 的設定,10.3 就加進來了,但預設打開是 pnpm 11 的事(v11.0.0 的 release notes 明列這項變更)。打開之後的意思是:帶安裝期腳本的依賴,預設不跑,而且未經你明確審核的話,install 直接非零退出。它強迫你為每一個要跑腳本的套件簽名畫押。我這個專案釘的是 pnpm@11.5.0,所以吃滿這條規則。

所以那個紅字不是 pnpm 在找碴,是它在問:「sharp 要在你的機器上執行程式碼,你批准嗎?」

我覺得這個設計是對的,代價是遷移時會被絆一次。而且絆的位置很討厭——在雲端,不在本機。

怎麼做#

pnpm 11 的設定放在 pnpm-workspace.yaml不是 package.json)。即使你的專案不是 monorepo,也用這個檔:

allowBuilds:
  esbuild: true
  sharp: true
  workerd: false

一個套件一個布林值。true = 允許跑安裝期腳本,false = 不跑。

這裡我要更正一個自己也曾經深信的直覺:「有原生檔的套件一定要放行,否則會壞」是錯的。 我把 sharp 設成 false、強迫重裝,然後叫它產生一張圖——它照跑不誤。原因是 sharp 的平台原生檔是透過 optional dependencies(@img/sharp-linux-x64 那一族)預編譯好送來的,那個 build 腳本只在需要從原始碼編譯時才派上用場。

所以正確的起手式不是「猜它需不需要」,而是預設拒絕,確認需要才放行

但這條規則有個陷阱值得說清楚:「裝得起來、跑得動」不等於「沒事」。被拒絕的安裝期腳本未必會讓你看到錯誤——它可能只是讓套件悄悄退回較慢的 fallback、少掉某個平台專屬的能力。我上面那個 sharp: false 的實驗只證明了「在我這台 Linux 機器上、我用到的那條路徑」沒壞,不能推論到你的平台或你用到的功能。

所以完整的做法是:預設 false → 讀一下那個腳本到底在幹嘛、翻一下官方文件 → 在每個目標平台上把你真的會用到的能力測過一遍 → 再決定。安裝成功不是證據。

回頭看我上面那三行:

  • workerdfalse。我是純靜態部署,只上傳 dist/,沒有 Worker 執行期。代價是本機的 wrangler dev 不能用——我不用它,這筆交易划算。
  • sharpesbuildtrue。老實說,以 sharp 而言我在這台試過 false 也能動;我留著 true,是因為我信任它,而且不想在某天換平台、預編譯檔缺席時,才發現自己一直靠著一條沒測過的 fallback。這是信任決策,不是功能需求——把兩者分清楚,你才知道自己在批准什麼。

這裡有個真的會咬人的細節:那個佔位字串是 pnpm install 自己寫進去的,不是 approve-builds。當它發現有依賴的腳本沒被審核,就會順手在 pnpm-workspace.yaml 補上一行:

  sharp: set this to true or false   # ← install 幫你加的,這不是有效值

那個字串等於「未審核」,install 一樣會失敗。設定檔看起來明明「有那一行」,卻還是過不了——因為它要的是布林,不是一句待辦事項。要嘛跑 pnpm approve-builds 讓它把選擇寫成布林,要嘛自己手改。

填完之後要怎麼驗證?這裡有個陷阱:光跑 pnpm install --frozen-lockfile 證明不了任何事——如同前面說的,你的 node_modules 可能讓它什麼都不做就 exit 0。

要驗證,就得逼它真的重裝一次:

pnpm install --frozen-lockfile --force
echo $?   # 要看到 0

--force 讓 pnpm 忽略「已經是最新」的判斷、重新解析與連結依賴,於是那道 build script 閘門一定會被觸發。--frozen-lockfile 則拒絕修改 lockfile,行為與 CI 一致。想更徹底就直接 rm -rf node_modules 再裝一次,那才是雲端真正的處境。

順帶一提,如果你的 CI 是 GitHub Actions,有兩個地方也常常在同一天一起爆。第一,pnpm/action-setup 要放在 setup-node 之前,否則 cache: pnpm 找不到 pnpm,快取那步會直接失敗。第二,在 package.json 裡釘住版本:

{ "packageManager": "pnpm@11.5.0" }

這行讓 action 自動偵測要裝哪一版 pnpm,也讓你和 CI 跑在同一個 pnpm 上。我看過太多「本機好好的」其實是本機 pnpm 版本比 CI 新兩個 minor。

還有一個純粹是版本遷移的雷:pnpm 11 起,設定不再讀 package.jsonpnpm 欄位,全部搬到 pnpm-workspace.yaml(v11.0.0 的 release notes 明列這項變更)。如果你看到 The "pnpm" field ... is no longer read 這種警告,就是它——你的設定其實一行都沒生效,難怪怎麼改都沒用。

怎麼確定 workerd 真的可以 false?

我原本想用 wrangler deploy --dry-run 來「證明」,後來發現那證明不了什麼。dry-run 只跑打包與上傳前的流程,根本不會啟動 Worker 執行期——我把 workerd 的原生二進位檔整個移走,dry-run 照樣 exit 0。

所以正確的說法是:workerd: false 只是不跑它的安裝期腳本,套件本身還在。我能確認的是我的部署路徑(純靜態資產上傳)不需要它,而且 dry-run 通過。我不能因此宣稱它對所有人都可以是 false——wrangler dev 這種會啟動本機執行期的指令就需要它。

判斷標準:那個原生檔在你實際會跑的指令上會不會被執行到。我只跑 build 與部署,所以不會;哪天我要用 wrangler dev 或改成 SSR,這一行就得翻回 true

我從這件事學到的規則#

寫完那次 fix 之後,我在專案的 LESSONS.md 裡留了一條給未來的自己。摘要成一句話就是:

加完任何帶 native/postinstall 的依賴,立刻補 allowBuilds,並用 pnpm install --frozen-lockfile --force 在本機驗一次 exit 0,才准 push。

順序很重要——先驗證再 push,而不是 push 上去讓雲端幫你測。等雲端告訴你,你已經浪費了一次 build 的時間,而且錯誤訊息還躺在你看不見的地方。

重點回顧#

  1. 本機沒事、雲端爆炸,通常不是因為雲端比較嚴格,而是因為你本機那次 install 什麼都沒做Already up to date 直接 exit 0)。閘門只在「這次安裝有待建置的腳本」時觸發——別假設雲端一定乾淨(Cloudflare 就會還原 node_modules 快取)。
  2. strictDepBuildspnpm 11 起預設為 true(設定本身 10.3 就有),會封鎖依賴的安裝期腳本。這是防供應鏈攻擊的刻意設計,不是配置錯誤。若你還在 pnpm 10.x,先確認自己這台的預設值再照抄結論。
  3. 設定寫在 pnpm-workspace.yamlallowBuilds,一個套件一個明確布林。pnpm install 會替未審核的依賴補上 set this to true or false 佔位字串,那不是有效值;要用 pnpm approve-builds 或手改成布林。
  4. 別猜「它需不需要跑腳本」——預設 false,確認需要才放行。但注意:安裝成功與一次 smoke test 無法排除靜默降級(退回慢速 fallback、少掉平台專屬能力),要在每個目標平台上測你真的會用到的能力。我實測 sharp: false 在我這台仍可正常產圖(原生檔來自 optional dependencies),僅此而已。也別用 deploy --dry-run 的成功去證明某個原生檔「不需要」,dry-run 本來就不啟動執行期。
  5. 驗證必須強迫重裝pnpm install --frozen-lockfile --force,或乾脆刪掉 node_modules)並檢查 exit 0。不帶 --force 的那次綠燈,可能只代表 pnpm 什麼都沒做。

延伸閱讀#

  • pnpm settings — allowBuilds:官方設定文件。
  • npm install-scripts:npm 現行的 allowScripts 機制,同一個方向的另一種實作。
  • pnpm v11.0.0 release notes:明列「strictDepBuilds is true by default」的那一版。
  • 如果你還在考慮要不要從 npm 換過來:pnpm 擋幽靈依賴、省磁碟,代價就是這類「它會攔你」的時刻。我認為值得。

#pnpm#nodejs#ci