雲端 Mac CI 固定 Xcode Destination 實作

雲端 Mac CI 固定 Xcode Destination 實作

同一條 xcodebuild test 命令在雲端 Mac 上連續執行數週後,可能突然回報 Destination 無法使用,或選到新安裝 runtime 中的同名模擬器。專案本身沒有變更,真正發生漂移的是 Xcode 對目標裝置的解析結果。穩定的做法不是將某個 UDID 永久寫入 CI,而是清楚區分封存、編譯檢查與模擬器測試,並在每次任務開始時解析及記錄目標。

先釐清三類建置目標

Xcode 的 Destination 回答的是「這次動作要在哪個平台與裝置上執行」,並不等同於 Scheme。同一個共享 Scheme 可以用於通用 iOS 封存、指定模擬器測試及僅編譯驗收,但三者不應共用一條語意模糊的命令。

任務建議目標是否需要具體 UDID
產生 Archivegeneric/platform=iOS
模擬器測試具體 iOS Simulator
編譯驗收依產物平台選擇通用或具體目標視測試內容而定

如果封存任務寫成 name=iPhone 16,就會無端引入對模擬器 runtime 的依賴。反過來,若測試任務只寫 platform=iOS Simulator,Xcode 可能找到多個候選項,最終選擇會受到已安裝 runtime 與裝置建立順序影響。

通用目標用來證明「這個專案能為該平台產生建置產物」,具體目標則用來證明「測試能在這個明確的裝置環境中執行」。不要讓同一條 Destination 同時承擔兩種語意。

在任務開始前檢查可用目標

先固定 Xcode 路徑,再讓專案自行回報可用目標。如此即可區分「Scheme 不支援該平台」與「本機沒有相符裝置」,避免一開始就刪除快取。

set -euo pipefail

export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
WORKSPACE="App.xcworkspace"
SCHEME="App"

xcodebuild \
  -workspace "$WORKSPACE" \
  -scheme "$SCHEME" \
  -showdestinations

這份輸出應保留為任務附件。若清單中完全沒有 iOS Simulator,應先檢查 Scheme 的 Supported Destinations、目標平台與測試 Target,而不是反覆執行 simctl erase all。如果裝置存在但標示為 unavailable,則應確認對應 runtime 是否仍受目前的 Xcode 支援。

同時記錄工具鏈識別資訊

Destination 問題經常伴隨預設 Xcode 被切換。每次執行至少應記錄以下資訊:

xcodebuild -version
xcrun simctl list runtimes
xcrun simctl list devices available

這些輸出不包含專案金鑰,適合封存為建置診斷資訊。多台雲端 Mac 平行執行時,也能據此確認失敗是否只發生在特定工具鏈上。

動態解析模擬器,而非固定 UDID

UDID 屬於目前主機上的裝置執行個體。刪除並重建模擬器、更換 runtime 或重設工作節點後,它都可能改變。CI 應依裝置名稱與可用狀態進行查找,並要求結果必須唯一。

以下指令碼會從 simctl JSON 中選出一台可用的 iPhone 16。實際流水線還應將目標 runtime 識別碼設為參數,避免兩個系統版本中存在同名裝置。

SIM_NAME="${SIM_NAME:-iPhone 16}"

SIM_UDID="$(
  xcrun simctl list devices available -j |
  python3 -c '
import json, sys
name = sys.argv[1]
data = json.load(sys.stdin)
matches = []
for runtime, devices in data["devices"].items():
    for device in devices:
        if device.get("isAvailable") and device.get("name") == name:
            matches.append((runtime, device["udid"]))
if len(matches) != 1:
    for runtime, udid in matches:
        print(f"{runtime} {udid}", file=sys.stderr)
    raise SystemExit(f"expected one simulator named {name}, found {len(matches)}")
print(matches[0][1])
' "$SIM_NAME"
)"

printf 'Resolved simulator: %s (%s)\n' "$SIM_NAME" "$SIM_UDID"

關鍵在於候選項不唯一時立即失敗。靜默採用陣列中的第一項雖然能讓任務繼續執行,卻會把環境歧義轉變成難以重現的測試結果。

分離測試命令與封存命令

取得 UDID 後,先啟動裝置並等待初始化完成,再執行測試。bootstatus -b 可防止測試在系統服務尚未就緒時提前開始。

xcrun simctl boot "$SIM_UDID" 2>/dev/null || true
xcrun simctl bootstatus "$SIM_UDID" -b

xcodebuild test \
  -workspace "$WORKSPACE" \
  -scheme "$SCHEME" \
  -destination "platform=iOS Simulator,id=$SIM_UDID" \
  -resultBundlePath "Artifacts/TestResults.xcresult"

封存則完全不引用模擬器:

xcodebuild archive \
  -workspace "$WORKSPACE" \
  -scheme "$SCHEME" \
  -configuration Release \
  -destination "generic/platform=iOS" \
  -archivePath "Artifacts/App.xcarchive"

這種拆分也能讓權限邊界更清楚:測試階段負責管理模擬器狀態,封存階段只關注專案、簽署設定與輸出路徑。即使兩個步驟位於同一台 SetMini 雲端 Mac,也應保留兩段獨立日誌。

將解析結果轉為可驗收記錄

只讓命令成功還不夠。流水線應將 Scheme、DEVELOPER_DIR、裝置名稱、UDID、runtime、動作類型與結果套件路徑寫入純文字或 JSON。進行疑難排解時,應先比較這些事實,再討論程式碼變更。

建議在退出階段只關閉由本次任務啟動的裝置:

cleanup() {
  if [[ -n "${SIM_UDID:-}" ]]; then
    xcrun simctl shutdown "$SIM_UDID" 2>/dev/null || true
  fi
}
trap cleanup EXIT

不要在共享節點上無條件關閉所有模擬器,也不要將 erase all 當成例行清理。前者會中斷平行任務,後者會掩蓋狀態污染的來源,並增加後續初始化所需時間。

最終檢查項目可濃縮為五點:封存使用通用目標;測試使用本次動態解析出的 UDID;候選項必須唯一;執行前記錄 Xcode 與 runtime;退出時只處理本次任務擁有的裝置。做到這些,Destination 就能從隱含的環境狀態,轉變為可檢查、可重現的建置輸入。

常見問題

封存 iOS App 時需要指定某一台模擬器嗎?

不需要。封存應使用 generic/platform=iOS,避免把工作綁定到可能隨執行環境更新而改變的模擬器。

CI 腳本可以長期保存模擬器 UDID 嗎?

不建議。模擬器重建後 UDID 可能改變,應在每次工作開始時依名稱與執行環境重新解析,並記錄實際使用值。

找到多個同名模擬器時應如何處理?

先只保留 available 裝置並限制執行環境;若仍有多個候選項,腳本應停止並列出候選項,不要默默挑選第一台。

獨享實體節點

將工程步驟放進隨時可連線的雲端 Mac

從兩種 Apple Silicon 配置與四個資料中心中選擇,資源不與其他租戶共享;實際可用狀態以控制台即時回傳為準。

選擇方案