雲端 Mac 已經可以透過 SSH 登入,xcodebuild -version 也能正常回傳版本,但第一個無人值守工作仍可能卡在授權確認、附加元件安裝或工具路徑切換。更棘手的是,這類問題往往只會在新增節點、升級 Xcode 或重建 runner 後出現,而手動開啟一次圖形介面又可能暫時掩蓋問題。更穩妥的做法,是將首次啟動初始化視為獨立的節點交付步驟,而不是讓正式建置工作一邊執行、一邊修復環境。
先區分節點初始化與建置工作
節點初始化可以修改系統狀態,需要管理員權限,而且每個 Xcode 版本只需執行一次。建置工作則應具備可重複執行、無互動,並盡量不使用提權命令等特性。將兩者混在同一份 CI 指令碼中,會造成三個問題:多個工作同時安裝元件、無人值守的 sudo 等待輸入,以及失敗後無法判斷究竟是程式碼錯誤,還是環境尚未準備完成。
建議將 runner 的生命週期拆成三個階段:
- 安裝或切換 Xcode,完成授權確認與首次元件初始化。
- 執行唯讀預檢,記錄實際使用的開發者目錄與工具版本。
- 預檢通過後,runner 才進入可接收工作的狀態。
「能夠開啟 Xcode」不是驗收條件。真正的驗收標準,是 runner 使用者能在非互動式 Shell 中找到編譯工具、查詢 SDK,並完成一次最小化的專案解析。
固定實際使用的 Xcode 路徑
當機器上安裝了多個 Xcode 版本時,不要只依賴全域的 xcode-select。它會影響同一實體節點上的其他工作,而且切換版本後,很容易產生「日誌顯示一個版本,實際呼叫另一個版本」的錯覺。對 CI 而言,更合適的做法是在工作層級固定 DEVELOPER_DIR。
set -euo pipefail
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
printf 'developer_dir=%s
' "$DEVELOPER_DIR"
xcodebuild -version
xcrun --find clang
xcrun --find simctl
xcodebuild -showsdks
如果使用帶有版本號的應用程式目錄,應將路徑寫入 runner 設定或納入版本控制的環境檔案,而不是複製到多份指令碼中。更新 Xcode 時只需修改單一入口,並讓預檢輸出該路徑。如此一來,排查日誌時便能先確認工具鏈身分,再分析專案本身。
不要用 GUI 狀態取代 Shell 驗證
遠端桌面工作階段中的環境變數、預設 Shell 與權限內容,可能和 runner 服務不同。所有檢查都應以實際執行建置的同一個使用者身分進行。尤其要確認 xcrun --find 的結果位於預期的 DEVELOPER_DIR 之下,而不是只檢查應用程式目錄是否存在。
一次完成首次啟動步驟
在準備節點時,先檢查首次啟動狀態,再由具備管理員權限的初始化流程完成授權確認與元件準備:
set -euo pipefail
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
if ! xcodebuild -checkFirstLaunchStatus; then
sudo xcodebuild -license accept
sudo xcodebuild -runFirstLaunch
fi
xcodebuild -checkFirstLaunchStatus
重點不在命令本身,而在於執行位置。這些命令不應出現在每次提交都會觸發的建置指令碼中。runFirstLaunch 可能修改共用元件;若兩個並行工作同時執行,不僅浪費時間,也可能導致其中一個工作讀取到尚未完成的狀態。
授權確認同樣不能依靠點選對話方塊解決。自動化環境應明確執行命令、檢查結束碼,並在失敗時停止讓節點接收工作。如果管理員命令需要互動式輸入,就應在節點交付階段處理,不能將等待輸入的流程留給背景 runner。
使用原子鎖保護並行初始化
在自動擴充容量或多個服務同時啟動時,即使初始化指令碼原本只設計為執行一次,也可能遭到並行呼叫。macOS 的預設工具已足以透過建立目錄來實作原子鎖:
set -euo pipefail
lock_dir="/tmp/xcode-bootstrap.lock"
if ! mkdir "$lock_dir" 2>/dev/null; then
echo "Xcode bootstrap is already running" >&2
exit 75
fi
cleanup() {
rmdir "$lock_dir" 2>/dev/null || true
}
trap cleanup EXIT
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
sudo xcodebuild -license accept
sudo xcodebuild -runFirstLaunch
xcodebuild -checkFirstLaunchStatus
mkdir 在同一個檔案系統中具有原子性,因此只有一個處理程序能夠成功。結束碼 75 可交由排程層解讀為暫時性失敗,稍後再重試,而不是將節點永久標記為建置故障。
此外,還要處理異常結束後遺留的舊鎖。可以在節點啟動流程中確認沒有初始化處理程序正在執行後再清理,或將鎖定目錄放在重新啟動後會自動清空的位置。不要一看到鎖就直接刪除,否則可能破壞仍在執行的初始化流程。
在正式接收工作前建立預檢閘門
首次啟動狀態通過後,再執行一次不會修改系統的驗收。最低限度應涵蓋 Xcode 身分、SDK 查詢、模擬器工具與專案入口:
set -euo pipefail
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
xcodebuild -checkFirstLaunchStatus
xcodebuild -version
xcodebuild -showsdks >/tmp/xcode-sdks.txt
xcrun simctl list runtimes
xcodebuild -list -workspace "Example.xcworkspace"
最後一行應替換為儲存庫實際使用的 workspace 或 project。這比直接執行完整的 archive 更快,卻能提早發現專案入口不存在、相依套件解析的前置條件未滿足,或 runner 工作目錄錯誤等問題。
失敗時應保留哪些證據
診斷日誌至少應保留 DEVELOPER_DIR、xcodebuild -version、xcrun --find clang、xcrun simctl list runtimes,以及失敗命令的結束碼。不要在日誌中輸出完整的環境變數,因為其中可能包含權杖、簽署密碼或儲存庫憑證。
常見的判斷順序是:先確認路徑,再檢查首次啟動狀態,接著確認所需的 SDK 與 runtime,最後才進入專案設定。若切換了 Xcode 版本,即使舊版本已經完成初始化,也應針對新的 Developer 目錄重新執行這套流程。完成後,將初始化指令碼版本與 Xcode 版本一併記錄;如此在重建 runner 時,就能依照相同順序還原,而不必依賴某個人曾經手動開啟過哪些介面。
常見問題
為什麼 Xcode 在圖形介面可開啟,CI 仍卡在首次啟動檢查?
圖形工作階段與非互動工作可能使用不同環境及 Xcode 路徑。應在 runner 實際使用的 DEVELOPER_DIR 下執行 checkFirstLaunchStatus,並於管理員初始化階段完成 runFirstLaunch。
可以在每個 CI 工作內執行 sudo xcodebuild -runFirstLaunch 嗎?
不建議。這項操作應在節點初始化或切換 Xcode 版本後執行一次,正式工作只做唯讀預檢,避免並行安裝競爭與無人值守 sudo 阻塞。
切換 Xcode 版本後是否需要重新初始化?
需要針對新的 Developer 目錄重新檢查。授權狀態、工具元件及模擬器執行環境可能隨版本改變,不能沿用舊版本的完成標記。
需要一台獨享 Apple Silicon 建置機?
按日、週、月或季租用獨享實體節點,用於 Xcode 建置、iOS CI、遠端開發與自動化工作。