同一份 iOS 專案在開發者終端機中可以順利編譯,換到雲端 Mac 的非互動式 CI 後,卻可能在讀取中文設定、解析含重音字元的資源名稱或擷取日誌時失敗。最棘手的是,這類問題經常被誤判為相依套件損壞:檔案明明存在,指令碼卻回報找不到;日誌看起來完全相同,Git 中卻出現兩個不同路徑。根本原因通常不在 UTF-8 本身,而是工作進入點、語言執行環境與儲存庫檔名對編碼的理解並未對齊。
先確認差異發生在哪一層
不要一開始就修改系統設定。先將問題拆成四層:工作程序的 locale、指令碼執行環境的預設編碼、管線中個別命令的行為,以及檔名採用的 Unicode 正規化形式。
分別在互動式終端機與 CI 工作開頭執行:
printf 'shell=%s\n' "$SHELL"
locale
printf 'LANG=%s\nLC_ALL=%s\n' "${LANG:-unset}" "${LC_ALL:-unset}"
python3 -c 'import locale,sys; print(locale.getpreferredencoding(False), sys.getfilesystemencoding())'
ruby -e 'p [Encoding.default_external, Encoding.default_internal]'
將輸出儲存為一般建置產物,不要只留在持續捲動的日誌中。如果終端機顯示 en_US.UTF-8,但工作中的變數為空,問題就在啟動邊界;如果 locale 一致,但 Ruby 仍使用其他編碼讀取檔案,則應檢查指令碼是否明確傳入了錯誤選項。
「機器支援 UTF-8」不等於「每個工作都使用 UTF-8」。CI 的實際設定是程序收到的環境,而不是登入終端機所顯示的環境。
在工作進入點固定使用 UTF-8
最穩妥的做法是在 runner 包裝指令碼或管線工作的進入點設定環境,而不是依賴 .zshrc。非互動式 Shell 通常不會按照開發者的預期載入這些檔案,由 launchd 啟動的程序也可能具有另一套環境。
#!/bin/zsh
set -euo pipefail
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
locale
exec "$@"
將指令碼儲存為 ci/run-utf8.zsh,授予執行權限,再讓所有建置命令都透過它啟動:
chmod +x ci/run-utf8.zsh
ci/run-utf8.zsh ./ci/build.zsh
不要為了「修復」日誌而全域執行 export LC_ALL=C。C locale 適合需要穩定位元組排序的單一命令,但也會改變字元分類、大小寫轉換與正規表示式行為。確實有需要時,應將作用範圍限縮至命令本身,例如 LC_ALL=C sort input.txt。
同時檢查指令碼本身的讀取方式
Python 應明確指定 encoding="utf-8",Ruby 可以使用 File.read(path, encoding: "UTF-8")。Shell 逐行讀取文字時,應使用 IFS= read -r,避免反斜線及行首、行尾空白遭到改寫。JSON、plist 或 YAML 應交由對應的解析器處理,不要使用 grep 和 sed 猜測結構。
檢查 Unicode 檔名正規化
字元 é 可以由單一碼位表示,也可以由字母與組合符號構成。兩種形式看起來相同,位元組卻不一樣。儲存庫在 macOS、Linux 與壓縮檔之間流轉時,這種差異可能表現為重複資源、找不到指令碼,或僅變更大小寫的提交無法正確套用。
在 CI 中加入唯讀檢查:
from pathlib import Path
import sys
import unicodedata
bad = []
for path in Path(".").rglob("*"):
if ".git" in path.parts:
continue
raw = path.as_posix()
normalized = unicodedata.normalize("NFC", raw)
if raw != normalized:
bad.append((raw, normalized))
for raw, normalized in bad:
print(f"non-nfc: {raw!r} -> {normalized!r}")
sys.exit(1 if bad else 0)
將其儲存為 ci/check_unicode_paths.py,並在安裝相依套件前執行。檢查器只負責回報,不應自動重新命名,因為正規化後的路徑可能與現有檔案衝突。修復時,先確認 Git 索引中的實際名稱:
git -c core.quotepath=false ls-files
git -c core.quotepath=false status --short
接著透過 git mv 完成獨立提交。如果只是大小寫變更,可以先移至暫時名稱,再移至目標名稱。完成後必須在全新目錄中重新複製並建置,不能使用已受到檔案系統快取影響的舊工作區進行驗收。
讓日誌保留原始證據
編碼錯誤經過 tee、日誌收集器或 JSON 逸出處理後,原始位元組可能遭到替換,最後只留下問號或替代字元。調查期間應同時保留可讀日誌,以及關鍵檔案的位元組檢視:
file -I Config/環境.json
xxd -g 1 -l 96 Config/環境.json
plutil -lint App/Info.plist
不要將 file 的判斷視為唯一結論,它只能提供線索。真正讀取檔案的程式仍應明確指定 UTF-8,並在解碼失敗時傳回非零狀態。建置指令碼也不應在執行 cmd | tee build.log 後忽略前一個命令的結束碼;在 zsh 中可以讀取 $pipestatus,或將關鍵命令與日誌收集分開執行。
日誌也應記錄失敗路徑的逸出形式。Python 的 repr() 與 Ruby 的 String#dump 都比直接輸出更適合辨識組合字元、換行與不可見空白。
建立合併前門禁與修復順序
一套可維護的門禁只需檢查可確定的事實,不應擅自修改儲存庫。建議依照以下順序執行:
- 輸出 locale 與各執行環境的編碼。
- 檢查儲存庫路徑是否採用 NFC。
- 檢查正規化後是否出現同名項目。
- 使用明確編碼讀取關鍵文字設定。
- 在全新工作區執行最小建置。
- 儲存環境探針、路徑報告與失敗日誌。
如果工作只在某個執行器上失敗,應先比較環境探針,再比較 Git 索引,不要一開始就清除所有快取。如果路徑報告出現異常,先修正檔名並建立獨立提交;如果路徑正常但解析失敗,再檢查檔案內容的編碼宣告與讀取參數。這樣才能將環境問題、儲存庫問題與工具問題分開處理。
RentMini 上的工作也應將這組檢查納入儲存庫,由專案自行進行版本控制,而不是依賴某台雲端 Mac 的手動設定。當進入點指令碼、Unicode 檢查器與日誌證據都隨程式碼一同審查後,重新建立工作區或更換節點時,才能獲得一致的行為。
常見問題
為什麼終端機正常的指令稿到了 CI 就出現亂碼?
CI 多半由非互動式程序啟動,不會讀取終端機使用的初始化檔。應在工作入口明確設定 LANG 與 LC_ALL,並輸出 Ruby、Python 等執行環境實際採用的編碼。
發現非 NFC 檔名後能直接批次重新命名嗎?
不建議直接修改。先產生報告並確認大小寫與正規化後名稱沒有衝突,再用獨立提交執行 git mv,最後於全新檢出目錄重新建置。
為下一次建置準備獨享實體 Mac mini
選擇 M4 配置、租期與節點。每筆訂單對應一台獨享實體設備,實際可用狀態以控制台即時回傳為準。