一個 bug 讓你損失一次。一個壞掉的檢查機制讓你損失到發現它為止——而且在那之前,你會一直以為自己被保護著。
這五個坑的共同點是:它們讓一道 gate 停止作用,而 gate 停止作用的樣子,與 gate 通過的樣子完全相同。
一、cmd | grep -q 在 set -o pipefail 下會誤判
症狀
if cmd | grep -q PATTERN; then ... else ... fi 明明該命中,卻走進 else。
而且只在上游輸出量夠大時發生。小樣本手動測試永遠正常,所以 code review、bash -n、shellcheck、actionlint、單元測試全都抓不到。
直接跑得出來(命中率 20/20):
bash -c '
set -euo pipefail
CHANGED=$(printf "MATCH_ME\n"; for i in $(seq 1 15000); do echo "filler/path-$i"; done)
if echo "$CHANGED" | grep -q "MATCH_ME"; then echo hit; else echo "MISS(誤判)"; fi'
根因
grep -q 命中第一筆就提前 exit 0。
當上游寫入量超過 pipe buffer(Linux 預設 64KB)時,上游程序在 grep -q 已經退出之後繼續往管線寫,會收到 SIGPIPE,以 exit code 141 結束。
set -o pipefail 讓整條 pipeline 的回傳值取「最後一個非零的 stage」。於是即使 grep -q 自己判定為「有命中」,pipeline 整體回傳 141。if 拿這個值判斷時,141 是非零,等同失敗,直接落入 else——「有命中」被讀成「沒命中」。
放大條件很惡毒:命中行越靠前、上游輸出越長,觸發機率越高。命中越早,grep -q 越早退出,上游剩下未寫完的資料越多,SIGPIPE 越可能發生。
為什麼這特別糟
觸發條件往往正是這道 gate 最該啟動的場景。
以「檢查這次改動有沒有動到某類檔案」這種 gate 為例:大批量改動且真的動到目標檔案的那一次,同時是輸出量最大、命中最早的形狀——也就是最需要被擋下、卻最可能誤判成沒命中的那一次。
這不是隨機失效,是與風險正相關的失效。
修法
不要讓 pipeline 的 exit code 承載判斷。改用不會提前退出的形式:
# 用 herestring,上游不是獨立程序,沒有 SIGPIPE
if grep -q "MATCH_ME" <<< "$CHANGED"; then ...
# 或者把結果存下來再判斷
MATCHED=$(echo "$CHANGED" | grep -c "MATCH_ME" || true)
if [ "$MATCHED" -gt 0 ]; then ...
第一種最乾淨。要點是讓上游與 grep 不在同一條 pipe 上。
講到這裡你可能會想,那把 set -o pipefail 拿掉不就沒事了?方向反了。pipefail 要抓的是
「pipeline 中間某一段失敗、被最後一段的 exit code 蓋掉」——那是更常見、也更貴的失效形態。
為了一個 grep -q 關掉它,等於用一個大洞換一個小洞。要換掉的是判斷方式,不是把偵測關掉。
二、pnpm 的 Already up to date 保證的不是你以為的那件事
症狀
改了 workspace 的 overrides 之後,pnpm-lock.yaml 仍記著舊值,而四種指令全部回同一句話、都不改 lockfile:
$ grep -A1 '^overrides:' pnpm-workspace.yaml
overrides:
vitest: 4.1.11
$ grep -A1 '^overrides:' pnpm-lock.yaml
overrides:
vitest: 4.1.9
$ pnpm install # → Already up to date
$ pnpm install --force # → Already up to date
$ pnpm install --lockfile-only # → Already up to date
$ pnpm install --frozen-lockfile # → Already up to date
最危險的是第四行。 --frozen-lockfile 的存在理由就是「lockfile 與 manifest 不一致時要 fail」,而這裡它回 exit 0。
CI 因此照著舊 lockfile 裝舊版本,跑的相依樹與本機不同,而沒有任何一方會出聲。
根因
pnpm 的 up-to-date 短路,判準是「node_modules 是否已滿足 manifest」,不是「lockfile 是否已滿足 manifest」。
當 node_modules 剛好已經裝著 manifest 要的版本時(例如先前某次 install 裝好了,之後 lockfile 被 git checkout 或 merge 解衝突覆蓋回舊版),pnpm 認定無事可做,整個跳過 resolution——連帶跳過改寫 lockfile。
而 --force、--lockfile-only、--frozen-lockfile 全都排在同一道短路之後,所以三者都改變不了結論。
在乾淨副本裡跑的控制實驗(pnpm 11.0.8):
| node_modules 狀態 | manifest | lockfile | --lockfile-only 之後 |
|---|---|---|---|
| 存在且已裝 4.1.11 | 4.1.11 | 4.1.9 | 仍是 4.1.9 |
存在,但 .modules.yaml 被移走 | 4.1.11 | 4.1.9 | 仍是 4.1.9 |
整個 node_modules 移走 | 4.1.11 | 4.1.9 | 改成 4.1.11 ✓ |
修法
要真的重算 lockfile,得讓短路的前提不成立——移走 node_modules 再 install。
但更重要的是不要把 --frozen-lockfile 當成 drift 偵測器。它在這個情境下是失效的。真正可靠的 drift 檢查是直接比對兩份檔案的對應欄位:
diff <(yq '.overrides' pnpm-workspace.yaml) <(yq '.overrides' pnpm-lock.yaml)
一般化的教訓:一個 flag 的名字描述的是它的意圖,不是它的實作。--frozen-lockfile 想做的事和它實際檢查的東西之間有落差,而落差只有在你去讀短路邏輯時才看得到。
三、斷言被它自己的註解滿足
症狀
一段回歸測試對某個設定檔斷言「某個 filter 必須存在」:
gate_grep -qF 'status != "completed"' ||
fail "gate must select in-flight runs by excluding the terminal state"
把實作裡的那段 filter 整個刪掉,測試照樣 PASS。
正向斷言過、反向斷言也過——兩邊同時綠,這本身就是斷言失效的訊號。
根因
斷言的標的區塊裡含有非可執行文字,也就是註解。而那段註解為了解釋實作,逐字引用了實作:
# 篩選寫成「排除終態」(status != "completed") 而非列舉進行中狀態:
# 因為 run status 除了 queued / in_progress 之外還有 requested / waiting / pending⋯
於是「實作存在」與「有人寫過關於實作的說明」在斷言眼中同義。而後者可以在前者被刪除之後獨自成立。
註解寫得越好越危險。 一段忠實引用運算式的註解,就是一份能永久滿足該斷言的複本。
反向斷言(not.toContain(X))是鏡像形態:註解命中會讓測試誤報失敗,逼作者去修改一段其實正確的註解。
一般化
這和「fixture 資料被納入掃描範圍」、「測試檔自己的 heredoc 命中自己的 pattern」、「文件檔被 grep 到」是同一個形態:
斷言標的的邊界,不等於行為的邊界。
任何對「原始碼文字」下的斷言,都要先確定標的區塊裡只有會被執行的東西。
修法
兩條路,擇一:
- 收窄標的——在抽取區塊時先剝掉註解行再斷言
- 改變標的層級——不要斷言原始碼文字,斷言行為。以上面的例子來說,就是實際餵一組含終態與非終態的資料進去,驗輸出只留下非終態的那些
第二條路永遠比較好,但也比較貴。折衷是:對行為下主要斷言,對文字下的斷言只當輔助,而且明確剝掉註解。
四、空輸出的 hash 把不同成因塌縮成同一個值
症狀
一段逐檔比對內容的腳本:
a=$(git show "stash@{0}:$f" | sha256sum)
b=$(sha256sum < "$f")
[ "$a" = "$b" ] || echo "MISMATCH: $f"
每一個檔案都拿到同一個值:
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 -
這是空輸入的 SHA-256。而真因是 git show 對那條路徑根本失敗了(untracked 檔案不在那個 ref 底下),stderr 印的 fatal: path '…' does not exist 從來沒進到判讀者眼前。
於是一批完好無損的檔案被判成「內容有異」。
更糟的變體
同一個形態在另一個地方長成這樣——一段檢查兩邊 secret 是否一致的腳本:
a=$(grep -m1 "^KEY_A=" fileA | cut -d= -f2- | sha256sum)
b=$(grep -m1 "^KEY_B=" fileB | cut -d= -f2- | sha256sum)
[ -n "$a" ] && [ "$a" = "$b" ] && echo IDENTICAL || echo MISMATCH
[ -n "$a" ] 看起來就是在防「抓不到 key」。實測它防不到任何東西:
$ a=$(printf '' | sha256sum); [ -n "$a" ] && echo "guard 通過"
guard 通過
sha256sum 對空輸入照樣輸出 64 個字元加檔名,-n 當然為真。
於是當兩個 grep 同時抓不到(改名、檔案不在、路徑打錯)時,兩邊都等於空輸入的 digest,腳本印出 IDENTICAL——對一台「兩把 secret 其實都不存在」的機器回報一致。
這比誤報失敗嚴重得多:它把「完全沒檢查到」回報成「檢查通過」。
根因
sha256sum 是全函數(total function)——任何輸入都有輸出,包含空輸入。而 pipeline 中間某一段失敗時,它的 stdout 就是空的,stderr 走另一條路不會進到 $()。
於是「內容真的相同」、「兩邊都抓不到」、「上游指令失敗」、「路徑打錯」——四種完全不同的成因,塌縮成同一個 digest。
修法
判「有沒有抓到」要看 exit code,不是看內容空不空:
# 迴圈外建一次就好,並掛 trap 確保離開時清掉
tmp=$(mktemp) || exit 1
err=$(mktemp) || exit 1
trap 'rm -f "$tmp" "$err"' EXIT
# 以下在逐檔迴圈內
git show "stash@{0}:$f" >"$tmp" 2>"$err" \
|| { echo "SKIP(unreadable): $f — $(tr '\n' ' ' <"$err")"; continue; }
a=$(sha256sum <"$tmp") || { echo "FAIL(hash): $f"; continue; }
b=$(sha256sum <"$f") || { echo "FAIL(hash): $f"; continue; }
[ "${a%% *}" = "${b%% *}" ] || echo "DIFF: $f"
比對那一行不能省。 第一版腳本真正被信任的是它印出的 IDENTICAL,而不是它算了幾個
digest——算完不比、或比了不報,稽核者看到的仍然是一片安靜。同樣的道理,sha256sum 自己
也會失敗(檔案在迴圈中途被刪、權限變更),它的 exit code 一樣要接。
|| 看的是 pipeline 的最後一段,不是你以為的那一段。 上面刻意沒寫成
a=$(sha256sum <"$tmp" | cut -d' ' -f1)——那樣 || 判的是 cut,而 cut 對空輸入
照樣回 0,於是 sha256sum 失敗會被當成功,同一個坑在 pipeline 裡又犯一次。
set -o pipefail 能修,但 POSIX sh 沒有它;改用參數展開 ${a%% *} 切掉檔名欄位,
根本不需要多接一段 pipeline。實測:
$ sha256sum </nonexistent | cut -d' ' -f1 >/dev/null; echo $?
0
$ sha256sum </nonexistent >/dev/null; echo $?
2
git show 的 stderr 也不要丟。第一版把 fatal: 導進 /dev/null,正是下面第 3 點在
講的事——導進暫存檔再印出來,「檔案不存在」與「stash 不存在」才分得開。
內容要落到檔案,不要經過 $()。 command substitution 會剝掉結尾的換行,而多數文字檔都以
換行結尾——把內容抓進變數再 hash,算出來的值與直接 hash 檔案必然不同,於是每一個檔都被報成
不符。實測:
$ printf 'hello\n' > /tmp/t.txt
$ sha256sum < /tmp/t.txt
5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03 -
$ A=$(cat /tmp/t.txt); printf '%s' "$A" | sha256sum
2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 -
同樣的理由,二進位檔也不能走變數——$() 存不了 NUL byte。
三個要點:
- 檢查上游指令的 exit code,不要只看輸出
- 判「取得成功」看 exit code,不要拿「內容非空」當代理。空檔是合法狀態——原本那個
[ -s "$tmp" ] || continue看起來在防「抓不到」,實際上會把真正的空檔靜默跳過: stash 裡是空檔、工作區已經被寫進內容,這個 guard 讓它連比都沒比就過關。同一個坑換了個 位置而已 - 把 stderr 留下來。上面第一版腳本最貴的地方是
fatal:訊息被丟掉了——真因一直在,只是沒人看得到
一般化:任何全函數都會把錯誤路徑與正常路徑塌縮到同一個值域。 hash、JSON.stringify、字串格式化都是。要在進入全函數之前分岔。
五、core.hooksPath 讓 git hook 靜默變成死檔
症狀
沒有症狀。 這正是重點。
- 沒有錯誤訊息、沒有非零 exit code、沒有 log
ls -la .git/hooks/pre-commit顯示檔案在、可執行、內容正確- 手動執行那個 hook script 也完全正常
- commit 一路暢通,就像 gate 通過了一樣
實際情況是 git 從頭到尾沒有執行過那個檔案。
根因
core.hooksPath 的語意是取代 $GIT_DIR/hooks,不是在它之外追加一個搜尋路徑。設定之後 git 只在 core.hooksPath 指向的目錄找 hook,.git/hooks/ 整個目錄被略過。
husky 9 的安裝流程會設 core.hooksPath=.husky/_。於是任何直接寫進 .git/hooks/ 的 hook,在 husky 裝好的那一刻起就成了死檔——而且不會有任何訊息。
我們踩到的形狀是一個中間狀態:core.hooksPath 已經被設了,但 .husky/pre-commit 還沒建立。安裝腳本的偵測條件是「.husky/pre-commit 存不存在」,於是判定「這個專案沒有外部 hook 管理工具」,走 .git/hooks/ 分支——裝了一個從安裝當下就不會被執行的檔案。
為什麼三層都沒抓到
| 層 | 為什麼沒抓到 |
|---|---|
| typecheck / lint | hook 安裝是 runtime 的檔案操作,沒有型別面 |
| 檢查覆蓋率的稽核腳本 | 當時只讀 package.json 的 script,完全不看 git hook |
| 人工檢查 | 檔案存在且內容正確,肉眼看不出問題 |
第三點值得展開。要發現它是死檔,必須知道去比對 core.hooksPath——而這個知識本身就是這個坑。在踩到之前,沒有理由去查那個設定。
判定
不要看 hook 檔在不在,看 git 實際會去哪裡找:
git config core.hooksPath
# 有值 → .git/hooks/ 底下的東西全部是死檔
要驗一個 hook 真的會執行,唯一可靠的方法是讓它做一件看得見的事然後觸發它:
# 暫時在 hook 開頭加一行,然後跑一次 commit
echo "echo '[hook] executed' >&2" >> <hook-path>
看不到那行輸出,hook 就沒有執行。這比檢查任何靜態條件都可靠。
一般化
這一條的教訓最單純,也最容易被忽略:
「安裝成功」不等於「會被執行」。
任何靠約定位置生效的機制(git hook、systemd unit、cron、autoload 目錄、plugin 目錄)都有這個性質。檔案放對位置只是必要條件,充分條件是執行方真的會去那個位置找。
而執行方的搜尋路徑通常是可以被別的東西改掉的。
收束:怎麼知道 gate 還活著
五個坑,五種機制,但它們指向同一個缺口:沒有人在檢查檢查機制。
實務上可以做三件事。
第一,每一道 gate 都要有 known-negative 測試。 也就是「故意製造一個該被擋下的情況,確認它真的被擋下」。這一條能抓到上面五個裡的至少四個——第三個坑就是靠「刪掉實作之後測試照樣過」被發現的。
只做 known-positive(正常情況通過)的測試,證明不了 gate 有鑑別力。一個永遠回 PASS 的 gate 也能通過所有 known-positive 測試。
第二,區分「沒觸發」與「檢查失敗」。 上面第一與第四個坑都是這個形態——腳本在「什麼都沒查到」與「查到了而且沒問題」之間沒有分辨力。輸出至少要能分成三態:通過、失敗、沒能檢查。
第三,判定要打在執行的痕跡上,不是設定上。 hook 有沒有執行看它的輸出,不看檔案在不在;lockfile 有沒有更新看檔案內容,不看指令的 exit code;filter 有沒有生效看行為,不看原始碼文字。
這三條沒有一條是新的。但上面五個坑每一個都活了很久,而它們活下來的原因都是同一個——綠燈太便宜了。一個什麼都不做的檢查,永遠是綠的。