标签路由明明正确,第二个项目却读到了前一个项目残留的源码、缓存或签名文件。

最快的解法是:把普通编译测试放进受控共享构建池,把生产签名、不同信任域项目和非可信合并请求放进专用 Mac Runner;不要把标签、Protected Runner 或目录拆分误当成完整安全隔离。

本周建议动作:先盘点项目的代码来源、仓库权限、内部网络访问和签名资产,再决定哪些任务能共享、哪些任务需要独立账号,哪些任务必须使用独立 Mac。

这篇文章适合三类人:需要为多个仓库统一提供 GitLab macOS 构建资源的平台工程负责人;需要控制证书、私钥和内部依赖访问范围的安全与发布负责人;正在评估共享、专用或弹性远程 Mac 节点数量的 IT 与采购决策者。

先搭双层架构,再接入第二个项目

GitLab CI 多项目共享 Mac Runner 的正确起点,不是注册 Runner,而是先划定信任边界。推荐采用下面的双层结构:

普通构建、单元测试、非生产归档
        │
        ▼
受控共享 Mac 构建池
group runner + macos-build 标签
        │
        ├── 仅允许可信项目
        ├── 独立工作目录与缓存键
        └── 任务结束后清理

生产归档、正式签名、上传商店
        │
        ▼
专用发布 Mac 池
project runner 或受限 group runner
        │
        ├── Protected Runner
        ├── 仅接受 Protected 分支或标签
        └── 独立 macOS 账号与 Keychain

GitLab 官方将 Runner 分为 instance runner、group runner 和 project runner。group runner 可以服务一个群组及其子群组中的项目,project runner 则可以限制给单个项目使用。对于企业多项目共享,通常应先从受控 group runner 开始,而不是直接开放 instance runner。参考 GitLab Runner 的范围管理文档

你可以按四个条件给任务分类:

  • 可进入共享池:代码来自可信仓库,项目成员权限相近,不访问高敏感内部网络,也不接触生产签名私钥。
  • ⚠️ 需要独立账号:项目仍可共享硬件,但需要与其他项目区分 macOS 本地用户、Home 目录、缓存和依赖配置。
  • 必须独立 Mac:任务包含生产证书私钥、正式发布凭证、跨部门代码,或允许非可信分支执行任意脚本。
  • 不应进入共享 Shell Runner:来源无法确认的合并请求,尤其是可以修改 CI 脚本并在构建机上执行命令的任务。

共享硬件不等于共享身份。GitLab Runner 的 scope 决定哪些项目可以看到 Runner,标签决定哪些任务可以匹配,Protected Runner 决定是否只接受受保护分支或标签;这些都是调度和访问控制,不是主机级沙箱。GitLab 官方 Runner 配置说明明确建议使用受保护分支或受保护标签,避免 Runner 暴露敏感信息。

第一阶段:用 Runner 范围和标签建立准入线

在注册 GitLab Runner 前,先完成项目清单。不要先注册一个能被所有项目使用的 instance runner,再靠开发者自觉写标签。

建议的起步方式是:

任务类型 Runner 范围 推荐标签 凭证边界 是否进入共享池
普通编译与单元测试 group runner macos-build 无生产签名私钥 ✅ 可以
测试包归档 group runner 或独立 project runner macos-archive 测试证书与测试 Profile 视信任域决定
正式归档与发布 project runner 或专用 group runner macos-release 生产证书、私钥、发布凭证 ❌ 不与普通池混用
非可信合并请求 独立节点或其他隔离方案 不匹配发布标签 不能接触生产凭证 ❌ 不进入共享发布池

GitLab 任务只能匹配具备相应标签的 Runner。macOS 官方设置流程采用 Shell executor,并要求在 Runner 注册时选择 shell,再在 .gitlab-ci.yml 中使用匹配标签。GitLab macOS Runner 设置文档给出了安装 GitLab Runner、配置 Xcode 和注册 Shell Runner 的流程。

最小路由配置可以保持得很短:

build_ios:
  stage: build
  tags:
    - macos-build
  script:
    - xcodebuild -workspace App.xcworkspace \
        -scheme App \
        -sdk iphonesimulator \
        build

发布任务不要只把标签改成 macos-release 就结束。你还需要在 GitLab 中把 Runner 设置为 Protected,并让发布作业只由受保护分支或受保护标签触发。这样可以减少错误路由,但不能防止一个已经被允许执行的脚本读取同一主机上的其他文件。

验证时至少保留四类证据:

  1. Runner 的范围截图,证明它属于正确的 group 或 project。
  2. Runner 标签与 .gitlab-ci.yml 标签的匹配记录。
  3. 允许项目清单,证明没有意外继承到其他业务组。
  4. 一次拒绝测试,证明普通项目无法匹配 macos-release

第二阶段:让首条流水线在真实服务账号下运行

macOS Shell executor 的关键风险,在于任务直接执行在 Runner 主机上。GitLab 官方明确提醒,Shell executor 的隔离能力有限,适合运行可信构建;作业可能访问同一主机上的其他项目代码。Shell executor 安全说明不应被简化成“每个 job 都自动有独立容器”。

因此,首条流水线不能用管理员终端测试成功就算验收。你要确认 GitLab Runner 服务实际使用的 macOS 本地账号,并在这个账号下检查:

  • $CI_BUILDS_DIR 对应的源码目录;
  • $CI_PROJECT_DIR 对应的当前项目工作区;
  • 缓存目录、临时目录与 DerivedData;
  • Homebrew、Ruby、CocoaPods 和 Xcode 的用户级配置;
  • SSH 配置、Git 凭证和其他环境变量;
  • 任务结束后是否仍有后台进程、模拟器或文件句柄。

目录拆分应该服务于清理和审计,而不是制造虚假的隔离感。源码、可重建缓存、构建归档和长期凭证必须分开处理。缓存可以按项目和依赖锁定文件建立不同的 key;制品应通过 GitLab artifacts 传递,而不是依赖下一个项目读取上一个项目留下的目录。GitLab 对 cache 和 artifacts 的用途有明确区分,可参考 CI/CD 缓存官方文档

你可以为共享构建作业设置清理动作:

default:
  after_script:
    - rm -rf "$CI_PROJECT_DIR/DerivedData"
    - rm -rf "$CI_PROJECT_DIR/tmp"
    - git clean -ffdx

build_ios:
  tags:
    - macos-build
  script:
    - xcodebuild -workspace App.xcworkspace -scheme App build

这段配置只能清理你明确指定的路径,不能自动消除主机上所有残留。GitLab 的 after_script 在缓存和制品上传前执行,且运行在新的 Shell 中;如果清理失败而流水线仍显示成功,你就可能把残留文件一并打包或缓存。GitLab YAML 语法文档说明了 after_script 的执行时机和失败行为。

验收时应主动执行跨项目读取测试。项目 A 先创建一个带唯一标识的临时文件、环境变量和后台进程;项目 B 再检查这些内容是否存在。若项目 B 能读到项目 A 的源码、缓存、环境变量或长期凭证,就不能继续扩大共享范围。

⚠️ 目录分拆只能降低误用概率,不能阻止同一个 Shell 用户读取其他项目资产。只要任务脚本可以在同一用户上下文中执行任意命令,就必须把不同信任域的问题上升到独立账号或独立 Mac。

第三阶段:接入第二个项目时主动制造污染场景

第一个项目成功,只能证明工具链基本可用,不能证明共享池安全。第二个项目接入时,你应当故意检查那些最容易被忽略的残留点。

检查源码和工作目录

为每个项目设置不同的工作目录标识,并在任务开始和结束时记录目录树。重点关注:

  • 上一个项目是否留下未跟踪文件;
  • DerivedData 是否被多个项目复用;
  • Git 凭证和 SSH known hosts 是否跨项目保留;
  • 构建脚本是否把输出写入固定绝对路径;
  • 项目 A 的环境变量是否被项目 B 继承。

如果项目使用固定的 ~/Library/Developer/Xcode/DerivedData,不要简单地认为它只是性能缓存。缓存可能包含项目路径、编译中间文件、模块信息和生成内容。不同信任域项目应使用独立用户、独立节点,或至少使用经过验证的清理机制。

检查后台进程和模拟器资源

macOS 构建任务可能启动模拟器、辅助脚本、JavaScript 服务或本地数据库。任务结束后,检查进程列表、监听端口和模拟器状态。

共享池中最常见的隐性问题不是“项目 B 直接读取项目 A”,而是项目 A 遗留进程继续持有文件、端口或临时令牌。这样会带来构建结果不稳定、测试互相干扰和内部服务访问范围扩大等问题。

检查缓存和制品边界

缓存的目标是复用依赖,不是复用身份。项目级缓存 key 至少应包含项目标识和依赖锁定文件的变化条件,发布产物则应使用 artifacts 或明确的制品仓库路径。

GitLab 文档指出,缓存通常用于依赖,artifacts 用于在流水线阶段之间传递构建结果。两者混用会导致项目间出现难以追踪的内容污染。缓存与制品的官方区分应写入你的平台规范,而不是留给每个项目自行解释。

什么时候停止共享

出现以下任意情况时,停止把两个项目放在同一个共享 Shell 用户下:

  • 两个项目属于不同业务部门或不同供应商;
  • 一个项目可以访问内部生产网络,另一个项目不能;
  • 任一项目允许外部贡献者提交可修改 CI 脚本的合并请求;
  • 项目需要使用不同的生产证书或不同的 Apple 团队身份;
  • 清理测试无法稳定通过;
  • 构建任务需要长期后台服务或特殊系统权限。

这时的回退路径不是继续增加标签,而是改用独立 Runner 账号,必要时直接拆分为独立 Mac 节点。

第四阶段:把 Keychain 和正式签名移出共享池

iOS 正式签名是共享 Mac Runner 设计的分水岭。普通编译可以共享,生产签名不应只依赖一个 macos-release 标签来保护。

Apple 的 Keychain Services 用于保存密码、密钥和证书等敏感数据;Keychain ACL 可以定义哪些操作允许哪些受信任应用执行。Apple Keychain Services 文档Access Control Lists 文档都说明了访问控制的作用边界:它能控制 Keychain 项目的访问,但不会把整个 macOS Shell 任务变成安全沙箱。

你至少要把下面几类资产分开定义:

  • 生产证书与私钥;
  • 测试证书与测试 Provisioning Profile;
  • App Store Connect API Key 或其他发布凭证;
  • 普通依赖下载令牌;
  • 内部 Git、制品库和云服务访问令牌;
  • 构建日志、归档文件与上传记录。

正式签名节点建议采用以下约束:

  1. 只接受 Protected 分支或 Protected 标签。
  2. 只绑定专用 project runner,或绑定严格受控的 group runner。
  3. 使用独立 macOS 本地账号,不与普通构建账号共用 Home 目录。
  4. 只安装发布所需的 Xcode、证书和工具。
  5. 禁止普通项目访问该 Runner。
  6. 记录每次签名、归档和上传的流水线编号。
  7. 在证书轮换后重新验证,不能只验证首次部署。

Apple 允许通过 kSecAttrAccessibleSecAccessControl 约束 Keychain 项目的访问条件,并提供“仅本设备”等更严格的可访问性选项。Keychain 项目访问限制文档可作为签名节点设计依据。

但要注意一个容易被忽略的边界:如果任意项目脚本已经能够在同一个 macOS 用户上下文中执行命令,Keychain 的应用访问控制并不能自动阻止所有主机级攻击路径。正式签名应使用最小权限、专用账号和专用 Mac 叠加保护,而不是把全部希望寄托在 Keychain 弹窗或 ACL 上。

第五阶段:用闭环验收决定共享、拆分或扩容

Runner 显示在线,只能证明它能与 GitLab 通信。上线前要完成一条完整闭环:

代码拉取
  ↓
依赖准备
  ↓
Xcode 构建与测试
  ↓
制品归档
  ↓
正式或测试签名
  ↓
制品上传
  ↓
清理工作区与缓存
  ↓
节点重启后恢复 Runner

验收记录不要只保存流水线成功截图,还应保留:

  • Runner 范围、标签和 Protected 状态;
  • 实际执行任务的 macOS 本地账号;
  • 工作目录、缓存目录和 DerivedData 路径;
  • 跨项目读取测试结果;
  • 清理前后的目录与进程记录;
  • 未授权项目匹配发布 Runner 的拒绝记录;
  • 最小签名测试与凭证轮换结果;
  • 节点重启后重新接收任务的结果。

完成验证后,按下面的条件作出部署决策:

  • 若所有项目属于同一可信域,普通构建可以稳定清理,且不接触生产私钥:继续使用共享构建池。
  • 若只有少数项目需要特殊依赖、较高并发或独立内部网络访问:保留共享池,同时为这些项目增加专用 Runner。
  • 若任务包含正式签名、跨部门代码或非可信合并请求:回退到独立 Mac Runner,不再依赖标签实现隔离。
  • 若峰值构建造成排队,但安全边界仍一致:优先增加共享构建节点或使用弹性远程 Mac,而不是放宽项目准入。
  • 若一个节点同时承载普通构建、非可信分支和生产签名:立即拆分为共享构建池与专用发布池,并先做小范围试点。

如果你正在规划新的节点,可以先阅读 远程 Mac 租赁方案,把共享构建节点和专用发布节点分别纳入 PoC,而不是一次性采购大量固定硬件。对于企业预算评估,还可以结合 VMSPIN 的方案与价格页面核对按需扩容、租期和节点交付条件。

独立 FAQ:上线前先回答这几个问题

多项目共享时,Runner 的作用范围怎样划分?

如果多个项目属于同一业务组、依赖相近并处于同一信任域,优先选择受控 group runner,便于统一标签、升级和审计。project runner 适合生产签名、特殊 Xcode 工具链或需要单项目独占的节点。instance runner 适用范围最大,不应作为敏感 Mac 构建资源的默认入口。

macOS 上的 Shell executor 怎样隔离不同项目?

先用 group runner 限定项目范围,再用标签和 Protected Runner 限定任务类型;随后分别拆分工作目录、缓存、临时文件和用户级配置,并在每次任务结束后清理。目录拆分主要降低误用和残留风险,同一个 Shell 服务账号仍可能读取主机上其他目录,因此不能替代独立账号或独立 Mac。

生产签名任务应放在哪类 Runner 上?

只要流水线会接触生产证书私钥、正式 Provisioning Profile 或发布凭证,就应优先使用仅接受 Protected 分支和 Protected 标签的专用 GitLab Runner。普通测试签名可以留在共享池,但归档、签名和上传应路由到专用 Mac。这样做不是因为标签失效,而是因为 Shell executor 本身缺少完整主机级隔离。

共享 Mac Runner 怎样防止 Keychain 和缓存泄露?

不要把生产证书私钥、正式 Provisioning Profile 和 App Store Connect 凭证放入普通共享池。缓存使用项目和依赖锁定文件生成独立 key,源码、DerivedData、制品和临时文件分开管理,并在任务结束后清理。Keychain ACL 能限制受信任应用访问,但无法抵消同一 Runner 用户执行任意脚本带来的主机级风险。

非可信合并请求可以进入共享构建池吗?

如果合并请求可以修改 CI 脚本,或其代码来源无法纳入企业信任边界,就不应进入能够接触内部依赖、长期凭证或其他项目资产的共享 Shell Runner。你可以把它路由到不含敏感凭证的独立节点,并通过清理、网络限制和单独账号降低风险;生产发布节点必须完全拒绝这类任务。

最终判定:共享池解决容量,专用池解决信任边界

现场条件 推荐方案 主要原因
同一团队、可信仓库、普通构建测试 受控共享 Mac Runner 降低闲置节点,统一维护工具链
项目权限接近但需要独立依赖环境 独立 Runner 账号或专用节点 减少 Home、缓存和进程互相影响
正式签名、生产上传、私钥和发布凭证 专用 Mac Runner 避免共享 Shell 用户接触生产身份
非可信合并请求或跨部门项目 独立节点或更强隔离方案 标签与目录拆分不足以提供主机级隔离
构建峰值导致排队,但信任域一致 共享基础容量加弹性远程 Mac 先扩容,再决定是否长期采购

如果你现在只有一台 Mac,同时承载普通构建、非可信分支和生产签名,它的问题不是“标签写得不够细”,而是信任边界没有落到主机层。继续使用单台 Mac,最容易出现的真实缺点是:缓存残留难以证明已清理、签名凭证暴露面过大、项目间资源冲突难以追溯,以及峰值排队时无法独立扩容。

更稳妥的做法是先用 VMSPIN 申请隔离远程 Mac 试点,把一台节点验证为共享构建池,另一台节点验证为专用发布池,再根据项目数量、签名任务占用和清理证据决定是否扩大规模。这样你不必立刻批量采购 Mac,也不会把生产签名继续押在一台共享 Shell Runner 上。