工程文章

雲端 Mac CI Shell 環境差異排查與治理

雲端 Mac CI Shell 環境差異排查與治理

在遠端終端執行建置一切正常,換成 CI 任務後卻出現 command not found、工作目錄錯誤,甚至同一支腳本手動執行成功,透過 launchd 啟動時卻失敗。這類問題通常不是工具損壞,而是三種進入方式取得的環境不同:互動式 Shell 會讀取使用者設定,非互動式 Shell 只會讀取部分設定,而 launchd 則從更精簡的環境啟動。修正重點不是繼續在設定檔中追加 PATH,而是讓所有自動化任務都透過同一個明確的進入點執行。

先保存三種入口的環境證據

先不要修改設定。請分別從遠端終端、CI 任務與 launchd 收集相同資訊,確認差異究竟出在 PATH、目前目錄,還是工具選擇。

#!/bin/zsh
set -eu

printf 'user=%s
' "$(id -un)"
printf 'uid=%s
' "$(id -u)"
printf 'shell=%s
' "${SHELL:-unset}"
printf 'home=%s
' "${HOME:-unset}"
printf 'pwd=%s
' "$PWD"
printf 'path=%s
' "${PATH:-unset}"

for tool in zsh git xcodebuild ruby python3; do
  printf '%s=' "$tool"
  command -v "$tool" || printf 'missing
'
done

/usr/bin/xcode-select -p 2>/dev/null || true
/usr/bin/sw_vers

將它儲存為 scripts/inspect-env.sh,透過三種入口分別執行,並保留各自的輸出。不要直接使用完整的 env 收集正式環境中的任務資訊,因為其中可能含有權杖或暫時性憑證。

如果兩次執行所解析出的工具絕對路徑不同,即使目前版本號相同,也應視為環境漂移。日後升級可能導致兩條路徑產生不同結果。

使用最小環境重現問題

可以使用 env -i 模擬不讀取使用者慣用設定的任務:

/usr/bin/env -i \
  HOME="$HOME" \
  USER="$USER" \
  PATH="/usr/bin:/bin:/usr/sbin:/sbin" \
  /bin/zsh ./scripts/inspect-env.sh

如果問題能在此環境中穩定重現,就不必反覆重新安裝工具,而應轉而檢查入口腳本與 PATH 定義。

建立唯一的任務入口腳本

自動化任務不應依賴 .zshrc。這個檔案是為互動式操作而設計,通常包含提示符號、別名、終端偵測,以及僅適用於登入工作階段的邏輯。請為儲存庫建立獨立入口,例如 scripts/run-ci.sh

#!/bin/zsh
set -euo pipefail

export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export LANG="en_US.UTF-8"
export LC_ALL="en_US.UTF-8"

repo_root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$repo_root"

required_tools=(git xcodebuild)
for tool in "${required_tools[@]}"; do
  if ! command -v "$tool" >/dev/null 2>&1; then
    printf 'required tool missing: %s
' "$tool" >&2
    exit 127
  fi
done

exec ./scripts/build.sh

PATH 的順序必須固定,並保留系統目錄。如果任務依賴其他工具,應先在機器上使用 command -v 確認實際位置,再將其加入入口腳本,而不是把整段使用者設定複製進來。

入口腳本也負責切換至儲存庫根目錄。如此一來,建置腳本中的相對路徑就不會受到 CI 執行器暫存目錄或 launchd 預設目錄影響。

讓 launchd 只負責啟動

launchd 設定應保持簡單:指定直譯器、入口腳本、工作目錄與記錄檔,不要在 plist 中拼接複雜指令,也不要在其中保存敏感值。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "file://localhost/System/Library/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.example.ci-runner</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/zsh</string>
    <string>/Users/runner/project/scripts/run-ci.sh</string>
  </array>
  <key>WorkingDirectory</key>
  <string>/Users/runner/project</string>
  <key>StandardOutPath</key>
  <string>/Users/runner/Library/Logs/ci-runner.out.log</string>
  <key>StandardErrorPath</key>
  <string>/Users/runner/Library/Logs/ci-runner.err.log</string>
</dict>
</plist>

載入前先檢查語法:

plutil -lint ~/Library/LaunchAgents/com.example.ci-runner.plist
launchctl bootstrap "gui/$(id -u)" \
  ~/Library/LaunchAgents/com.example.ci-runner.plist
launchctl kickstart -k \
  "gui/$(id -u)/com.example.ci-runner"

修改 plist 後,請先使用 bootout 卸載原有任務,再重新執行 bootstrap。只執行 kickstart,不會讓已載入的舊設定自動更新。

處理工具版本與敏感變數

固定路徑不代表版本也固定。入口腳本應在任務開始時輸出不敏感的工具版本、解析路徑與工作目錄,方便比較失敗的任務,但不要輸出所有環境變數。

檢查項目 建議做法 失敗時保留
工具解析 command -v 工具絕對路徑
工具版本 呼叫官方版本參數 版本文字
工作目錄 主動 cd 至儲存庫根目錄 pwd 輸出
系統工具鏈 檢查目前的開發目錄 路徑與結束碼
敏感變數 僅判斷是否存在 變數名稱,不記錄值

敏感值應由受控的憑證流程在任務執行時提供。腳本可以使用 ${TOKEN:?TOKEN is required},讓缺少變數時立即失敗,但不得透過 set -xenv 或錯誤回顯洩露內容。完成診斷後,也要檢查記錄檔是否意外記下請求標頭、指令參數或暫存檔路徑。

使用驗收矩陣完成問題排查

修正後必須從三種入口執行同一支腳本,不能只在目前的終端中驗證。

  1. 在互動式終端直接執行 ./scripts/run-ci.sh
  2. 使用 env -i,只傳入 HOME、USER 與基礎 PATH 後執行。
  3. 透過 launchctl kickstart 啟動,並檢查結束狀態與兩份記錄檔。
  4. 比較三次執行的儲存庫根目錄、工具絕對路徑、版本與結束碼。
  5. 重新啟動一次使用者工作階段後再次執行,確認任務不依賴暫時匯出的變數。

常見誤區是把修正內容寫入 .zshrc,結果手動測試恢復正常,CI 依然失敗;或是在 plist 中重複維護另一套 PATH,幾週後又與儲存庫腳本產生分歧。更穩健的職責邊界是:排程器只負責啟動、入口腳本定義環境、業務腳本執行建置。將三層職責分開後,環境差異就能被記錄、重現與審查。

在 OwnAMac 的遠端 Mac 上實作這套方法時,也應先確認任務實際使用的執行帳號與儲存庫路徑,再產生 plist。請勿直接複製範例中的使用者名稱。最終驗收標準不是「能在終端中執行」,而是最小環境與排程環境都能解析到同一組工具,並回傳一致的結果。

常見問題

為什麼指令在遠端終端可用,CI 裡卻顯示 command not found?

互動式 Shell 可能讀取使用者設定並擴充 PATH,非互動工作或 launchd 則不一定會讀取相同檔案。應在工作入口腳本明確設定 PATH,並用 command -v 驗證工具位置。

可以把存取權杖直接寫進 launchd 的 plist 嗎?

不建議。plist 可能被納入備份、日誌或診斷包。敏感值應透過受控流程在執行時注入,腳本只檢查必要變數是否存在,不輸出實際內容。

如何確認修正不只對目前的終端有效?

分別在互動式終端、env -i 建立的最小環境與 launchd 中執行同一支驗收腳本,比對 PATH、工作目錄、工具絕對路徑及結束碼。

OwnAMac 雲端 Mac

將下一次建置交給獨享的 Apple Silicon 物理節點

依照任務規模選擇三檔設定與五個節點,運算資源與儲存空間不與其他租戶共用。所有節點全年 365 天正常運作,實際可用狀態以控制台即時回傳為準。

選擇設定並訂購