VMArm 技术记录

云端 Mac CI 外部 PR 隔离:无密钥构建与受信发布

云端 Mac CI 外部 PR 隔离:无密钥构建与受信发布

团队允许外部贡献者提交 PR 后,云端 Mac CI 面对的就不再只是“代码能不能编译”。构建脚本本身也是待审输入:它可以读取环境变量、搜索用户目录、修改共享缓存,甚至把后续任务会使用的工具替换掉。安全目标应当明确:外部 PR 可以验证编译和测试,但拿不到发布凭据,也不能把生成物直接送入正式发布。

先划分两条信任路径

把任务分成“无密钥验证”和“受信发布”两条路径。前者接收外部 PR,只执行依赖解析、编译、静态检查和不需要真实凭据的测试;后者只接受已经审核并合并的固定提交,负责签名、归档与交付。

在 VMArm 的独享物理节点上,可以为两条路径建立不同的 macOS 系统用户。验证用户不加入管理员组,不保存发布证书,不读取发布用户的主目录。发布用户也不复用验证用户可写的脚本、缓存或工具目录。

系统用户隔离不是虚拟机边界。它能显著减少普通脚本误读凭据和污染文件的机会,但不能替代系统更新、最小权限与对不受信代码的人工审核。

推荐的边界如下:

项目 无密钥验证 受信发布
代码来源 外部 PR 固定提交 已审核分支固定提交
代码签名 禁用 按发布流程启用
工作区 每次新建 独立目录
缓存 可丢弃、不可跨信任域 仅受信任务共享
构建产物 只用于检查 重新构建后交付
凭据 不注入 按需短时注入

建立最小权限工作区

不要让 runner 长期在用户主目录根部构建。为验证任务创建专用根目录,并确保其他本地用户不能读取。任务开始时使用临时目录,结束时无论成功或失败都清理。

set -euo pipefail

umask 077
RUN_ROOT="$HOME/ci-untrusted"
mkdir -p "$RUN_ROOT"
chmod 700 "$RUN_ROOT"

WORK_DIR="$(mktemp -d "$RUN_ROOT/job.XXXXXX")"
cleanup() {
  chmod -R u+rwX "$WORK_DIR" 2>/dev/null || true
  rm -rf "$WORK_DIR"
}
trap cleanup EXIT INT TERM

cd "$WORK_DIR"
printf 'user=%s\nworkspace=%s\n' "$(id -un)" "$WORK_DIR"

rm -rf 前必须保证路径来自当前任务创建的临时目录,不能直接拼接分支名。分支名可能包含斜线、空格或刻意构造的字符,适合当显示文本,不适合未经处理地进入文件路径。

源码检出后记录提交哈希,并在日志中输出它。后续受信路径必须按这个不可变标识重新获取代码,不能只依赖可能继续移动的分支名。

在执行构建前证明环境里没有密钥

“没有主动使用密钥”不等于“任务读不到密钥”。runner 服务可能继承启动环境,Shell 初始化文件也可能附加令牌。应在调用项目脚本之前检查变量名,并只把必要变量传给构建命令。

blocked='TOKEN|SECRET|PASSWORD|PRIVATE|SIGNING|AUTH|CREDENTIAL'
if env | cut -d= -f1 | grep -Eiq "$blocked"; then
  printf '%s\n' "blocked environment variable detected" >&2
  exit 70
fi

env -i \
  HOME="$HOME" \
  PATH="/usr/bin:/bin:/usr/sbin:/sbin" \
  TMPDIR="$WORK_DIR/tmp" \
  xcodebuild \
    -workspace App.xcworkspace \
    -scheme App \
    -configuration Debug \
    -derivedDataPath "$WORK_DIR/DerivedData" \
    CODE_SIGNING_ALLOWED=NO \
    build

实际工程如果依赖包管理器或自定义工具,应把经过审核的工具目录显式加入 PATH,而不是继承完整登录环境。变量名检查只是门槛,敏感值还可能存在于配置文件、钥匙串、代理设置和 Git 凭据助手中,因此验证用户应从一开始就保持空白,而不是每次构建前临时删除几个变量。

处理依赖与缓存

外部代码可以向缓存写入内容,受信任务随后读取同一缓存就可能受到污染。最稳妥的做法是按信任域拆分缓存;对外部 PR,再按仓库或任务编号细分。不要让验证路径写入发布用户的 SwiftPM、编译模块或自定义二进制工具缓存。

缓存恢复后应检查所有者和权限,拒绝符号链接指向工作区之外。可执行工具最好来自只读基线目录;项目在构建中生成的脚本,只能在当前临时工作区内运行。

不让产物跨越信任边界

外部 PR 编译成功只能证明该提交在验证条件下通过,不能证明生成的归档值得发布。验证任务可能替换构建阶段、修改输出内容,或者把额外文件塞进归档。因此,无密钥路径产生的应用包、归档和导出目录都不应进入受信发布任务。

受信路径应执行以下步骤:

  1. 接收审核系统确认的提交哈希。
  2. 在发布用户的全新工作区重新检出该提交。
  3. 验证当前提交与批准值完全一致。
  4. 从受信缓存或空缓存重新解析依赖。
  5. 运行测试,并从源码重新归档。
  6. 仅在需要签名的短时间内提供凭据。
  7. 记录提交哈希、依赖锁文件摘要和最终产物摘要。

如果必须从验证路径传递测试报告,只传递不可执行的数据,并在受信侧按严格格式解析。不要传递 Shell 脚本、插件、编译缓存或可直接进入应用包的文件。

把失败清理和审计纳入验收

隔离方案最容易在失败分支失效。构建被取消、进程超时或机器重启后,临时目录、后台进程和日志仍可能残留。runner 应在每个任务开始前检查上次遗留,并在结束阶段终止属于该任务的子进程。

上线前可以按下面的清单验收:

最后做一次主动演练:提交一个测试 PR,让构建脚本尝试读取常见敏感变量、访问发布目录并向共享缓存写文件。正确结果不是脚本“没有发现东西”,而是这些路径从权限和流程设计上就不可达。完成这一步后,外部贡献验证才真正与正式发布解耦。

常见问题

只给外部 PR 关闭代码签名就足够安全吗?

不够。还应移除密钥环境变量、限制工作区权限、隔离缓存与日志,并禁止该任务访问受信发布账户。关闭签名只能减少一类凭据暴露。

外部 PR 验证通过后可以直接发布它生成的归档吗?

不建议。应在代码获准合并后,由受信流水线重新检出固定提交并重新构建,避免把不受信环境生成或替换过的产物带入发布环节。

独享物理 Mac 节点

为下一次 Xcode 构建选择持续在线的云端 Mac

核对芯片、内存、存储、计费周期和节点区域后进入配置流程。实际可用状态以控制台实时返回为准。

选择云端 Mac 配置