同一條 xcodebuild test 命令在雲端 Mac 上連續執行數週後,可能突然回報 Destination 無法使用,或選到新安裝 runtime 中的同名模擬器。專案本身沒有變更,真正發生漂移的是 Xcode 對目標裝置的解析結果。穩定的做法不是將某個 UDID 永久寫入 CI,而是清楚區分封存、編譯檢查與模擬器測試,並在每次任務開始時解析及記錄目標。
先釐清三類建置目標
Xcode 的 Destination 回答的是「這次動作要在哪個平台與裝置上執行」,並不等同於 Scheme。同一個共享 Scheme 可以用於通用 iOS 封存、指定模擬器測試及僅編譯驗收,但三者不應共用一條語意模糊的命令。
| 任務 | 建議目標 | 是否需要具體 UDID |
|---|---|---|
| 產生 Archive | generic/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 配置與四個資料中心中選擇,資源不與其他租戶共享;實際可用狀態以控制台即時回傳為準。