前言:當建置一直紅燈
CI/CD pipeline 亮紅燈是開發日常,但連續遇到三個不同層面的問題,從 iOS codesigning、Android Gradle、到 Google Play API,這就值得記錄下來了。
這篇文章記錄我在同一天內遇到的三個建置失敗,以及逐步排除的過程。每個問題都有其獨特的根因,但也反映出 CI/CD 環境的複雜性。
問題一:iOS Keychain 解鎖失敗
症狀
Jenkins 建置在 iOS 階段失敗,錯誤訊息:
[!] Error unlocking keychain at path: fastlane_keychain
Command failed with exit status 51
macOS Keychain 運作機制
在深入問題之前,先了解 macOS Keychain 的運作方式:
flowchart TD
subgraph Keychain["macOS Keychain 系統"]
KC1[login.keychain<br/>使用者預設]
KC2[fastlane_keychain<br/>CI 專用]
KC3[fastlane_tmp_keychain<br/>臨時]
end
subgraph Certs["憑證與密鑰"]
CERT[Apple Distribution<br/>Certificate]
KEY[Private Key]
end
KC2 --> CERT
KC2 --> KEY
subgraph Access["存取控制"]
LOCK[🔒 Locked<br/>密碼保護]
UNLOCK[🔓 Unlocked<br/>可存取憑證]
end
LOCK -->|正確密碼| UNLOCK
LOCK -->|錯誤密碼| ERROR[❌ Exit 51]
classDef default fill:#f5f5f5,stroke:#333,color:#333
classDef error fill:#f8d7da,stroke:#dc3545,color:#721c24
classDef success fill:#d4edda,stroke:#28a745,color:#155724
class ERROR error
class UNLOCK success
關鍵概念:
- macOS 可以有多個 Keychain,每個都有獨立密碼
- 憑證必須在「已解鎖」的 Keychain 中才能被
codesign使用 - CI 環境通常會建立專用的 Keychain,避免影響系統 Keychain
Fastlane Match 與 Keychain 的互動流程
sequenceDiagram
participant FL as Fastlane
participant KC as Keychain
participant CS as Codesign
participant XC as Xcode Build
FL->>FL: setup_ci(force: true)
Note over FL: 建立臨時 keychain
FL->>FL: match(type: appstore)
FL->>KC: 從 Git repo 下載憑證
FL->>KC: 匯入憑證到 keychain
FL->>KC: unlock_keychain(password)
alt 密碼正確
KC-->>FL: ✅ 解鎖成功
else 密碼錯誤
KC-->>FL: ❌ Exit status 51
Note over FL: 建置失敗!
end
FL->>XC: flutter build ios
XC->>CS: 簽名 IPA
CS->>KC: 讀取憑證與私鑰
KC-->>CS: 返回憑證
CS-->>XC: 簽名完成
調查過程
Exit status 51 代表「密碼錯誤」。SSH 進 Jenkins Mac mini 確認:
# 確認 keychain 存在
security list-keychains
# 輸出包含 fastlane_keychain-db
# 確認憑證存在
security find-identity -v -p codesigning fastlane_keychain
# 輸出:Apple Distribution: XXXXXXX Inc. (XXXXXXXX)
問題出在 Fastfile 的邏輯:
unlock_keychain(
path: "fastlane_keychain",
password: ENV['KEYCHAIN_PASSWORD'] || "temp_password",
set_default: false
)
根本原因
密碼來源的優先順序問題:
flowchart LR
ENV["ENV['KEYCHAIN_PASSWORD']<br/>Jenkins Credential"]
DEFAULT["'temp_password'<br/>預設值"]
ACTUAL["實際 Keychain 密碼<br/>temp_password"]
ENV -->|有值時優先使用| RESULT{使用的密碼}
DEFAULT -->|ENV 為空時| RESULT
RESULT -->|與實際不符| FAIL[❌ Exit 51]
RESULT -->|與實際相符| SUCCESS[✅ 解鎖成功]
classDef error fill:#f8d7da,stroke:#dc3545,color:#721c24
classDef success fill:#d4edda,stroke:#28a745,color:#155724
classDef warning fill:#fff3cd,stroke:#ffc107,color:#856404
class FAIL error
class SUCCESS success
class ENV warning
Keychain 實際密碼是 temp_password,但 Jenkins Credential KEYCHAIN_PASSWORD 被設定成其他值。Ruby 的 || 運算子在 ENV['KEYCHAIN_PASSWORD'] 有值時不會 fallback。
解決方案
直接硬編碼密碼,繞過可能設定錯誤的環境變數:
unlock_keychain(
path: "fastlane_keychain",
password: "temp_password", # 直接使用,避免 credential 設定錯誤
set_default: false
)
同時修復 unsetup_ci 函式,避免刪除持久的 keychain:
def unsetup_ci
# 只刪除 setup_ci 創建的臨時 keychain
temp_keychain = "fastlane_tmp_keychain"
if File.exist?(File.expand_path("~/Library/Keychains/#{temp_keychain}-db"))
delete_keychain(name: temp_keychain)
end
end
教訓
CI 環境的 credential 管理要有單一真相來源。 當 keychain 密碼同時存在於 Jenkins Credentials 和程式碼中,不一致就會發生。
問題二:file_picker Gradle 相容性
症狀
iOS 建置成功後,Android 建置失敗:
A problem occurred evaluating project ':file_picker'.
> Cannot invoke method substring() on null object
Build file: file_picker-10.3.8/android/build.gradle line: 30
Flutter Plugin 版本解析流程
flowchart TD
PUBSPEC["pubspec.yaml<br/>file_picker: ^10.1.9"]
RESOLVE["pub get 解析版本"]
CACHE["Pub Cache<br/>file_picker-10.3.8"]
GRADLE["Gradle Build"]
PROP["讀取 local.properties"]
PUBSPEC --> RESOLVE
RESOLVE -->|"^10.1.9 允許 10.3.8"| CACHE
CACHE --> GRADLE
GRADLE --> PROP
PROP -->|Flutter 3.27+| OK["✅ 屬性存在"]
PROP -->|Flutter 3.24.x| NULL["❌ 屬性為 null"]
NULL --> ERROR["substring() on null"]
classDef error fill:#f8d7da,stroke:#dc3545,color:#721c24
classDef success fill:#d4edda,stroke:#28a745,color:#155724
classDef warning fill:#fff3cd,stroke:#ffc107,color:#856404
class ERROR error
class OK success
class NULL warning
根本原因
這是 Flutter 版本相容性問題。file_picker 10.3.x 使用了 Flutter 3.27+ 才有的 Gradle 屬性,但 Jenkins 環境使用的是 Flutter 3.24.5。
解決方案
在 pubspec.yaml 中鎖定版本:
dependencies:
file_picker: 10.1.9 # 移除 ^,鎖定相容版本
教訓
版本約束要考慮 CI 環境的 Flutter 版本。 使用 ^ 語義版本時,要注意 minor version 升級可能帶來的相容性問題。
問題三:Google Play 草稿狀態限制
症狀
iOS 上傳 TestFlight 成功,Android AAB 也建置成功,但上傳 Google Play 失敗:
Google Api Error: Invalid request - Only releases with status draft
may be created on draft app.
Google Play Console App 狀態機
stateDiagram-v2
[*] --> Draft: 建立新 App
Draft --> Review: 提交審核
Review --> Published: 審核通過
Review --> Draft: 審核失敗
state Draft {
[*] --> Incomplete
Incomplete --> Complete: 填寫必要資訊
Note right of Incomplete: 只能上傳 draft release
}
state Published {
[*] --> Active
Active --> Active: 可上傳 completed release
}
根本原因
STG app 在 Google Play Console 中尚未完成首次發布流程,還處於「草稿狀態」。在這個狀態下,Google Play API 不允許創建 completed 狀態的版本。
解決方案
將 release_status 改為 draft:
supply(
package_name: "com.example.app.stg",
aab: "../build/app/outputs/bundle/stagingRelease/app-staging-release.aab",
track: 'internal',
release_status: 'draft', # 草稿 app 只能用 draft
json_key_data: ENV['SUPPLY_JSON_KEY_DATA']
)
教訓
不同環境的 app 可能處於不同的 Play Console 狀態。 Production app 已經發布過,可以用 completed;但 STG/DEV app 可能還是草稿。
總結:CI/CD 的複雜性
一天內遇到三個不同層面的問題:
| 層面 | 問題 | 根因 |
|---|---|---|
| iOS Signing | Keychain 解鎖失敗 | Credential 與實際密碼不一致 |
| Android Build | Gradle 編譯錯誤 | Plugin 版本與 Flutter 版本不相容 |
| Play Store API | 上傳被拒絕 | App 狀態與 API 參數不匹配 |
這些問題的共同點是:CI 環境與本地開發環境的差異。
解決這類問題的關鍵是:
- 詳讀錯誤訊息:exit status 51、substring on null、draft app 都是明確的線索
- SSH 進 CI 環境驗證:不要猜測,直接進去看
- 理解背後的機制:Keychain 如何運作、Gradle 如何解析版本、Play Console 的狀態機
- 修復後驗證:每次只改一個東西,確認是否解決
