前言:當 Build Failed 成為日常

在過去的 19 個小時裡,我經歷了 15 次 build failed,產生了 15 個 fix commits。如果你覺得這很誇張,讓我告訴你更誇張的:最後一個 bug 是 git describe 在多個 tag 指向同一 commit 時會隨機返回其中一個。

是的,隨機。在 CI/CD Pipeline 裡。

這篇文章完整記錄這場除錯馬拉松,從最初的 Fastlane 版本問題,到 Discord 通知功能的實作與修復,再到 Ruby 相容性地獄,最後揭開 git 鮮為人知的行為。泡杯咖啡,這會是一段旅程。


第一章:Fastlane 與 Bundler 的糾葛

問題 1:Fastlane 版本不一致

Commit: fix(jenkins): use bundle exec for fastlane to ensure version consistency

Jenkins 機器上有全域安裝的 Fastlane,但版本與 Gemfile.lock 指定的不同。這導致某些 action 行為不一致。

// Before: 使用全域 fastlane
sh 'fastlane ios build'

// After: 透過 Bundler 執行,確保版本一致
sh 'bundle exec fastlane ios build'

學習:在 CI 環境中,永遠使用 bundle exec 執行 Ruby 工具,確保版本與 lockfile 一致。

問題 2:Bundler 權限錯誤

Commit: fix(jenkins): fix bundler permission error with BUNDLE_PATH env var

加上 bundle exec 後,新問題出現:

There was an error while trying to write to `/var/lib/gems/3.0.0/cache`
You don't have write permissions for the /var/lib/gems directory

Bundler 預設嘗試寫入系統 gem 目錄。解決方案是設定 BUNDLE_PATH 環境變數:

environment {
  BUNDLE_PATH = "${env.HOME}/.jenkins/cache/mobile-ci/bundler"
}

這不只解決權限問題,還讓 gem 快取跨 build 保留,加速後續建置。


第二章:Discord Webhook 的誕生與磨難

功能實作:失敗時發送壓縮 Log

Commit: feat(jenkins): send compressed build log to Discord on failure

為了更快除錯,我實作了失敗時自動發送 build log 到 Discord 的功能:

post {
  failure {
    script {
      // 壓縮 log 檔案
      sh '''
        tail -n 5000 "${LOG_FILE}" > /tmp/build.log
        7z a -mx=9 /tmp/build.7z /tmp/build.log
      '''
      // 發送到 Discord
      sh '''
        curl -F "payload_json=<payload.json" \
             -F "file=@/tmp/build.7z" \
             "${DISCORD_WEBHOOK}"
      '''
    }
  }
}

然而,這只是噩夢的開始。

問題 3:JSON Payload 格式錯誤

Commit: fix(jenkins): fix Discord webhook JSON payload formatting

Discord API 對 JSON 格式非常嚴格。最初的 JSON 因為 Groovy 字串插值問題而損壞:

// 錯誤:Groovy 會解析 ${}
def json = '{"title": "${env.JOB_NAME}"}'  // 結果是空的

// 正確:使用 HEREDOC 避免插值問題
sh """
  cat > /tmp/payload.json << 'EOF'
  {"title": "Build Failed", "color": 15548997}
  EOF
"""

問題 4:Log 路徑編碼

Commits:

  • fix(jenkins): use Jenkins API to get build log
  • refactor(jenkins): simplify Jenkinsfile and fix Discord log
  • fix(jenkins): fix Discord log path encoding for special characters

Jenkins 的 job 路徑包含 + 符號(來自 tag 名稱如 prod-0.0.89+2025000231),但在檔案系統中被編碼為 %2B

# Tag 名稱
prod-0.0.89+2025000231

# Jenkins job 路徑
mobile-ci/prod-0.0.89%2B2025000231

# 實際 log 檔案路徑
/Users/jenkins/.jenkins/jobs/mobile-ci/jobs/prod-0.0.89%2B2025000231/builds/1/log

最終解決方案:

ENCODED_JOB="$(echo '${jobName}' | sed 's|+|%2B|g')"
JOB_PATH="$(echo "$ENCODED_JOB" | sed 's|/|/jobs/|g')"
LOG_FILE="${JENKINS_HOME}/jobs/${JOB_PATH}/builds/${BUILD_NUMBER}/log"

第三章:CocoaPods 與 Build Number 的迷宮

問題 5:CocoaPods PATH 錯誤

Commit: fix(ci): fix CocoaPods PATH and reduce log verbosity

Flutter build 找不到正確的 CocoaPods:

Warning: CocoaPods not installed. Skipping pod install.

原因是 FLUTTER_COCOAPODS_PATH 沒有正確設定。修復:

sh '''
  export FLUTTER_COCOAPODS_PATH="$(which pod)"
  fvm flutter build ios --release ...
'''

問題 6:Build Number 驗證失敗

Commit: fix(ci): add build number verification and reduce log verbosity

我想在 build 完成後驗證 Info.plist 的版本號是否正確:

ACTUAL_BUILD=$(/usr/libexec/PlistBuddy -c 'Print CFBundleVersion' ios/Runner/Info.plist)
if [ "$ACTUAL_BUILD" != "$EXPECTED_BUILD" ]; then
  echo "Build number mismatch!"
  exit 1
fi

結果讀出來的是… $(FLUTTER_BUILD_NUMBER)

問題 7:PlistBuddy 讀到 Xcode 變數

Commits:

  • refactor(fastlane): use flutter build as SSOT for build number
  • fix(fastlane): remove PlistBuddy verification and add manual pod install

原來 Flutter 的 Info.plist 使用 Xcode 變數 $(FLUTTER_BUILD_NUMBER),實際數值在 Xcode build 時才會替換。PlistBuddy 讀取的是原始檔案,當然只能看到變數名稱。

解決方案:採用 SSOT(Single Source of Truth)架構,flutter build --build-number 是唯一的版本號來源,移除所有驗證嘗試。

問題 8:Pod Install 找不到 Gemfile

Commit: fix(fastlane): fix pod install to run from project root with Gemfile

cd ios && bundle exec pod install
# Error: Could not locate Gemfile

ios/ 目錄沒有 Gemfile!修復:

# 不要 cd,使用 --project-directory
sh("bundle exec pod install --project-directory=ios --silent")

第四章:Ruby 版本地獄

問題 9:Ruby 3.4 與 ActiveSupport 6.1 不相容

Commit: fix(ci): use Ruby 3.3.6 via rbenv for ActiveSupport compatibility

NameError: uninitialized constant ActiveSupport::LoggerThreadSafeLevel::Logger

Ruby 3.4 將 logger 從標準函式庫移至獨立 gem,ActiveSupport 6.1 沒有正確宣告依賴。

第一次嘗試:安裝 rbenv,使用 Ruby 3.3.6。

environment {
  PATH = "${env.HOME}/.rbenv/shims:${env.HOME}/.rbenv/bin:${env.PATH}"
  RBENV_VERSION = "3.3.6"
}

結果:還是失敗。ActiveSupport 6.1.7.10 即使在 Ruby 3.3 上也有 Logger 問題。

問題 10:ActiveSupport 需要升級到 7.x

Commit: fix(ci): upgrade ActiveSupport to 7.x and relax Ruby version constraint

# Gemfile
ruby ">= 3.3.0"  # 範圍約束,兼容開發機和 CI

gem "activesupport", "~> 7.0"  # 7.x 正確處理 logger 依賴
gem "logger"  # 明確宣告

關鍵學習:Ruby 版本約束使用 ">= 3.3.0" 而非 "3.3.6",讓本機(Ruby 3.4.3)和 CI(Ruby 3.3.6)都能運作。


第五章:大魔王登場 — git describe 的隨機行為

問題 11:Prod Build 使用 Dev 設定

Commit: fix(ci): use BRANCH_NAME instead of git describe for tag detection

Build 成功了!部署到 TestFlight… 失敗。

The bundle version must be higher than the previously uploaded version: '2025000230'

等等,我明明用的是 prod-0.0.89+2025000231,為什麼它說 2025000230

檢查 log:

+ export APP_VERSION=0.2.1          # 應該是 0.0.89!
+ export APP_BUILD_NUMBER=2025000230  # 應該是 2025000231!
+ bundle exec fastlane ios deploy_dev_to_testflight  # 應該是 deploy_prod!

Prod build 完全使用了 dev 的環境變數!

根本原因:git describe 隨機返回 Tag

問題出在 Jenkinsfile 的 tag 解析:

def tagName = sh(
  script: "git describe --tags --exact-match",
  returnStdout: true
).trim()

多個 tag 指向同一個 commit 時:

$ git log --oneline -1 dev-0.2.1+2025000230
30c1555 fix(ci): upgrade ActiveSupport...

$ git log --oneline -1 prod-0.0.89+2025000231
30c1555 fix(ci): upgrade ActiveSupport...

git describe --tags --exact-match隨機返回其中一個!這不是 bug,這是 git 的設計行為。

解決方案

在 Jenkins Multibranch Pipeline 中,BRANCH_NAME 環境變數就是觸發 build 的 tag:

stage('Parse Git Tag') {
  steps {
    script {
      // 優先使用 BRANCH_NAME,它是 Jenkins 設定的觸發 tag
      def tagName = env.BRANCH_NAME ?: ''

      if (!tagName) {
        // Fallback
        tagName = sh(
          script: "git describe --tags --exact-match 2>/dev/null || echo ''",
          returnStdout: true
        ).trim()
      }

      def matcher = (tagName =~ /^(dev|stg|prod)-(\d+\.\d+\.\d+)\+(\d+)$/)
      env.SHORT_ENV = matcher[0][1]
      env.VERSION = matcher[0][2]
      env.BUILD_NUM = matcher[0][3]
    }
  }
}

終於,prod-0.0.89+2025000232 build 成功,部署成功。

🤖 Build Android (prod) v0.0.89+2025000232   ✅
No errors uploading '...ipa'                  ✅
Finished: SUCCESS                             ✅

完整問題清單

#Commit問題根本原因
1c33e2dbFastlane 版本不一致未使用 bundle exec
296e496eBundler 權限錯誤嘗試寫入系統目錄
31af99aaDiscord 通知功能(新功能)
44009d16JSON 格式錯誤Groovy 字串插值
566b2354無法取得 logJenkins API 問題
669b9c47Log 路徑錯誤路徑拼接邏輯
791d122b特殊字元編碼+ 號編碼為 %2B
8b44ca7eCocoaPods 找不到PATH 未設定
9bfb6801版本驗證失敗PlistBuddy 讀到變數
100377373架構重構改用 SSOT
113faa2a7移除驗證Xcode 變數無法讀取
122c7dbc5Pod install 失敗Gemfile 不在 ios 目錄
13d1a5fa4Logger NameErrorRuby 3.4 移除標準庫
1430c1555仍然失敗ActiveSupport 6.1 問題
152b1e41a環境變數錯誤git describe 隨機返回

結語:19 小時的教訓

這場除錯馬拉松教會我幾件事:

  1. CI/CD 是複雜系統:Jenkinsfile、Fastlane、Ruby、CocoaPods、Flutter、Xcode、App Store Connect、Google Play——任何一環出錯都會導致 build failed。

  2. Log 是最好的朋友:實作 Discord 通知雖然花了 5 個 commits 才穩定,但之後的除錯效率提升了 10 倍。

  3. 不要假設工具的行為git describe 的「隨機」行為完全在文件中,但誰會想到在 CI/CD 中這會是問題?

  4. 版本管理很重要:從 bundle execrbenv,版本不一致是大多數問題的根源。

  5. SSOT 原則:當多個地方可以設定同一個值時,選一個作為唯一來源,其他地方都從它讀取。

希望這篇文章能幫你在遇到類似問題時少走一些彎路。

下次 build failed 時,記得先深呼吸,然後問自己:「有沒有什麼東西是隨機的?」