前言:當 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 logrefactor(jenkins): simplify Jenkinsfile and fix Discord logfix(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 numberfix(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 | 問題 | 根本原因 |
|---|---|---|---|
| 1 | c33e2db | Fastlane 版本不一致 | 未使用 bundle exec |
| 2 | 96e496e | Bundler 權限錯誤 | 嘗試寫入系統目錄 |
| 3 | 1af99aa | Discord 通知功能 | (新功能) |
| 4 | 4009d16 | JSON 格式錯誤 | Groovy 字串插值 |
| 5 | 66b2354 | 無法取得 log | Jenkins API 問題 |
| 6 | 69b9c47 | Log 路徑錯誤 | 路徑拼接邏輯 |
| 7 | 91d122b | 特殊字元編碼 | + 號編碼為 %2B |
| 8 | b44ca7e | CocoaPods 找不到 | PATH 未設定 |
| 9 | bfb6801 | 版本驗證失敗 | PlistBuddy 讀到變數 |
| 10 | 0377373 | 架構重構 | 改用 SSOT |
| 11 | 3faa2a7 | 移除驗證 | Xcode 變數無法讀取 |
| 12 | 2c7dbc5 | Pod install 失敗 | Gemfile 不在 ios 目錄 |
| 13 | d1a5fa4 | Logger NameError | Ruby 3.4 移除標準庫 |
| 14 | 30c1555 | 仍然失敗 | ActiveSupport 6.1 問題 |
| 15 | 2b1e41a | 環境變數錯誤 | git describe 隨機返回 |
結語:19 小時的教訓
這場除錯馬拉松教會我幾件事:
CI/CD 是複雜系統:Jenkinsfile、Fastlane、Ruby、CocoaPods、Flutter、Xcode、App Store Connect、Google Play——任何一環出錯都會導致 build failed。
Log 是最好的朋友:實作 Discord 通知雖然花了 5 個 commits 才穩定,但之後的除錯效率提升了 10 倍。
不要假設工具的行為:
git describe的「隨機」行為完全在文件中,但誰會想到在 CI/CD 中這會是問題?版本管理很重要:從
bundle exec到rbenv,版本不一致是大多數問題的根源。SSOT 原則:當多個地方可以設定同一個值時,選一個作為唯一來源,其他地方都從它讀取。
希望這篇文章能幫你在遇到類似問題時少走一些彎路。
下次 build failed 時,記得先深呼吸,然後問自己:「有沒有什麼東西是隨機的?」
