雲端 Mac CI 如何避免 xcodebuild 管線吞掉錯誤

CI/CD 實踐 ·約 7 分鐘閱讀

雲端 Mac CI 如何避免 xcodebuild 管線吞掉錯誤

將建置日誌接入 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

將下一項開發任務放到雲端 Mac 上

選擇 Berry M4 或 Berry M4 Pro,確認五個節點、租用期間與附加選項後提交訂單。

選擇設定並訂購