將建置日誌接入 tee 之後,終端輸出變得完整,CI 卻偶爾會把明明失敗的 xcodebuild 標記為成功。最危險的並不是日誌缺失,而是流水線繼續執行上傳、通知或部署步驟。根本原因通常很單純:shell 預設傳回管線中最後一個命令的退出碼,tee 寫入成功便會掩蓋前方 xcodebuild 的失敗狀態。
先確認錯誤是如何被吞掉的
以下寫法十分常見:
xcodebuild test \
-workspace App.xcworkspace \
-scheme App \
-destination "$CI_DESTINATION" \
2>&1 | tee build.log
這條管線包含兩個行程。左側執行測試,右側則將輸出同時寫入檔案與標準輸出。在未啟用 pipefail 的 Bash 中,整條命令預設採用 tee 的狀態。即使測試傳回非零值,只要日誌寫入成功,腳本看到的仍可能是 0。
先在雲端 Mac 上進行最小化驗證:
/bin/bash -c 'exit 65' | tee /tmp/pipeline-check.log
printf 'pipeline=%s\n' "$?"
如果輸出為 pipeline=0,就表示目前的腳本具備誤報成功的條件。不要只檢查日誌中是否出現 error:,因為編譯器、測試框架與自訂腳本的輸出格式都可能改變。退出碼才是 CI 判斷工作成功或失敗的穩定介面。
日誌用來解釋失敗,退出碼用來宣告失敗。兩者必須分別保存,不能以文字搜尋取代行程狀態。
固定 Shell,再處理管線狀態
macOS 的互動式預設 shell,可能與 CI 腳本實際使用的直譯器不同。Bash 使用 PIPESTATUS,zsh 使用 pipestatus,兩者的陣列索引方式也不相同。最穩妥的做法是在儲存庫腳本中明確宣告直譯器,並讓流水線直接執行該檔案,而不是將多行命令交給不確定的 shell。
#!/bin/bash
set -uo pipefail
-u 會攔截未定義的變數,pipefail 則會在管線中任一命令失敗時,讓整條管線傳回非零值。然而,只依賴 pipefail 仍不夠精確:如果 tee 因磁碟或權限問題失敗,但 xcodebuild 本身成功,最終的 $? 只能表示管線失敗,無法指出是哪個階段出錯。
因此,應在管線結束後立即複製 PIPESTATUS。任何賦值、條件判斷或日誌命令都可能重新整理它;只要晚一步讀取,取得的就會是另一條命令的狀態。
編寫可重複使用的 xcodebuild 包裝腳本
以下腳本會同時保存原始日誌、結果套件,以及 xcodebuild 的真實退出碼。它會暫時停用 errexit,避免 shell 在狀態尚未保存前直接退出。
#!/bin/bash
set -uo pipefail
run_id="${CI_RUN_ID:-local-$(date +%s)}"
artifact_dir="${CI_ARTIFACT_DIR:-$PWD/artifacts/$run_id}"
result_path="$artifact_dir/TestResults.xcresult"
log_path="$artifact_dir/xcodebuild.log"
mkdir -p "$artifact_dir"
set +e
xcodebuild test \
-workspace App.xcworkspace \
-scheme App \
-configuration Debug \
-destination "$CI_DESTINATION" \
-resultBundlePath "$result_path" \
2>&1 | tee "$log_path"
pipeline_status=("${PIPESTATUS[@]}")
xcode_status="${pipeline_status[0]}"
tee_status="${pipeline_status[1]}"
set -e
printf 'xcodebuild_exit=%s\n' "$xcode_status"
printf 'tee_exit=%s\n' "$tee_status"
if (( tee_status != 0 )); then
printf 'Log capture failed\n' >&2
fi
exit "$xcode_status"
CI_DESTINATION 應由工作設定明確傳入,例如綁定至已通過驗收的模擬器目標。不要在腳本中模糊比對第一個可用裝置,否則執行環境更新後,可能會選到不同的系統版本。所有路徑變數都應加上引號,以免工作目錄包含空格時導致參數被拆開。
這裡選擇以 xcodebuild 的狀態作為工作的最終狀態。如果日誌寫入失敗也必須阻斷工作,可以在退出前增加判斷:任一狀態為非零就傳回失敗,並將兩個狀態分別寫入工作摘要。
將結果套件視為診斷證據
主控台文字適合快速閱讀,而 xcresult 會保留測試階層、失敗附件、活動記錄與診斷資訊。兩者用途不同,建議一併封存。
| 產物 | 主要用途 | 失敗時是否保留 |
|---|---|---|
xcodebuild.log |
搜尋編譯器輸出與腳本日誌 | 是 |
TestResults.xcresult |
檢視測試、附件與活動記錄 | 是 |
exit-status.txt |
記錄各管線階段的狀態 | 是 |
| 暫存 DerivedData | 需要現場重現時短期保留 | 依工作策略 |
若需要產生機器可讀的摘要,可以使用系統內建的 xcresulttool。請先確認結果套件確實存在,再執行解析,避免解析失敗覆蓋原始建置狀態。
if [[ -d "$result_path" ]]; then
xcrun xcresulttool get test-results summary \
--path "$result_path" \
> "$artifact_dir/test-summary.json" || true
fi
printf 'xcodebuild=%s\ntee=%s\n' \
"$xcode_status" "$tee_status" \
> "$artifact_dir/exit-status.txt"
此處的 || true 只允許在摘要產生失敗後繼續封存,不能放在 xcodebuild 主命令後方。輔助報告失敗不應抹除已保存的主要狀態,主要建置失敗也不能被輔助命令改寫為成功。
使用異常情境驗收,而不是只執行成功工作
包裝腳本合併前,至少應執行以下四類驗收:
編譯失敗
暫時引用一個不存在的符號。預期 xcodebuild 傳回非零值、CI 失敗且日誌存在;是否產生結果套件則取決於失敗發生的階段。
測試失敗
加入一個必定失敗的斷言。預期編譯完成,但測試工作失敗;xcresult 中應能定位失敗案例,而且流水線不得繼續執行成功分支。
日誌寫入失敗
將產物目錄指向不可寫入的位置,確認 tee_status 為非零值。如果團隊要求診斷產物完整才能交付,這個情境也應阻斷工作。
中斷與逾時
由 CI 發出終止訊號,確認後續發布步驟不會執行,並檢查子行程是否已被回收。不要將逾時包裝成一般測試失敗,兩者在重試策略與故障分析上的含義不同。
最後還要檢查工作定義本身:上傳診斷產物的步驟可以設定為失敗後仍執行,但部署、分發與成功通知必須依賴包裝腳本傳回的真實狀態。如此一來,即使輸出篩選器、封存工具或摘要產生器發生變化,xcodebuild 的失敗仍會沿著唯一且可驗證的路徑傳回 CI。
常見問題
已經啟用 set -o pipefail,為什麼仍要讀取 PIPESTATUS?
pipefail 只能指出管線內有命令失敗,不保證回傳的就是 xcodebuild 狀態。立即讀取 PIPESTATUS[0],才能排除 tee 或其他過濾器的退出碼干擾。
如何驗證包裝腳本不會再誤報成功?
分別製造編譯錯誤、測試斷言失敗與不可寫入的日誌目錄,確認三種工作都標記為失敗,並檢查原始日誌與 xcresult 是否按預期保留。
將下一項開發任務放到雲端 Mac 上
選擇 Berry M4 或 Berry M4 Pro,確認五個節點、租用期間與附加選項後提交訂單。