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 → 讀一下那個腳本到底在幹嘛、翻一下官方文件 → 在每個目標平台上把你真的會用到的能力測過一遍 → 再決定。安裝成功不是證據。
回頭看我上面那三行:
workerd:false。我是純靜態部署,只上傳dist/,沒有 Worker 執行期。代價是本機的wrangler dev不能用——我不用它,這筆交易划算。sharp、esbuild:true。老實說,以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.json 的 pnpm 欄位,全部搬到 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 的時間,而且錯誤訊息還躺在你看不見的地方。
重點回顧#
- 本機沒事、雲端爆炸,通常不是因為雲端比較嚴格,而是因為你本機那次 install 什麼都沒做(
Already up to date直接 exit 0)。閘門只在「這次安裝有待建置的腳本」時觸發——別假設雲端一定乾淨(Cloudflare 就會還原node_modules快取)。 strictDepBuilds自 pnpm 11 起預設為 true(設定本身 10.3 就有),會封鎖依賴的安裝期腳本。這是防供應鏈攻擊的刻意設計,不是配置錯誤。若你還在 pnpm 10.x,先確認自己這台的預設值再照抄結論。- 設定寫在
pnpm-workspace.yaml的allowBuilds,一個套件一個明確布林。pnpm install會替未審核的依賴補上set this to true or false佔位字串,那不是有效值;要用pnpm approve-builds或手改成布林。 - 別猜「它需不需要跑腳本」——預設
false,確認需要才放行。但注意:安裝成功與一次 smoke test 無法排除靜默降級(退回慢速 fallback、少掉平台專屬能力),要在每個目標平台上測你真的會用到的能力。我實測sharp: false在我這台仍可正常產圖(原生檔來自 optional dependencies),僅此而已。也別用deploy --dry-run的成功去證明某個原生檔「不需要」,dry-run 本來就不啟動執行期。 - 驗證必須強迫重裝(
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:明列「
strictDepBuildsistrueby default」的那一版。 - 如果你還在考慮要不要從 npm 換過來:pnpm 擋幽靈依賴、省磁碟,代價就是這類「它會攔你」的時刻。我認為值得。