首页 / 博客 / GitLab CI iOS 打包:2026 远程 Mac 教程
ENGINEERING_BLOG · 2026.08.31

GitLab CI iOS 打包:2026 远程 Mac 教程

Linux Runner 的构建阶段通过,但 iOS Job 找不到 Xcode。

最快解法:把 iOS 发布任务路由到专用 macOS Runner,使用用户级登录会话与 Shell executor,锁定 Xcode 工具链,隔离签名凭据,并用真实 Archive、TestFlight 上传和重启恢复完成验收。不要把 Runner 注册成功当成生产环境已经可用。

这篇文章适合 3 类人:使用 GitLab、但现有 Runner 只能构建 Android 或后端项目的跨平台独立开发者;准备把手动 Xcode Archive 改成自动流水线的 iOS 开发者;正在评估远程 Mac 是否能承担签名与 TestFlight 发布的小型团队。

SECTION 01GitLab CI iOS 打包为什么必须从 macOS 会话开始

GitLab CI 的 iOS Job 不是普通的编译任务。它需要 macOS、Xcode、Apple SDK、代码签名工具、Keychain,以及能够访问 App Store Connect 的上传工具链。Linux Runner 即使可以顺利执行依赖安装、静态检查或 Android 构建,也不能替代 Xcode 的 iOS Archive 环节。

GitLab 官方文档明确说明,macOS 上的 GitLab Runner 以用户级 LaunchAgent 运行,而不是系统级 LaunchDaemon。它在用户登录时启动、用户退出时停止,并依赖该用户的 Keychain 和图形会话;官方也建议 iOS / macOS 构建使用 Shell executor。详见 GitLab macOS Runner 安装与服务模式文档

这会带来第一个容易被忽略的验收关系:

状态 你应该看到的结果 不通过时的典型原因
Mac 重启后 指定用户进入可用会话 用户没有登录,或远程桌面没有建立图形会话
LaunchAgent Runner 进程被加载 通过 SSH 执行安装,导致 launchctl 找不到用户域
GitLab 页面 Runner 显示在线并可接单 网络、代理、Runner 配置或服务进程异常
iOS Job 能执行 xcodebuild PATH 指向错误,或 Xcode 命令行组件未启用
签名步骤 能访问目标 Keychain 当前会话不属于导入证书的用户
发布任务 Archive、导出和上传分别成功 只做了 Debug Build,没有验证发布链

自动登录可以改善重启后的可用性,但不应被当成唯一的安全方案。更稳妥的做法是使用专用 macOS 用户、项目级 Runner、受保护分支和最小化凭据权限;如果你不能接受一台机器长期保持用户会话,就不应把它直接当作无人值守的 iOS 发布节点。

SECTION 02先锁定工具链,再判断 Xcode 是否真的可用

远程 Mac 上安装了多个 Xcode,并不代表 GitLab Job 会调用你以为的那个版本。Shell executor 的环境变量、登录 Shell、图形会话和手动 SSH 会话可能不同,所以只检查 /Applications 目录是不够的。

在隔离测试项目中,先让 Job 输出脱敏后的实际路径与开发者目录:

set -euo pipefail

xcode-select -p
xcrun --find xcodebuild
xcodebuild -version
xcrun --sdk iphoneos --show-sdk-path
echo "PATH=${PATH}"

不要输出证书名称、私钥内容、Token 或完整环境变量。你要确认的是:xcodebuild 来自预期的 Xcode 活动开发者目录,iPhoneOS SDK 可以被定位,Job 使用的 Shell 环境与发布环境一致。

工具链基线至少包括以下几层:

  • Xcode 活动开发者目录与命令行工具;
  • 项目声明的 iOS SDK 和部署目标;
  • Swift Package Manager、CocoaPods 或其他依赖的锁文件;
  • Ruby、Bundler、脚本解释器及其 PATH;
  • 构建配置、Scheme、签名方式和导出选项;
  • 工作目录、DerivedData 与临时 Keychain 的位置。

Apple 的发布流程不是“编译成功就结束”。官方文档要求先创建 Archive,再进行 Validate、导出或上传;Archive 是包含调试信息及发布内容的构建包。你可以参考 Apple 的 Xcode Archive 与发布文档

因此,连续使用同一提交执行 3 次验证,比单次 Job 绿色更有意义:

  1. 依赖解析成功,锁文件没有产生未提交变化;
  2. 普通编译和测试成功,说明源代码与工具链基本匹配;
  3. 真实 Archive 成功,说明发布 Scheme、签名设置和构建产物完整。

下面的区分可以避免把“构建通过”误判为“可以上架”:

验证层级 代表命令或产物 能证明什么 不能证明什么
依赖解析 xcodebuild -resolvePackageDependencies 依赖源和锁文件可用 代码能够编译
编译测试 xcodebuild test 代码、测试和部分 SDK 可用 发布签名完整
Archive xcodebuild archive.xcarchive 发布 Scheme 可以生成归档 上传权限和后台处理成功
导出 xcodebuild -exportArchive 证书、Profile、导出选项匹配 TestFlight 已出现构建
上传 Transporter 或 Xcode 上传流程 二进制已交给 App Store Connect Apple 后台处理已经完成

如果你的项目需要在 2026 年上传到 App Store Connect,还应按照 Apple 当前上传要求核对 Xcode 版本。Apple 的上传文档写明,从 2026 年开始,上传 App Store Connect 需要使用 Xcode 14 或更高版本,具体构建目标还要满足页面列出的支持组合。详情见 Apple 上传构建要求

SECTION 03Runner 路由与 Shell executor 的边界

一个常见错误是:注册了 macOS Runner,却没有限制它只能接收可信的 iOS 发布任务。Shell executor 会直接在宿主机上执行脚本,GitLab 官方明确提醒它对 Job 的隔离有限,脚本可能读取同一主机上的其他项目文件,甚至影响宿主机环境。相关边界见 GitLab Shell executor 文档

所以,发布 Runner 不应接收任意合并请求脚本,也不应同时服务于陌生仓库。你可以按下面的原则配置:

  • 发布 Runner 采用项目级范围,优先只绑定需要发布的项目;
  • Runner 添加专用标签,例如 ios-release-mac
  • .gitlab-ci.yml 中的发布 Job 明确声明该标签;
  • 发布 Runner 设置为 Protected,只接受受保护分支或标签;
  • 普通测试 Job 与发布 Job 不共用同一凭据;
  • 不可信 Merge Request 不得自动接触签名 Keychain;
  • 构建、测试、发布是否共用账号和工作目录,要根据风险决定,而不是为了省事默认共用。

GitLab 的 Runner 配置文档说明,标签用于控制 Job 路由,Protected Runner 则可限制其只运行受保护分支或标签上的任务。相关设置可查看 GitLab Runner 标签与受保护任务文档

一个最小化的路由示例可以这样写:

ios_archive:
  stage: release
  tags:
    - ios-release-mac
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
  script:
    - ./ci/check-xcode.sh
    - ./ci/archive.sh
    - ./ci/export.sh
    - ./ci/upload-testflight.sh

这里的重点不是 YAML 写法本身,而是让 ios_archive 不会被 Linux Runner 或普通测试 Runner 接走。如果同一台远程 Mac 还要服务开发调试,你至少要把发布工作目录、账号权限、缓存和 Keychain 访问边界拆开;否则一次失败的脚本可能留下临时证书、残留进程或错误的 DerivedData。

SECTION 04签名材料不能只放进一个变量

iOS 发布至少涉及两种不同的凭据体系:

  • 代码签名材料:证书、私钥、Provisioning Profile、Keychain 和项目签名设置;
  • 上传授权材料:App Store Connect API Key,通常包括 Key ID、Issuer ID 和私钥文件。

上传密钥并不能替代证书与 Provisioning Profile。Apple 的 API Key 文档说明,API Key 用于生成 JWT 并授权 App Store Connect API 请求;私钥只提供一次下载,泄露后应立即撤销。详见 Apple App Store Connect API Key 文档

在 GitLab 中,证书、Profile 和 API Key 文件更适合使用 File 类型 CI/CD 变量,并设置为 Protected;密码或短字符串则使用 Masked、Protected 变量。GitLab 文档明确区分了普通变量和 File 变量:File 变量的值会写入临时文件,Job 中的变量名保存的是文件路径。相关说明见 GitLab CI/CD 变量文档

示例只展示逻辑,不填入真实值:

set -euo pipefail

TEMP_KEYCHAIN="$RUNNER_TEMP/<TEMP_KEYCHAIN_NAME>.keychain-db"
CERT_FILE="$IOS_CERTIFICATE_FILE"
PROFILE_FILE="$IOS_PROFILE_FILE"

security create-keychain -p "<TEMP_KEYCHAIN_PASSWORD>" "$TEMP_KEYCHAIN"
security unlock-keychain -p "<TEMP_KEYCHAIN_PASSWORD>" "$TEMP_KEYCHAIN"
security import "$CERT_FILE" \
  -k "$TEMP_KEYCHAIN" \
  -P "<CERTIFICATE_PASSWORD>" \
  -T /usr/bin/codesign \
  -T /usr/bin/security

mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles"
cp "$PROFILE_FILE" "$HOME/Library/MobileDevice/Provisioning Profiles/<PROFILE_UUID>.mobileprovision"

security list-keychains -d user -s "$TEMP_KEYCHAIN"

生产配置还要补上这些防护:

  • 只在受保护分支或受保护标签中注入签名变量;
  • 不执行 envprintenv 或打印变量内容的调试命令;
  • 不把 .p12、私钥、Profile 和临时 Keychain 放进 Git 仓库;
  • 不把签名材料放入普通 Cache;
  • Job 结束后删除临时文件与临时 Keychain;
  • API Key 的权限只满足当前发布任务,不直接使用过宽的团队级权限。

GitLab 也提醒,Masked 变量不是绝对防泄露机制;恶意脚本仍可能通过读取文件、修改构建过程或执行任意命令获取敏感信息。因此,受保护变量只能作为权限边界的一部分,不能替代可信 Runner 和受控仓库。

SECTION 05Cache、Artifacts 与发布产物分开管理

依赖缓存和发布产物的用途不同,混用后最容易出现“这次构建能过,下次却找不到归档”的问题。

GitLab 官方定义中,Cache 主要用于复用下载的依赖;Artifacts 用于在 Job 或阶段之间传递构建结果。Artifacts 默认保留 30 天,但项目可以自定义期限;这个数字来自 GitLab 当前文档,不应被理解为你项目必须采用的保留策略。具体行为见 GitLab Cache 与 Artifacts 文档

推荐这样分配:

内容 建议使用 原因
Swift Package、Pods 下载目录 Cache 可重新生成,适合按锁文件失效
xcarchive Artifacts 需要与本次提交、导出结果建立关系
导出的 IPA Artifacts 或外部制品库 供后续上传、验收或回溯
xcresult Artifacts 保存测试结果、失败诊断和报告
证书、私钥、Profile CI/CD 变量 不应进入普通缓存或发布制品
临时 Keychain 不保留 Job 完成后清理

缓存键应绑定依赖锁文件,而不是只使用分支名:

cache:
  key:
    files:
      - Package.resolved
      - Podfile.lock
  paths:
    - .build/
    - Pods/

如果 Xcode、依赖锁文件或构建架构发生变化,应主动更换缓存键。不能把缓存当成永远存在的构建输入;即使缓存命中,发布 Job 也应能够在干净环境中重新解析依赖并完成构建。

Artifacts 则需要保留可追踪关系,例如:

artifacts:
  when: always
  paths:
    - build/<APP_ARCHIVE>.xcarchive
    - build/<EXPORT_DIR>/
    - build/<TEST_RESULT>.xcresult
  expire_in: "<PROJECT_DEFINED_RETENTION>"

不要把唯一的发布归档只放在 Cache 中,也不要让导出包和 xcresult 使用互不相关的命名。发布失败时,你需要能够从 Commit、Pipeline、Archive、导出包一路追到具体的签名和测试结果。

SECTION 06按六项指标完成一次可复现验收

不要按“安装 Runner、编辑 YAML、点击运行”的时间线验收。生产可用性应按指标判断,下面这套顺序更适合远程 Mac:

  1. 会话指标:重启 Mac,确认专用用户进入有效登录会话;检查 LaunchAgent、Runner 进程和 GitLab 在线状态。
  2. 工具链指标:在 CI Job 中输出 xcode-selectxcrunxcodebuild -version 的脱敏结果,确认调用路径一致。
  3. 路由指标:提交普通测试任务与受保护发布任务,确认前者不会被发布 Runner 接收,后者不会落到 Linux Runner。
  4. 凭据指标:只在受保护任务注入证书和 API Key,验证临时 Keychain 能被 codesign 使用,任务结束后材料不残留。
  5. 产物指标:分别保留 xcarchive、导出包和 xcresult,确认它们与 Commit、Pipeline ID 及版本号可对应。
  6. 发布指标:执行真实 Archive、签名导出、上传 TestFlight,并等待 App Store Connect 后台处理状态。

上传后构建不会立即等同于 TestFlight 可用;构建需要经过 Apple 系统处理,完成后才会出现在 App Store Connect。上传方式可以使用 Xcode、Transporter 或相关接口,最终仍应在 App Store Connect 中核对版本号、Build Number 和处理状态。

最终验收应包含一次重启后的第二轮 Job。你要重新检查:

  • Runner 是否恢复在线并能接单;
  • Job 是否仍调用正确的 Xcode;
  • Keychain 是否仍能完成签名;
  • 上传工具是否能访问 App Store Connect;
  • 下一次构建是否生成新的、可追踪的 Archive。

如果重启后只需要手动登录一次就能恢复,可以标记为“需修复”,而不是直接判定通过。若发布 Runner 与个人开发环境共用账号、目录和签名材料,或者任意合并请求都能触发发布任务,则应标记为“不适合共用主机”。

决策条件:你的 Mac 是否适合承担发布 Runner

  • 若 Mac 能保持专用用户会话,Runner 使用 Shell executor,Xcode 工具链可固定,签名变量能限制到受保护任务,并且真实 Archive 与 TestFlight 上传在重启后仍能完成,则可以选择常驻部署。
  • 若 Mac 能完成手动 Archive,但重启后 Runner 离线、Keychain 不可用或 Xcode 路径变化,则先修复会话与工具链问题,不要立即把它用于自动发布。
  • 若 Mac 同时服务多个不可信仓库,发布账号与个人开发账号无法隔离,或者工作目录和签名材料必须共用,则回退到隔离主机或独立远程 Mac。
  • 若你只有临时发布需求,没有长期在线的 Mac,则先选择远程 Mac 跑通一条真实 Pipeline;当构建频率、团队人数或发布风险上升后,再评估更长周期的常驻方案。

SECTION 07常见问题

Linux Runner 构建 iOS 为什么会失败?

Linux Runner 可以运行 GitLab CI 脚本,但没有 macOS、Xcode、Apple SDK 和 macOS Keychain。它适合承担后端、静态分析或跨平台任务;需要 Xcode Build、Archive、签名导出和 TestFlight 上传的 Job,必须通过标签路由到真实 macOS Runner。

GitLab Runner 在 macOS 重启后为什么离线?

macOS Runner 使用用户级 LaunchAgent,用户退出后 Runner 也会停止。先确认目标用户已登录,再检查 ~/Library/LaunchAgents/ 下的服务、Runner 日志和 GitLab 接单状态;如果你通过 SSH 而不是图形会话执行服务管理,也可能遇到 launchctl 用户域错误。

签名证书与 Provisioning Profile 应该怎样注入?

把证书、私钥和 Profile 与 API Key 分开管理。证书及 Profile 进入临时 Keychain 和临时目录,API Key 只用于 App Store Connect 授权;在 GitLab 中使用 Protected、Masked 或 File 类型变量,并在 Job 完成后清理导入材料。

怎样确认上传的构建真的进入 TestFlight?

命令返回成功只能说明上传请求完成,不能证明 Apple 后台处理结束。你还需要在 App Store Connect 中确认对应 Bundle ID、版本号和 Build Number,并等待构建状态完成处理;上传后的构建可能仍处于处理、失败或缺少合规信息的状态。

远程 Mac 运行 GitLab Runner 是否必须保持图形会话?

对于纯 Shell 命令不是所有步骤都需要图形界面,但 iOS 发布链依赖用户级 Runner、Keychain 和部分图形会话能力。远程 Mac 最好使用专用用户保持登录,并通过受保护 Runner 和最小权限控制风险,而不是把 Runner 安装成没有用户会话的系统服务。

如果你现在的方案是 Linux Runner 加手动登录个人 Mac,真实缺点通常有 3 个:iOS Job 会在工具链边界处失败,签名材料容易散落在个人环境中,Mac 重启后也没有明确的恢复验收。继续把发布任务混进日常开发机,短期能省一次配置,长期却会让缓存、Keychain、工作目录和权限互相污染。

更稳妥的做法是先用专用远程 Mac 跑通一次受保护分支 Pipeline,再根据你的构建频率决定使用临时周期还是常驻周期。你可以先查看 MACNOX 的远程 Mac 方案,再对照 套餐与周期说明 评估是否适合你的 iOS 打包量;如果已经准备好项目,也可以直接从 Mac 远程使用入口 开始配置。它不一定适合需要长期高负载、物理接口或完全掌控硬件的团队,但对于没有常驻 Mac、又需要先验证 GitLab CI iOS 打包链路的独立开发者,先租一台可保持会话的远程 Mac,通常比立刻购买专用打包机更容易验证方案。

SECTION 08常见问题 FAQ

远程 Mac 运行 GitLab Runner 时,必须一直保持图形会话吗?

对于普通 Shell 命令不一定,但 iOS 发布链通常需要保持 Runner 所属用户的登录会话,因为 macOS Runner 以用户级 LaunchAgent 运行,并依赖用户 Keychain。远程 Mac 应设计为专用用户会话,而不是把 Runner 当成无会话的系统守护进程。