前言:當建置一直紅燈

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 SigningKeychain 解鎖失敗Credential 與實際密碼不一致
Android BuildGradle 編譯錯誤Plugin 版本與 Flutter 版本不相容
Play Store API上傳被拒絕App 狀態與 API 參數不匹配

這些問題的共同點是:CI 環境與本地開發環境的差異

解決這類問題的關鍵是:

  1. 詳讀錯誤訊息:exit status 51、substring on null、draft app 都是明確的線索
  2. SSH 進 CI 環境驗證:不要猜測,直接進去看
  3. 理解背後的機制:Keychain 如何運作、Gradle 如何解析版本、Play Console 的狀態機
  4. 修復後驗證:每次只改一個東西,確認是否解決

相關資源