标签路由明明正确,第二个项目却读到了前一个项目残留的源码、缓存或签名文件。
最快的解法是:把普通编译测试放进受控共享构建池,把生产签名、不同信任域项目和非可信合并请求放进专用 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,并让发布作业只由受保护分支或受保护标签触发。这样可以减少错误路由,但不能防止一个已经被允许执行的脚本读取同一主机上的其他文件。
验证时至少保留四类证据:
- Runner 的范围截图,证明它属于正确的 group 或 project。
- Runner 标签与
.gitlab-ci.yml标签的匹配记录。 - 允许项目清单,证明没有意外继承到其他业务组。
- 一次拒绝测试,证明普通项目无法匹配
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、制品库和云服务访问令牌;
- 构建日志、归档文件与上传记录。
正式签名节点建议采用以下约束:
- 只接受 Protected 分支或 Protected 标签。
- 只绑定专用 project runner,或绑定严格受控的 group runner。
- 使用独立 macOS 本地账号,不与普通构建账号共用 Home 目录。
- 只安装发布所需的 Xcode、证书和工具。
- 禁止普通项目访问该 Runner。
- 记录每次签名、归档和上传的流水线编号。
- 在证书轮换后重新验证,不能只验证首次部署。
Apple 允许通过 kSecAttrAccessible 和 SecAccessControl 约束 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 上。