我把积灰的 MacBook Pro 2019 改成了无限额度 Xcode CI Runner


合盖运行的 MacBook Pro 2019,柜子里默默干活

TL;DR

  • 闲置 MacBook Pro 16” 2019(Intel) 合盖常驻,接 Gitea Actions 当 Xcode CI Runner
  • 目标:push 自动测 / 构建;手动触发后 Archive 并上传 TestFlight
  • 最硬的两个坑:macOS 本地网络隐私(裸二进制被 NECP 拦)、Intel 上装到 arm64-only 的 Xcode
  • 一次 Internal TestFlight 全流程约 5 分多钟(上传完成);Apple 侧处理后再可分发
  • 「无限额度」指不计云构建分钟;单机串行、占盘、散热和维护成本仍在

最近在折腾 iOS 开发,每次改完代码 push 到 Gitea 之后,还要手动打开 Xcode、Archive、上传 TestFlight。一两周还行,长期下来实在受不了。

于是我开始看自动化方案。

Xcode Cloud 随 Apple Developer Program($99/年)附带每月 25 个计算小时,对轻度使用来说其实够用,Apple 生态内的集成也是最顺滑的。EAS(Expo Application Services) 也有免费 tier,适合 Expo / React Native 项目。

这些服务本身都不错。但我的需求不太一样:多个项目长期跑、频繁构建、不想数着分钟过日子——我要的是不计费分钟、自主可控

那就不如引入一台自己的常驻机器。

然后我扭头看了看柜子里那台 2019 年的 Intel MacBook Pro 16。

它屏幕完好,电池也正常,只是作为主力机已经太慢了。放着吃灰不如让它干点活。


一、改造目标

目标很明确:这台 Mac 接收 Gitea 的 push 事件,自动跑测试、构建;需要发版时再把 IPA 上传到 TestFlight。 日常不需要我碰它。

先报一下机器基本情况:

项目
机型MacBook Pro 16-inch 2019
CPUIntel x86_64
macOS15.7.8 Sequoia(Xcode 26.3 要求 ≥15.6,从 14 升级)
运行方式合盖、无外接显示器、只接电源
Runner 并发capacity: 1(串行)

最终效果:

  • push 之后,合盖的 Mac 在后台跑测试 / Simulator 构建,结果回到 Gitea
  • 手动触发 release workflow 后,约 5 分多钟完成 Archive 并上传;再等 Apple 异步处理,测试人员就能在 TestFlight 里看到

二、让 Mac 能 24 小时待命

作为 CI Runner,第一要务是不会睡着

防止睡眠

Terminal window
# 禁止普通空闲睡眠
sudo pmset -a sleep 0
# 合盖也不因「可睡眠」而关掉系统
sudo pmset -a disablesleep 1

验证是否生效:

Terminal window
pmset -g | grep SleepDisabled
ioreg -r -k AppleClamshellState -d 4 \
| grep -E 'AppleClamshellState|AppleClamshellCausesSleep'

期望输出:

SleepDisabled 1
AppleClamshellState = Yes
AppleClamshellCausesSleep = No

开启 SSH 和自动登录

Terminal window
# 开启远程登录
sudo systemsetup -setremotelogin on

自动登录通过「系统设置 → 用户与群组 → 登录选项 → 自动登录」配置。iOS 签名、keychain 和部分构建场景依赖 GUI 用户会话,自动登录可以确保 GUI session 一直在线。

⚠️ 自动登录会降低物理安全性。机器应放在可信网络环境中。


三、安装 Gitea Runner

我用的代码托管是自建的 Gitea 实例(内网),所以 Runner 自然是 Gitea Actions。

下载 Runner

以 v2.3.0 为例,release 资产名已经改为 gitea-runner-*

Terminal window
mkdir -p ~/gitea-runner
cd ~/gitea-runner
curl -fSL -o act_runner \
https://gitea.com/gitea/act_runner/releases/download/v2.3.0/gitea-runner-2.3.0-darwin-amd64
chmod +x act_runner
./act_runner --version
# gitea-runner version v2.3.0

生成配置:

Terminal window
./act_runner generate-config > config.yaml

配置 Runner Labels

默认配置会注册 ubuntu-latest:docker://...,这跟 Xcode 没什么关系。iOS 构建必须在 macOS 宿主机执行,所以改成 host labels:

runner:
labels:
- "macos:host"
- "macos-latest:host"
- "x64:host"
- "intel:host"
file: /Users/tomyail/gitea-runner/.runner
capacity: 1

capacity: 1 表示同一时间只跑一个 job。对单机 Xcode 来说这是合理默认:两套 xcodebuild 并行抢盘 IO 和 DerivedData,通常得不偿失。

注册 Runner

在哪创建 registration token,决定了 Runner 的服务范围:

Scope可服务仓库
Instance / 全局整个 Gitea 实例
Organization该组织下所有仓库
Repository单个仓库

我选了全局 scope,方便。如果你只想给一个项目用,repo scope 更安全。

Terminal window
./act_runner register \
--instance https://gitea.example.com \
--token '<REGISTRATION_TOKEN>' \
--name macos-intel-runner \
--no-interactive \
--config config.yaml

注册成功后在 Gitea 的 Actions → Runners 页面就能看到这台 runner,状态为 Online。


四、macOS 的本地网络权限——最隐蔽的坑

Runner 注册完了,我写了个 LaunchAgent 让它开机自动启动,然后——翻车了。

fail to invoke Declare
unavailable: dial tcp 192.168.50.12:443: connect: no route to host

但同一台机器上,nccurldig 全部正常。同一个局域网,同一个 IP,普通命令可达,Runner 不可达。

根因是 macOS 15 的 Local Network Privacy。对于没有 App Bundle 身份的第三方后台二进制,系统会在 TCP 层直接拦截——不是路由不通,是系统策略拒绝。Unified Log 中的 reason: NECPSYN in/out: 0/0 是决定性证据:SYN 包根本没发出去就被拦截了。

解决方法是给 Runner 一个合法的 App 身份。创建一个最小化的 App Bundle:

~/Applications/GiteaRunner.app/
└── Contents/
├── Info.plist
└── MacOS/
└── gitea-runner

Info.plist 的关键字段:

<key>CFBundleIdentifier</key>
<string>com.tomyail.gitea-runner</string>
<key>NSLocalNetworkUsageDescription</key>
<string>连接局域网中的 Gitea 实例,以接收并执行 CI 构建任务。</string>
<key>LSUIElement</key>
<true/>

LSUIElement = true 让它在 GUI 启动时不出现在 Dock 上。NSLocalNetworkUsageDescription 是 macOS 本地网络授权的用途声明。

然后复制二进制并签名:

Terminal window
APP="$HOME/Applications/GiteaRunner.app"
mkdir -p "$APP/Contents/MacOS"
cp ~/gitea-runner/act_runner "$APP/Contents/MacOS/gitea-runner"
chmod 755 "$APP/Contents/MacOS/gitea-runner"
xattr -cr "$APP"
codesign --force --deep --sign - "$APP"
codesign --verify --deep --strict "$APP"

GUI 启动一次,系统会弹出本地网络权限请求:

Terminal window
open -n "$HOME/Applications/GiteaRunner.app" \
--args daemon --config /Users/tomyail/gitea-runner/config.yaml

在「系统设置 → 隐私与安全性 → 本地网络」中勾选 Gitea Runner。授权一次后,LaunchAgent 启动也被识别为同一身份,永久生效。

Runner 日志最终变成:

runner: macos-intel-runner, with version: v2.3.0,
with labels: [macos macos-latest x64 intel], declare successfully

五、LaunchAgent 常驻

Runner 应该在 GUI 自动登录后自动启动,挂了自动重启。LaunchAgent 是最合适的方式。

~/Library/LaunchAgents/com.tomyail.gitea-runner.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.tomyail.gitea-runner</string>
<key>AssociatedBundleIdentifiers</key>
<array>
<string>com.tomyail.gitea-runner</string>
</array>
<key>LimitLoadToSessionType</key>
<string>Aqua</string>
<key>ProgramArguments</key>
<array>
<string>/Users/tomyail/Applications/GiteaRunner.app/Contents/MacOS/gitea-runner</string>
<string>daemon</string>
<string>--config</string>
<string>/Users/tomyail/gitea-runner/config.yaml</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/tomyail/gitea-runner</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>15</integer>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key>
<string>/Users/tomyail</string>
<key>PATH</key>
<string>/Users/tomyail/.local/share/mise/shims:/Users/tomyail/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
</dict>
</plist>

几个要点:

  • AssociatedBundleIdentifiers:与 App Bundle 身份绑定,确保本地网络权限延续
  • LimitLoadToSessionType = Aqua:在 GUI 会话中运行,keychain 和签名才可用
  • KeepAlive:进程意外退出自动重启
  • PATH 中包含 Node(mise shims),因为 actions/checkout 等 JavaScript action 依赖 Node 运行时

启动和管理:

Terminal window
PLIST="$HOME/Library/LaunchAgents/com.tomyail.gitea-runner.plist"
launchctl load "$PLIST" # 启动
launchctl unload "$PLIST" # 停止
launchctl unload "$PLIST" && launchctl load "$PLIST" # 重启
# 检查状态
launchctl list | grep gitea-runner
ps aux | grep '[G]iteaRunner.app'
tail -f ~/gitea-runner/runner.log

到这里,Runner 已就位。接下来是让它真正干活。


六、从 Xcode 16.4 到 26.3

最初我给这台 Runner 装的是 Xcode 16.4。当时的需求很简单:push 代码后自动跑一下 Swift 单元测试、构建 Simulator 版本,确认提交没有破坏编译。16.4 做到这些绰绰有余,没必要追新。

后来需求变了——我要自动发布 TestFlight。Apple 有一条硬性规定:从 2026 年 4 月 28 日起,上传 App Store Connect 的 App 必须使用 Xcode 26+,基于 iOS 26 SDK 构建。官方说明

16.4 的 Archive 已经不满足上传门槛,必须升级。

中间也短暂想过「日常 CI 留 16.4、发布链用 26.3」的双轨方案。装完并验证 Xcode 26.3 之后,单元测试、Simulator 构建、Archive、签名、TestFlight 上传全部跑通。对当前项目体量来说,维护两套 Xcode 和两套缓存的收益不大——最终所有 workflow 统一绑定到 26.3,仓库里不再区分「开发用 Xcode」和「发布用 Xcode」。

每个 Xcode 版本对 macOS 有最低版本要求(例如 26.3 要求 macOS ≥ 15.6)。这些要求会随版本变化,具体可以到 xcodereleases.com 查当前版本对应的 Requires 字段。

另:Xcode 的营销版本号和它自带的 iOS SDK 小版本不一定相同。我这边是 Xcode 26.3 + iPhoneOS 26.2 SDK,仍满足「iOS 26 SDK 或更高」的上传要求。


七、安装 Xcode 26.3

关键坑:下载完成 ≠ 能跑

我在 Intel Mac 上执行 xcodes install 26.3,下载、解压、安装全程顺利。然后运行 xcodebuild -version,报错:

sudo: unable to execute .../xcodebuild: Bad CPU type in executable

Xcode 同时提供 arm64 和 Universal(arm64 + x86_64)两种版本。 xcodes 默认行为可能在 Intel Mac 上下载到了 arm64-only 的版本,安装过程不会报错,直到你真正运行二进制才暴露。

预防方法(三步验证):

  1. 安装前确认版本标注 [Universal]
Terminal window
xcodes list --data-source apple | grep '26.3'
# 26.3 (17C529) [Universal]
  1. 安装时强制走 Apple 数据源(并建议配合 aria2 加速大包下载):
Terminal window
xcodes install 26.3 \
--data-source apple \
--aria2 /usr/local/bin/aria2c \
--directory /Applications
  1. 安装后验证二进制架构,并指定 DEVELOPER_DIR 真跑一次:
Terminal window
file /Applications/Xcode-26.3.0.app/Contents/Developer/usr/bin/xcodebuild
# Mach-O universal binary with 2 architectures:
# [x86_64:Mach-O 64-bit executable x86_64]
# [arm64:Mach-O 64-bit executable arm64]
DEVELOPER_DIR=/Applications/Xcode-26.3.0.app/Contents/Developer \
xcodebuild -version
# Xcode 26.3
# Build version 17C529

三项缺一不可:Apple 列表标注 [Universal]、二进制同时包含两个架构、指定 DEVELOPER_DIR 后能真实运行。

也不要从 Apple Silicon 本机直接拷 Xcode 到 Intel Runner:体积大、可能是 arm64-only,平台组件也不会跟着正确落位。

安装 iOS 平台组件

Xcode App 安装完成 ≠ 能构建 iOS。新版 Xcode 按需管理平台组件,需要额外安装 iOS Universal 平台:

Terminal window
# 在 Xcode 26.3 下安装 iOS 平台
DEVELOPER_DIR=/Applications/Xcode-26.3.0.app/Contents/Developer \
xcodebuild -downloadPlatform iOS

验收标准是 xcodebuild -showsdks 中出现 iphoneos26.x,并且一次真实的 Device Archive 能成功。


八、TestFlight 自动发布

前提:本机已经能手动 Archive 并上传过一次(证书、Provisioning、Bundle ID 等签过名链路)。下面只写 CI 侧怎么接。

Fastlane 配置

使用 Fastlane 的 build_app(gym)和 upload_to_testflight(pilot)。签名通过 App Store Connect API Key 完成。

API Key 敏感信息存入 Gitea Secrets:

Secret内容
ASC_KEY_IDKey ID
ASC_ISSUER_IDIssuer ID
ASC_PRIVATE_KEY_BASE64.p8 私钥文件的 Base64

CI 运行时临时还原:

Terminal window
echo "$ASC_PRIVATE_KEY_BASE64" | base64 -d > /tmp/asc_key.p8
# Fastlane 使用这个路径

结束后删除,不留下 .p8 文件。

Build Number 自动递增

App Store Connect 中同一营销版本(如 0.1.0)下,每次上传需要唯一的 Build Number。在 Fastlane 中查询最新构建号并加一:

latest_build = latest_testflight_build_number(version: app_version)
increment_build_number(build_number: latest_build + 1)

上传成功 ≠ TestFlight 可用

构建上传成功后,Fastlane 默认会轮询等待 Apple 完成异步处理。但我的构建明明已经 processingState: VALID,CI 仍输出:

Waiting for App Store Connect to finish processing the new build

这不是失败,是 Apple 的多个异步校验阶段之间存在时间差。让单机 Runner 一直等没有价值,还会阻塞后续任务。我关掉了等待:

upload_to_testflight(
skip_waiting_for_build_processing: true
)

CI 只负责把包交上去;几分钟后再去 App Store Connect 看,构建通常已经可分发。没必要让 CI 替你等。

发布 Workflow

Gitea Actions 兼容大部分 GitHub Actions 语法;下面的 github.event.inputs.* 在 act_runner 上可直接用。

name: TestFlight Release
on:
workflow_dispatch:
inputs:
version:
description: 'App 版本号(如 0.1.0)'
required: true
jobs:
release:
runs-on: macos
env:
DEVELOPER_DIR: /Applications/Xcode-26.3.0.app/Contents/Developer
steps:
- uses: actions/checkout@v4
- name: 还原 API Key
run: |
echo "$ASC_PRIVATE_KEY_BASE64" | base64 -d > /tmp/asc_key.p8
env:
ASC_PRIVATE_KEY_BASE64: ${{ secrets.ASC_PRIVATE_KEY_BASE64 }}
- name: 安装 Ruby 依赖
run: bundle check || bundle install
- name: Archive + 上传 TestFlight
run: |
bundle exec fastlane beta \
version:${{ github.event.inputs.version }}
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_PATH: /tmp/asc_key.p8
- name: 清理 API Key
if: always()
run: rm -f /tmp/asc_key.p8

workflow_dispatch 让发布由人手动触发,而不是每次 push 都试图上传 TestFlight。

单机 Runner 的缓存策略

所有构建在同一台固定机器上串行运行,没必要每次清空缓存。常规 CI 与 TestFlight 现在使用同一个 Xcode 26.3 缓存命名空间:

~/.cache/myapp/v1/xcode-26.3/

内容包括 Bundler 依赖、共享的 SwiftPM 测试产物和各自隔离的 DerivedData。Workflow 中不再默认 clean,由 Xcode 自己做增量构建。

一次完整的 Internal TestFlight 流水线大约 5 分 14 秒(含依赖安装、Archive、上传):

完整的 CI → TestFlight 流水线运行结果


九、回顾:这台 Mac 现在每天做什么

推送一行代码 → Gitea Actions 触发 → 合盖常开的 Intel MacBook 直接开跑 → 测 / 构建 → 结果返回 Gitea。

想发 TestFlight → 手动触发 release workflow → 自动递增 Build Number → Archive → 签名 → 上传(约 5 分钟)→ Apple 处理完成后,测试人员再下载。

构建时 CPU 会冲到峰值,但也就那一阵,平时安安静静。2019 年的 Intel Mac 当主力机太慢,当 CI 跑腿绰绰有余。

Xcode 构建峰值时的 CPU 占用,短暂冲高后迅速回落