一台云端 Mac 同时承担拉取代码、编译、测试和发布时,最容易被忽略的边界不是目录,而是进程环境。流水线把令牌写入环境变量后,xcodebuild、脚本、测试宿主和后台子进程都会默认继承它。即使主任务已经结束,一个未退出的辅助进程仍可能保留完整变量。正确做法不是禁止环境变量,而是把密钥的可见时间和可见进程压缩到最小。
先画出变量传播路径
从 runner 启动的每个进程都应归入构建、测试、发布或辅助工具四类。构建和测试通常不需要发布令牌,辅助工具也不应继承它。先记录变量名、注入阶段、使用命令和预期销毁点,不记录真实值。
| 检查对象 | 常见风险 | 验收方法 |
|---|---|---|
| runner 父进程 | 启动时获得全部密钥 | 仅检查变量名是否存在 |
| 构建子进程 | 无条件继承父环境 | 使用随机标记执行继承测试 |
| launchd 域 | setenv 后跨任务保留 |
任务前后分别检查并清除 |
| 临时文件 | 权限过宽或异常退出后残留 | 权限设为 600,并用 trap 删除 |
| 日志 | Shell 跟踪打印命令参数 | 密钥阶段关闭 set -x |
审计脚本只需要回答“某个标记是否可见”,不要把环境快照保存为构建附件。完整的
env、export或ps eww输出本身就可能成为新的泄露源。
用随机标记验证继承
不要拿真实令牌做实验。生成一次性标记,启动短生命周期子进程,再静默判断它能否看到标记。下面的检查不会把标记内容打印到日志,但退出码能告诉流水线继承是否发生。
set -euo pipefail
marker="$(/usr/bin/uuidgen)"
export CI_SECRET_PROBE="$marker"
/bin/sleep 20 &
child_pid=$!
if /bin/ps eww -p "$child_pid" -o command= | /usr/bin/grep -q "$marker"; then
result=1
else
result=0
fi
kill "$child_pid" 2>/dev/null || true
wait "$child_pid" 2>/dev/null || true
unset CI_SECRET_PROBE
test "$result" -eq 1
这个测试证明默认继承确实存在,不代表应该在生产日志中反复扫描所有进程。正式验收可使用固定变量名配合随机值,只记录“通过”或“失败”。
检查 launchd 残留
不应使用 launchctl setenv 注入任务密钥,因为它的生命周期可能超过单次 Shell。若历史脚本使用过这种方式,先在不输出值的前提下检查状态,随后执行:
launchctl unsetenv CI_RELEASE_TOKEN
test -z "$(launchctl getenv CI_RELEASE_TOKEN)"
检查应放在任务开始和清理阶段各一次。若 runner 作为独立用户运行,要在该用户对应的 launchd 域内执行,而不是只检查当前远程会话。
为构建建立最小环境
env -i 可以从空环境启动命令,但不能盲目清空。Xcode 工具链通常需要 HOME、PATH、TMPDIR、区域设置和明确的开发者目录。先用版本查询验收,再运行完整构建。
set -euo pipefail
job_tmp="$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/mac-ci.XXXXXX")"
trap '/bin/rm -rf "$job_tmp"' EXIT HUP INT TERM
/usr/bin/env -i \
HOME="$HOME" \
PATH="/usr/bin:/bin:/usr/sbin:/sbin" \
TMPDIR="$job_tmp/" \
LANG="en_US.UTF-8" \
DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer" \
/usr/bin/xcrun xcodebuild -version
真实项目若依赖 Ruby、Node.js 或包管理器,应把它们的可执行目录逐项加入 PATH,不要恢复整个登录 Shell 环境。每增加一个变量,都要写明哪个命令需要它。
把构建与发布拆成两个权限域
编译阶段只产出归档和校验文件,不接触发布凭据。发布阶段读取已经验收的产物,并只向上传命令注入所需变量。不要在任务开头 export CI_RELEASE_TOKEN,因为后续所有脚本都会继承它。
set +x
CI_RELEASE_TOKEN="$release_token" \
/usr/bin/env \
RELEASE_ARTIFACT="$artifact_path" \
./ci/publish.sh
unset release_token
set -x
更稳妥的做法是让 publish.sh 只启动一个上传进程,等待它退出后立即结束。若工具要求凭据文件,使用任务专属临时目录,创建前设置 umask 077,写入后确认权限为 600。不要把文件放进仓库、用户桌面或长期缓存目录。
限制失败处理程序
失败钩子经常收集环境、进程和工作区信息。它不应执行无过滤的 env,也不应把完整命令行打包。允许收集退出码、工具版本、产物路径、磁盘余量和经过白名单筛选的变量名。需要检查密钥时,只比较哈希或随机探针是否出现,不保存原值。
把清理结果设为任务门禁
任务成功不等于清理成功。结束阶段至少检查:发布变量已 unset、launchd 中没有对应名称、临时目录不存在、测试与上传子进程已经退出、日志未开启命令跟踪。任何一项失败,都应把当前 runner 标记为需要检查,停止继续接收带密钥的发布任务。
建议为每次任务生成独立的 TMPDIR,并把进程组作为清理单位。只杀主脚本可能留下模拟器辅助程序、上传器或自定义守护进程。清理后再运行一次不含真实值的随机探针,确认新的子进程无法观察到上一阶段的标记。
最终目标不是让流水线完全不使用环境变量,而是建立清晰边界:构建阶段没有发布密钥,发布阶段只有一个必要进程能看到密钥,任务结束后进程、launchd 和磁盘都不再保留它。把这套检查写进 runner 初始化与收尾脚本后,后续项目无需依赖操作者记忆。
常见问题
CI 密钥只放在环境变量里就足够安全吗?
不够。环境变量会默认传给子进程,还可能被诊断命令、调试输出或长驻服务读取。应把构建与发布拆开,只向确实需要凭据的单个命令注入变量。
怎样确认任务结束后没有环境变量残留?
检查 runner 父进程、launchd 域和仍在运行的子进程是否包含变量名,同时确认任务临时目录已删除。检查过程只匹配变量名或随机标记,不应把真实值写入日志。
使用 env -i 会不会导致 xcodebuild 无法运行?
会清空默认环境,因此必须显式传入 HOME、PATH、TMPDIR、LANG 和 DEVELOPER_DIR 等必要变量。先运行版本检查和小型构建,再把白名单应用到完整流水线。
把下一次构建放到云端 Mac mini
选择配置、计费周期与节点区域,使用独享物理机运行开发、构建和自动化任务。