1–5 分鐘交付

專屬 M4 Runner,告別 macOS 佇列

$21.5 / 天起 · 實體機獨享
配置雲端 Mac
M4 · 16 GB Xcode 版本可鎖 launchd 常駐

FIELD NOTE · iOS 建置

獨享 M4 節點註冊 GitHub Actions Runner:iOS 流水線接線實錄

團隊沒有常駐 Mac,卻要在 GitHub 上穩定跑 iOS Archive?本文記錄我們在 JexMac 獨享 Mac mini M4 實體節點上的完整接線過程:從 SSH 首連、Runner 註冊與 launchd 常駐,到 Workflow 標籤路由、Xcode 版本鎖定,以及無介面環境下的 Distribution 證書匯入——每一步都附可復現命令與實測耗時。

接線目標:跑通一條可驗收的 iOS 建置鏈路

在動手裝 Runner 之前,我們先定義「接線完成」的驗收標準,避免裝完 Runner 卻發現 Archive 卡在簽名環節。本次實錄的終點是:push 到指定分支 → GitHub Actions 在專屬 M4 節點上觸發 → checkout 程式碼 → 執行 xcodebuild archive 併成功產出 .xcarchive。TestFlight 上傳不在本文範圍,但 Archive 一旦穩定,後續 fastlane 或 altool 只是附加步驟。

測試倉庫是一箇中等規模的 SwiftUI 應用:約 60 個 Swift 原始檔、CocoaPods 管理三方依賴、Release 配置走 Manual 簽名。基準環境為 JexMac 新加坡節點上的 Mac mini M4(16 GB 統一記憶體、256 GB NVMe),Xcode 16.2 透過 xcodes 安裝並鎖定。

4m 08s
Clean Archive 實測(含 pod install)
< 25s
自託管 job 從排隊到開始執行
0 swap
16 GB 記憶體全程無交換分割槽
1 臺
獨享實體機,無虛擬化超售

對照組是同一倉庫在 runs-on: macos-14 託管 Runner 上的表現:UTC 13:00–18:00 時段(亞太下午)job 排隊中位數 11 分鐘,最長一次等待 19 分鐘;且 macos-14 預裝 Xcode 版本與本地開發機不一致,曾觸發 Swift 6 語法相容問題。自託管的價值在這裡非常直觀——等待時間從分鐘級降到秒級,Xcode 版本完全由你寫死在節點上

託管 macOS Runner 的三類隱性成本

很多團隊最初選 GitHub 託管 Runner 是因為「零運維」,但在 iOS 場景裡,隱性成本往往比帳單更棘手。

第一類是時間成本。GitHub 的 macOS 池容量有限,免費帳戶每月 2000 分鐘額度按 macOS 10 倍權重摺算,實際可用約 200 分鐘。一個每天觸發 8 次、單次建置 6 分鐘的專案,月消耗約 1440 加權分鐘——接近免費額度上限,稍微加個 nightly 建置就會超額。

第二類是環境漂移。macos-latest 標籤會隨 GitHub 基礎設施升級而切換底層 Xcode,歷史上多次出現「昨天綠、今天紅」的情況。臨時 workaround 是在 workflow 里加 sudo xcode-select 步驟,但每多一個版本切換就多 1–2 分鐘開銷,且無法保證與本地開發機完全一致。

第三類是除錯成本。託管 Runner 每次 job 結束後環境被銷燬,無法 SSH 進去復現問題。鑰匙串彈窗、描述檔案過期這類需要「登上去看一眼」的故障,在託管環境裡只能反覆 push 試日誌——我們曾為一個 User interaction is not allowed 錯誤來回 push 了 7 次才定位到 partition list 缺失。

實測環境說明

本文所有命令與耗時資料均在 JexMac 獨享 Mac mini M4 實體節點上執行,節點位於新加坡資料中心,測試視窗為 2026 年 7 月下旬。硬體規格:Apple M4 · 10 核 CPU · 16 GB 統一記憶體 · 1 Gbps 獨享頻寬。

節點交付後:SSH 首連與環境基線

JexMac 控制台在付款確認後通常 1–5 分鐘內推送 SSH 憑證。拿到公網 IP 後,建議先用 Ed25519 金鑰接入並關閉密碼登入,再開始裝 Runner——Runner 程序會以當前 macOS 使用者身份執行,SSH 安全基線要先打好。

  1. 01
    寫入 SSH 公鑰

    ssh-copy-id -i ~/.ssh/id_ed25519.pub jexmac@<節點IP>

    驗證免密登入成功後,編輯 /etc/ssh/sshd_config 設定 PasswordAuthentication no,重啟 sshd。

  2. 02
    安裝 Homebrew 與 xcodes

    /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

    brew install xcodesorg/made/xcodes

    xcodes install 16.2 --experimental-fast-pass 安裝並鎖定 Xcode 16.2(版本號按專案需求調整)。

  3. 03
    驗證建置鏈

    xcodebuild -version 應輸出 Xcode 16.2 及 Build 號。

    xcodebuild -showsdks | grep iphoneos 確認 iOS SDK 可用。

  4. 04
    安裝 CocoaPods(若專案使用)

    sudo gem install cocoapods -n /usr/local/bin

    提前在節點上跑通一次 pod install,確認 Specs 倉庫快取就緒,避免 CI 首次建置卡在 repo update。

基線配置全部完成後,節點上應存在一個專用目錄(本文用 ~/ci-runner)存放 Runner 與建置產物,與日常開發檔案隔離。我們在該目錄下預留 DerivedData 子目錄,後續 workflow 裡顯式指定 -derivedDataPath,防止多專案併發時路徑衝突。

GitHub 側:Runner 令牌與標籤路由設計

進入目標倉庫 → Settings → Actions → Runners → New self-hosted runner,平台選 macOS ARM64。頁面會生成一次性註冊令牌(有效期 1 小時)和下載連結。

標籤設計直接影響 workflow 能否路由到正確機器。我們的命名約定:

  • mac:所有 macOS 自託管節點的通用標籤
  • m4:標識 Apple Silicon M4 晶片,便於區分舊 Intel 節點
  • xcode-16-2:鎖定 Xcode 小版本,升級時改標籤而非改 workflow 邏輯
  • sg:資料中心區域(新加坡),多區域部署時可做就近路由

註冊命令中的 --labels 引數一次性寫入上述標籤。workflow 裡用陣列形式匹配:

jobs:
  ios-archive:
    runs-on: [self-hosted, mac, m4, xcode-16-2]
    concurrency:
      group: ios-build-${{ github.ref }}
      cancel-in-progress: true

concurrency 組確保同一分支不會並行跑兩個 Archive——在 16 GB 記憶體的 M4 節點上,雙專案併發 Clean Build 雖然能跑,但 DerivedData 爭用會讓單次耗時波動 30% 以上。如果團隊有多條產品線,建議按產品線拆標籤(如 product-aproduct-b)並配多臺節點,而不是在一臺機器上硬併發。

公開倉庫的安全邊界

自託管 Runner 在公開倉庫中可被 fork PR 觸發,惡意 workflow 將在你的 Mac 上執行任意程式碼。務必僅在私有倉庫或 Organization 級別啟用,且 Runner 程序執行在非管理員帳戶下。生產環境建議配合 GitHub Environment 保護規則,限制 secrets 僅在指定分支可用。

在 M4 節點安裝 Runner 並配置 launchd 常駐

以下步驟在 SSH 會話中執行,Runner 版本號以 GitHub 註冊頁面顯示為準(本文示例為 v2.321.0)。

  1. 01
    下載並解壓

    mkdir -p ~/ci-runner/actions-runner && cd ~/ci-runner/actions-runner

    curl -o actions-runner-osx-arm64-2.321.0.tar.gz -L \ https://github.com/actions/runner/releases/download/v2.321.0/actions-runner-osx-arm64-2.321.0.tar.gz

    tar xzf ./actions-runner-osx-arm64-2.321.0.tar.gz

  2. 02
    互動式註冊

    ./config.sh --url https://github.com/YOUR_ORG/YOUR_REPO \ --token YOUR_ONE_TIME_TOKEN \ --name jexmac-m4-sg-01 \ --labels mac,m4,xcode-16-2,sg \ --unattended

    --unattended 跳過互動確認,適合腳本化部署。Work folder 保持預設 _work 即可。

  3. 03
    安裝 launchd 服務

    ./svc.sh install

    ./svc.sh start

    驗證:./svc.sh status 應顯示 active (running)。GitHub 倉庫 Runners 頁面出現綠色 Online 狀態即註冊成功。

launchd 會在系統重啟後自動拉起 Runner,但有一個細節:Runner 以安裝時的 macOS 使用者身份執行,該使用者必須已登入過一次(或啟用自動登入),否則 launchd 可能無法訪問 Keychain。我們在節點上建立了專用帳戶 ci-bot,首次透過 SSH 登入完成鑰匙串初始化後再裝 svc.sh,後續重啟無需人工干預。

Runner 日誌位於 ~/ci-runner/actions-runner/_diag/,每次 job 結束後會生成 Worker_*.log。排查「job 分配到了節點但沒有步驟輸出」類問題時,先看這裡的 RunnerListener 日誌,比 GitHub UI 上的 Annotations 資訊更完整。

最小可用 Workflow:從 checkout 到 Archive

註冊完成後,在倉庫建立 .github/workflows/ios-archive.yml。下面是我們驗證透過的最小版本,不含 TestFlight 上傳,專注 Archive 產出:

name: iOS Archive
on:
  push:
    branches: [main, release/*]
  pull_request:
    branches: [main]

jobs:
  archive:
    runs-on: [self-hosted, mac, m4, xcode-16-2]
    timeout-minutes: 30

    steps:
      - uses: actions/checkout@v4

      - name: Select Xcode
        run: xcodes select 16.2

      - name: Install pods
        run: pod install --deployment
        working-directory: ios

      - name: Build archive
        run: |
          xcodebuild archive \
            -workspace ios/MyApp.xcworkspace \
            -scheme MyApp \
            -sdk iphoneos \
            -configuration Release \
            -archivePath ./build/MyApp.xcarchive \
            -derivedDataPath ~/ci-runner/DerivedData \
            CODE_SIGN_STYLE=Manual \
            CODE_SIGN_IDENTITY="Apple Distribution: Your Team (TEAMID)" \
            PROVISIONING_PROFILE_SPECIFIER="MyApp AppStore"
        env:
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}

      - name: Upload xcarchive artifact
        uses: actions/upload-artifact@v4
        with:
          name: MyApp-xcarchive
          path: ./build/MyApp.xcarchive
          retention-days: 7

幾個刻意保留的設計選擇:

pod install --deployment 使用 Podfile.lock 鎖定版本,避免 CI 與本地依賴不一致。-derivedDataPath 指向節點上的固定目錄,多次建置可複用編譯快取,增量建置從 4 分鐘降到約 1 分 40 秒。upload-artifact 把 xcarchive 上傳到 GitHub,方便在沒 SSH 權限的同事那裡下載驗證——artifact 保留 7 天,足夠做一次 QA 抽檢。

無介面環境:鑰匙串與 Distribution 證書匯入

Archive 步驟能否成功,取決於 CI 環境能否在無圖形介面的情況下訪問 Distribution 私鑰。託管 Runner 預配置了系統鑰匙串,但自託管節點需要你自己管理——這也是很多團隊卡在「Runner 裝好了但 Archive 報簽名錯誤」的原因。

推薦做法:每次 job 建立臨時鑰匙串,匯入 p12 後立即用於簽名,job 結束自動銷燬,避免私鑰長期駐留磁碟。

# 在 "Build archive" 步驟之前加入
- name: Import signing certificate
  run: |
    security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
    security default-keychain -s build.keychain
    security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
    security set-keychain-settings -t 3600 -u build.keychain

    echo "${{ secrets.CERTIFICATE_P12_BASE64 }}" | base64 --decode > cert.p12
    security import cert.p12 \
      -k build.keychain \
      -P "${{ secrets.P12_PASSWORD }}" \
      -T /usr/bin/codesign \
      -T /usr/bin/xcodebuild

    security set-key-partition-list \
      -S apple-tool:,apple: \
      -s -k "$KEYCHAIN_PASSWORD" build.keychain
    rm -f cert.p12
  env:
    KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}

GitHub Repository Secrets 中需預先配置三個變數:KEYCHAIN_PASSWORD(臨時鑰匙串密碼,隨機字串即可)、CERTIFICATE_P12_BASE64(Distribution 證書的 Base64 編碼)、P12_PASSWORD(匯出 p12 時設定的密碼)。

報錯關鍵詞 常見根因 處理辦法
User interaction is not allowed 私鑰 partition list 未設定,codesign 試圖彈 UI 補跑 set-key-partition-list 命令(見上方腳本)
errSecItemNotFound 證書匯入了錯誤的鑰匙串,或 default-keychain 未切換 確認 security default-keychain -s build.keychain 在 import 之前執行
could not find signing certificate CODE_SIGN_IDENTITY 字串與鑰匙串中 Identity 不完全匹配 執行 security find-identity -v -p codesigning build.keychain 複製完整名稱
Provisioning profile doesn't match 描述檔案過期或 Bundle ID / Capability 不一致 在 Apple Developer 後臺重新生成,下載後透過 secrets 或 match 同步

匯入成功後,可以用一條命令在 job 日誌裡快速驗證:

security find-identity -v -p codesigning build.keychain | grep Distribution

看到 1 valid identities found 再進入 Archive 步驟,能省掉大量「簽名失敗但不知道卡在哪」的排查時間。

排錯實錄:三個讓我們多 push 了一輪的問題

即使按上述步驟操作,首次接線仍可能遇到以下情況——都是我們真實踩過的坑,附上日誌特徵與解法。

標籤不匹配:job 永遠排隊不執行

現象:GitHub Actions UI 顯示 job 狀態 Queued,Runners 頁面節點卻是 Online。根因是 workflow 裡 runs-on 的標籤與註冊時不完全一致——比如 workflow 寫了 xcode-16.2(點號),註冊時用了 xcode-16-2(連字元)。GitHub 標籤匹配是精確字串比較,差一個字元就不會路由。

解法:在倉庫 Runners 頁面點選節點名稱,查看實際標籤列表,複製貼上到 YAML 裡,不要手打。

launchd 重啟後 Runner Offline

現象:節點 reboot 或 JexMac 側維護重啟後,Runner 顯示 Offline,需手動 SSH 執行 ./svc.sh start。根因是 launchd plist 裡的 UserName 與 SSH 登入使用者不一致,或該使用者從未完成首次圖形/session 登入。

解法:確認 ./svc.sh install./svc.sh start 在同一使用者下執行;重啟後檢查 ./svc.sh status。若仍失敗,查看 /Library/Logs/GitHubActionsRunner/ 下的 stderr 日誌。

DerivedData 權限衝突

現象:第二次建置報 Unable to write to DerivedData 或某些 .o 檔案 permission denied。根因是第一次 job 以 root 或不同使用者生成了 DerivedData 目錄,後續 job 無寫權限。

解法:統一 DerivedData 路徑並在 workflow 開頭加清理步驟:rm -rf ~/ci-runner/DerivedData && mkdir -p ~/ci-runner/DerivedData。或者在每次 Archive 前只清理對應專案的 hash 子目錄,保留可複用的編譯快取。

沒有常駐 Mac 時:按建置節奏租用專屬節點

走到這裡,你可能已經意識到:GitHub Actions 自託管 Runner 的技術門檻並不高,真正的瓶頸是有沒有一臺 24 小時線上、Xcode 版本可控的 macOS 實體機。本地 MacBook 不適合做 CI 主機——8 GB 記憶體機型跑 Archive 時風扇拉滿還頻繁 swap;公司採購 Mac mini 則要面對固定資產審批、機房託管和證書輪換時的現場維護。

我們的做法是:在迭代衝刺期(例如發版前兩週)租用 JexMac 獨享 Mac mini M4 節點充當 Runner 主機,上線穩定後按周續租或釋放。標準配置 16 GB 統一記憶體、256 GB NVMe、1 Gbps 獨享頻寬,按天 $21.5 起,付款後 1–5 分鐘交付 SSH / VNC 接入,無合同鎖定。五處節點——新加坡、日本(東京)、韓國(首爾)、中國香港、美國東部——可按團隊地理位置選擇,降低 git fetch 與 CocoaPods Specs 同步延遲。

與 GitHub 託管 macOS Runner 對比:高峰排隊 10–20 分鐘 vs 自託管 25 秒內開工;macOS 分鐘按 10 倍權重計費 vs 固定日租成本可預測。與自購硬體對比:零 upfront 投入,版本升級時換標籤或重灌 Xcode 即可,不必等採購週期。

同一臺節點還可以兼做遠端開發桌面——瀏覽器 VNC 連上去除錯 UI、Instruments 抓效能,CI 空閒視窗不浪費。對於兩三人的小團隊,「一臺 M4 實體機 = Runner + 遠端 Mac 開發環境」往往比分開買託管 Runner 分鐘和本地升級記憶體更划算。

實體機獨享 · 1–5 分鐘交付

把 Runner 接到你的 M4 節點上

文中全部命令已在 JexMac 獨享 Mac mini M4 實體節點驗證。開通 → SSH 接入 → 按步驟註冊 Runner,最快一小時內跑通 Archive。按天起租,衝刺結束可釋放,無年約捆綁。

標準配置
晶片Apple M4 · 38 TOPS
CPU10 核(4P + 6E)
記憶體16 GB 統一記憶體
網路1 Gbps 獨享頻寬
SLA99.9% 可用性
交付1–5 分鐘自動開通