問題

除錯時最花時間的不是「找不到錯誤訊息」,是錯誤訊息把你指向錯誤的方向。 整理部署腳本與 CI 設定時接連撞到三種形態,每一種都讓人先往錯的地方查。

形態一:編碼問題偽裝成語法錯誤

症狀

shellcheck 對一支部署腳本報了兩個 error:

scripts/blue-green-deploy.sh:116:8: error: Couldn't parse this brace group. [SC1073]
scripts/blue-green-deploy.sh:211:18: error: For command expansion, the tick should slant left [SC1078]

看到「未關閉的 backtick」第一反應是去找哪裡少打了一個反引號。找不到。 去看第 211 行,內容是一串亂碼中文。

原因

那個檔整份是 Big5 編碼,而 Big5 的某些位元組落在 ASCII 的反引號附近, shellcheck 用 UTF-8 解讀時把中文註解的位元組看成了 shell 語法。(轉碼後 error 消失是實測;具體是哪個位元組被當成反引號,我沒有逐一追,這段機制是推論。)

全 repo 掃了一次,三個檔有問題:兩支腳本整檔 Big5,還有一個 CI 設定檔是 UTF-8 但混進了 一個 Big5 的破折號(單一位元組序列 a158)。

修法

三個檔轉成 UTF-8 之後,那兩個 error 直接消失,只剩一個風格建議。

判別原編碼的方法很直接,試著用各種編碼解一次,看哪個解得出像話的中文:

d = pathlib.Path(f).read_bytes()
for enc in ("big5", "cp950", "gbk", "shift_jis", "latin-1"):
    try:
        t = d.decode(enc)
        cjk = sum(1 for c in t if '一' <= c <= '鿿')
        print(enc, cjk)   # CJK 字元最多的那個就是原編碼
    except Exception:
        pass

教訓:語法檢查工具報的「語法錯」,前提是它讀對了那個檔。 在有中文註解的老檔案上,先確認編碼再相信它的行號。

形態二:重導向在命令失敗時已經先清空了目標檔

症狀

一個把 Jenkins 外掛清單從跑著的實例回寫的指令:

pin-plugins:
	$(COMPOSE) exec -T jenkins sh -c 'curl ...' \
	  | python3 -c '...' \
	  > docker/controller/plugins.txt

python 那段因為 3.11 不接受 f-string 內含反斜線而失敗。指令當然是紅的, 但目標檔已經被清空了——plugins.txt 從 10 行變成 0 行。

原因

shell 在執行管線之前就先把 > file 的目標截斷成 0 位元組。

也就是說:一個「失敗的」指令,副作用是刪掉了你原本有的東西。 那次檔案有進版控,從 git 還原回來;如果沒進版控,就回不來了。

修法

先寫暫存再搬:

pin-plugins:
	... > docker/controller/plugins.txt.tmp && mv docker/controller/plugins.txt.tmp docker/controller/plugins.txt

教訓:任何會覆寫既有檔案的指令,都要假設它中途會失敗。 先寫暫存再原子搬移,成本只有一行。

形態三:外掛的預設值把你最需要的功能關掉

症狀

CI 的測試階段紅了(覆蓋率門檻沒過),照理說這正是最需要看覆蓋率報表的時候。 結果報表頁 404,console 寫著:

[Coverage] Skipping execution of coverage recorder since overall result is 'FAILURE'

原因

外掛的預設行為是:build 已經失敗就不記報表。這個預設在「build 因為編譯錯誤而失敗」時合理, 在「build 因為覆蓋率不足而失敗」時剛好相反——開發者最需要報表的那一刻,報表被關掉了。

修法

顯式打開:

recordCoverage(tools: [...], failOnError: false, skipPublishingChecks: true, enabledForFailure: true)

打開之後,同樣紅的 build 印出了整體 98.71% 的數字,報表頁也回來了,看得到是哪一個檔案只有 28%。

教訓:工具的預設值是照「最常見的情況」設的,不是照「你的情況」設的。 當某個功能「在正常時有用、在出錯時消失」,多半是踩到這類預設,去翻它有沒有 forFailure 之類的開關。

共同的除錯順序

三個形態的共同點:錯誤訊息描述的是工具看到的現象,不是根因。

所以順序應該是:

  1. 先問「這個工具讀到的輸入,跟我以為的輸入是同一個嗎」(編碼、路徑、版本)
  2. 再問「這個工具在失敗路徑上做了什麼」(副作用、跳過、預設關閉)
  3. 最後才照著錯誤訊息的字面意思去查

倒過來做的話,你會在不存在的反引號上找很久。

怎麼預防再次發生

  • repo 裡有中文的話,CI 加一條「所有追蹤的文字檔必須能用 UTF-8 解碼」
  • Makefile 裡任何 > 既有檔案 一律改成先寫 .tmp 再 mv
  • 報表類外掛裝好之後,故意讓 build 紅一次,確認報表在失敗時也留得下來
  • 除錯卡住超過兩次嘗試就換方向:不要再看錯誤訊息,改看工具的輸入與失敗路徑