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

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 |
| CPU | Intel x86_64 |
| macOS | 15.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,第一要务是不会睡着。
防止睡眠
# 禁止普通空闲睡眠sudo pmset -a sleep 0# 合盖也不因「可睡眠」而关掉系统sudo pmset -a disablesleep 1验证是否生效:
pmset -g | grep SleepDisabledioreg -r -k AppleClamshellState -d 4 \ | grep -E 'AppleClamshellState|AppleClamshellCausesSleep'期望输出:
SleepDisabled 1AppleClamshellState = YesAppleClamshellCausesSleep = No开启 SSH 和自动登录
# 开启远程登录sudo systemsetup -setremotelogin on自动登录通过「系统设置 → 用户与群组 → 登录选项 → 自动登录」配置。iOS 签名、keychain 和部分构建场景依赖 GUI 用户会话,自动登录可以确保 GUI session 一直在线。
⚠️ 自动登录会降低物理安全性。机器应放在可信网络环境中。
三、安装 Gitea Runner
我用的代码托管是自建的 Gitea 实例(内网),所以 Runner 自然是 Gitea Actions。
下载 Runner
以 v2.3.0 为例,release 资产名已经改为 gitea-runner-*:
mkdir -p ~/gitea-runnercd ~/gitea-runner
curl -fSL -o act_runner \ https://gitea.com/gitea/act_runner/releases/download/v2.3.0/gitea-runner-2.3.0-darwin-amd64chmod +x act_runner
./act_runner --version# gitea-runner version v2.3.0生成配置:
./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: 1capacity: 1 表示同一时间只跑一个 job。对单机 Xcode 来说这是合理默认:两套 xcodebuild 并行抢盘 IO 和 DerivedData,通常得不偿失。
注册 Runner
在哪创建 registration token,决定了 Runner 的服务范围:
| Scope | 可服务仓库 |
|---|---|
| Instance / 全局 | 整个 Gitea 实例 |
| Organization | 该组织下所有仓库 |
| Repository | 单个仓库 |
我选了全局 scope,方便。如果你只想给一个项目用,repo scope 更安全。
./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 Declareunavailable: dial tcp 192.168.50.12:443: connect: no route to host但同一台机器上,nc、curl、dig 全部正常。同一个局域网,同一个 IP,普通命令可达,Runner 不可达。
根因是 macOS 15 的 Local Network Privacy。对于没有 App Bundle 身份的第三方后台二进制,系统会在 TCP 层直接拦截——不是路由不通,是系统策略拒绝。Unified Log 中的 reason: NECP 和 SYN in/out: 0/0 是决定性证据:SYN 包根本没发出去就被拦截了。
解决方法是给 Runner 一个合法的 App 身份。创建一个最小化的 App Bundle:
~/Applications/GiteaRunner.app/└── Contents/ ├── Info.plist └── MacOS/ └── gitea-runnerInfo.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 本地网络授权的用途声明。
然后复制二进制并签名:
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 启动一次,系统会弹出本地网络权限请求:
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 运行时
启动和管理:
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-runnerps 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 executableXcode 同时提供 arm64 和 Universal(arm64 + x86_64)两种版本。 xcodes 默认行为可能在 Intel Mac 上下载到了 arm64-only 的版本,安装过程不会报错,直到你真正运行二进制才暴露。
预防方法(三步验证):
- 安装前确认版本标注
[Universal]:
xcodes list --data-source apple | grep '26.3'# 26.3 (17C529) [Universal]- 安装时强制走 Apple 数据源(并建议配合 aria2 加速大包下载):
xcodes install 26.3 \ --data-source apple \ --aria2 /usr/local/bin/aria2c \ --directory /Applications- 安装后验证二进制架构,并指定
DEVELOPER_DIR真跑一次:
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 平台:
# 在 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_ID | Key ID |
ASC_ISSUER_ID | Issuer ID |
ASC_PRIVATE_KEY_BASE64 | .p8 私钥文件的 Base64 |
CI 运行时临时还原:
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 Releaseon: 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.p8workflow_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、上传):

九、回顾:这台 Mac 现在每天做什么
推送一行代码 → Gitea Actions 触发 → 合盖常开的 Intel MacBook 直接开跑 → 测 / 构建 → 结果返回 Gitea。
想发 TestFlight → 手动触发 release workflow → 自动递增 Build Number → Archive → 签名 → 上传(约 5 分钟)→ Apple 处理完成后,测试人员再下载。
构建时 CPU 会冲到峰值,但也就那一阵,平时安安静静。2019 年的 Intel Mac 当主力机太慢,当 CI 跑腿绰绰有余。
