Build 突然壞了,但沒有人改過 CI 設定
iOS 的 Jenkins pipeline 突然開始失敗。上一版(058)還好好的,下一版(059)就掛了。再跑一次(060),還是掛。
第一時間檢查 git diff —— 兩版之間只有 Dart 程式碼改動,沒有任何人碰過 Fastfile、Jenkinsfile、或 CI 相關設定。程式碼改動是 Flutter 層的 bug fix,跟 iOS build 流程完全無關。
這是最令人困惑的情境:什麼都沒改,但 CI 壞了。
先看一下各版的 build 結果:
| Tag | Build | 結果 | 耗時 |
|---|---|---|---|
| prod-0.1.04+2026000058 | #1 | SUCCESS | 9.5 分鐘 |
| prod-0.1.04+2026000059 | #1 | FAILURE | 2.3 分鐘 |
| prod-0.1.04+2026000060 | #1 | FAILURE | 2.3 分鐘 |
059 和 060 都只跑了 2 分多鐘就掛了 —— 連 Flutter build 都沒跑到,在 Fastlane 的前期設定階段就失敗了。
先理解 Fastlane 的 Keychain 機制
在深入除錯之前,先理解 iOS code signing 在 CI 環境的運作方式。
iOS App 要上架必須用 Apple Distribution Certificate 簽名。這個憑證(.p12 私鑰 + .cer 公鑰)存在 macOS 的 Keychain 裡。你在 Xcode 裡簽名時用的就是 login keychain 裡的憑證。
但在 CI 環境,情況更複雜:
為什麼不直接用 login keychain? 因為 CI 環境需要隔離。多個 build 可能同時執行,login keychain 可能被鎖定,而且 CI 結束後不應該留下私鑰。setup_ci 建立的暫時 keychain 解決了這些問題 —— 用完就刪,互不干擾。
Fastlane match 則解決了憑證分發的問題。它把 Apple Certificate 和 Provisioning Profile 加密存在 Git repo 裡,CI 執行時下載、解密、匯入暫時 keychain。整個團隊共用同一份憑證,不需要每台機器手動安裝。
理解這個架構後,回到我們的錯誤。
第一層:Keychain 建立失敗
Fastlane 的 build summary 只跑了兩步就掛了:
+------+----------------------------+------+
| Step | Action | Time |
+------+----------------------------+------+
| 1 | Verifying fastlane version | 0s |
| 2 | default_platform | 0s |
| 💥 | setup_ci | 0s |
+------+----------------------------+------+
展開錯誤訊息:
INFO: Creating temporary keychain: "fastlane_tmp_keychain"
INFO: Temporary keychain fastlane_tmp_keychain not found, skipping delete
ERROR: Shell command exited with exit status 1 instead of 0.
setup_ci 在建立暫時 keychain 時失敗了。它的流程是「先刪舊的、再建新的」,但第一步 delete 就跳過了(not found),第二步 create 卻失敗了。
SSH 到 Jenkins 機器查 keychain 狀態:
$ security list-keychains
"/Users/ci/Library/Keychains/fastlane_keychain-db"
"/Users/ci/Library/Keychains/login.keychain-db"
"/Library/Keychains/System.keychain"
注意第一行 —— 多了一個 fastlane_keychain-db。這不是 setup_ci 建立的 fastlane_tmp_keychain,而是一個名字不同的殘留 keychain。它佔據了 keychain 搜尋列表的位置,影響了新 keychain 的建立。
清掉它:
$ security delete-keychain fastlane_keychain-db
$ security list-keychains
"/Users/ci/Library/Keychains/login.keychain-db"
"/Library/Keychains/System.keychain"
乾淨了。重跑 build。
第二層:清掉殘留後,換一個錯誤
重跑 build,setup_ci 通過了,match 也成功下載並匯入憑證。但 pipeline 在下一步掛掉:
+------+----------------------------+------+
| Step | Action | Time |
+------+----------------------------+------+
| 1 | Verifying fastlane version | 0s |
| 2 | default_platform | 0s |
| 3 | setup_ci | 0s |
| 4 | xcode_select | 0s |
| 5 | is_ci | 0s |
| 6 | match | 3s |
| 💥 | unlock_keychain | 0s |
| 8 | delete_keychain | 0s |
+------+----------------------------+------+
這次多跑了幾步,但掛在 unlock_keychain。錯誤訊息:
ERROR: Could not locate the provided keychain. Tried:
/Users/ci/Library/Keychains/fastlane_keychain-db
嘗試 unlock 的是 fastlane_keychain —— 就是我們剛剛刪掉的那個。
翻開 Fastfile 第 209 行:
# 解鎖 fastlane_keychain 讓 Flutter build 能存取簽名憑證
unlock_keychain(
path: "fastlane_keychain",
password: "temp_password",
set_default: false
)
這段程式碼依賴一個不由 CI 流程建立的 keychain。 它期望 fastlane_keychain 存在於 Jenkins 機器上,但現在 CI 流程用的是 setup_ci 建立的 fastlane_tmp_keychain。
問題變得更有趣了:這行程式碼一直都在 Fastfile 裡,為什麼之前不會失敗?
考古:這段程式碼的來歷
翻看 Fastfile 的結構,答案很清楚。這個專案的 iOS 簽名經歷過一次遷移:
舊架構(手動管理):
# 自己建立 keychain
create_keychain(
name: "fastlane_keychain",
password: "temp_password",
unlock: true,
timeout: 3600
)
# 手動匯入 .p12 憑證
import_certificate(
certificate_path: "cert.p12",
keychain_name: "fastlane_keychain",
keychain_password: "temp_password"
)
# 手動安裝 provisioning profile
install_provisioning_profile(
path: "../keys/AppStore.mobileprovision"
)
新架構(match 自動管理):
# setup_ci 建立 fastlane_tmp_keychain
setup_ci(force: true)
# match 自動下載憑證並匯入 fastlane_tmp_keychain
match(
type: "appstore",
app_identifier: "com.example.myapp",
readonly: is_ci
)
遷移的時候,create_keychain 和 import_certificate 被正確替換成了 setup_ci + match。舊的程式碼也被註解掉了。但 unlock_keychain 這段沒有被刪掉 —— 可能是遷移時覺得「多 unlock 一次沒壞處」,就留著了。
| 元件 | 舊架構 | 新架構 | 遷移時處理 |
|---|---|---|---|
| Keychain 建立 | create_keychain("fastlane_keychain") | setup_ci → fastlane_tmp_keychain | ✅ 已替換 |
| 憑證匯入 | import_certificate | match | ✅ 已替換 |
| Profile 安裝 | install_provisioning_profile | match | ✅ 已註解 |
| Keychain 解鎖 | unlock_keychain("fastlane_keychain") | 不需要(setup_ci 已處理) | ❌ 遺漏 |
幽靈依賴:為什麼「一直都能過」
知道了歷史,關鍵問題是:這段有問題的程式碼怎麼能存活這麼久?
答案是 Jenkins 機器上殘留了一個 fastlane_keychain-db 檔案。它不是由新的 CI 流程建立的,而是舊架構遺留下來的。遷移到 match 後,舊的 create_keychain 被移除了,但它之前建立的 keychain 檔案一直安靜地躺在機器上。
這就是幽靈依賴(Ghost Dependency)—— 程式碼依賴一個不由自身流程管理的外部狀態。它的危險在於:
- 表面上一切正常,因為依賴剛好被滿足
- 無法從程式碼中看出問題,因為邏輯本身沒有 bug
- 觸發時機不可預測,任何改變環境狀態的操作都可能引爆
修正:刪掉三行
修正異常簡單 —— 刪掉三個 build lane 裡的 unlock_keychain 呼叫。setup_ci 已經建立並 unlock fastlane_tmp_keychain,match 把憑證裝進同一個 keychain,整個流程自給自足:
setup_ci(force: true) # 建立 + unlock fastlane_tmp_keychain
match(type: "appstore", readonly: is_ci) # 下載憑證 → 匯入 fastlane_tmp_keychain
# 不需要額外的 unlock_keychain
推上去,Jenkins 跑了 12 分鐘,SUCCESS。
遠端診斷的技巧
這次除錯有一個實務上的挑戰:Jenkins 跑在辦公室的 Mac mini 上,我不在現場。所有操作都透過 SSH 遠端完成。幾個實用的指令記錄一下:
查看 keychain 狀態:
# 列出搜尋列表中的 keychains
$ security list-keychains
# 列出 keychain 目錄下的檔案
$ ls -la ~/Library/Keychains/
# 查看 keychain 裡的簽名身份
$ security find-identity -v -p codesigning
透過 Jenkins API 查 build 狀態:
# 查看特定 job 的最新 build 結果
$ curl -s -u "user:token" \
"http://localhost:8080/job/mobile-ci/job/prod-tag/1/api/json?tree=building,result"
# 觸發重新 build(需要 CSRF crumb)
$ CRUMB=$(curl -s -u "user:token" \
"http://localhost:8080/crumbIssuer/api/json" | python3 -c \
"import sys,json; print(json.load(sys.stdin)['crumb'])")
$ curl -s -b cookies -X POST -u "user:token" \
-H "Jenkins-Crumb: $CRUMB" \
"http://localhost:8080/job/mobile-ci/job/prod-tag/build" \
--data-urlencode json="{}"
清理殘留 keychain:
# 正確清理(同時從搜尋列表和檔案系統移除)
$ security delete-keychain fastlane_keychain-db
# 如果 delete 失敗,手動清檔案
$ rm -f ~/Library/Keychains/fastlane_keychain-db
問題本身不難,難的是它藏得深
老實說,這次用 AI 輔助除錯,從 log 到定位原因不到十分鐘。真正花時間的不是找原因,而是處理連鎖反應 —— 清掉殘留 keychain 後冒出第二個錯誤,修完 Fastfile 還要等 build 跑完驗證。
但如果沒有 AI 幫忙遠端讀 Jenkins log、SSH 查 keychain 狀態、交叉比對成功和失敗 build 的差異,這件事至少要多花一個小時手動翻 log。
真正值得記住的不是除錯技巧,而是這類 bug 的隱藏模式:
時間延遲。 遷移到 match 是幾個月前的事,unlock_keychain 一直靜靜地依賴著殘留的 keychain。問題的根因(遷移時遺漏刪除)和症狀(build 失敗)之間隔了好幾個月。
誤導性的成功。 如果遷移後第一次 build 就失敗,問題會當場修正,30 秒結束。但因為殘留 keychain「碰巧存在」,build「碰巧能過」,這段多餘的程式碼就像未爆彈,安靜地等了好幾個月。
環境耦合。 換一台全新的 CI 機器,反而會立刻失敗(因為根本沒有殘留 keychain),問題會當場被發現。正是因為舊機器上殘留了 keychain,bug 才能隱藏這麼久。
預防:CI 遷移的 Checklist
遷移 CI 流程(不只是 Fastlane)時,用這個 checklist 避免幽靈依賴:
遷移時:
- 搜尋舊名稱:遷移後在整個 repo
grep舊的 keychain 名稱、環境變數、路徑。任何對舊機制的引用都應該被移除,而不是「留著以防萬一」。 - 在乾淨環境測試:遷移後至少跑一次全新環境的 build —— 刪掉所有快取、暫存檔、殘留 keychain。如果只在舊環境能過,就是幽靈依賴。
- 刪除而不是註解:舊的程式碼要刪掉。Fastfile 裡被註解掉的
create_keychain不會執行,但沒被註解的unlock_keychain會繼續執行並依賴不存在的東西。
日常維護:
- CI cleanup 要強制執行:Fastlane 的
ensureblock 一定要有 cleanup。如果 cleanup 不可靠(如 process 被 kill),在每次 build 開始前先主動清理暫時 keychain。 - 定期在新機器跑 build:如果你的 CI 只跑在一台固定機器上,定期在另一台乾淨機器上測試一次。幽靈依賴在乾淨環境會立刻暴露。
- CI 環境視為 immutable:理想的 CI 環境每次 build 都從乾淨狀態開始(如 Docker container 或 ephemeral VM)。如果做不到,至少確保 pipeline 不依賴「上次 build 留下的東西」。
最終,這個問題的核心教訓很簡單:遷移不是「新的能跑」就結束了,「舊的要刪乾淨」才算完成。
