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 分钟自动开通