工程文章

云端 Mac CI Shell 环境漂移排查与治理

云端 Mac CI Shell 环境漂移排查与治理

远程终端里执行构建一切正常,换成 CI 任务后却出现 command not found、工作目录错误,甚至同一脚本手动运行成功、由 launchd 启动就失败。这类问题通常不是工具损坏,而是三个入口拿到的环境不同:交互式 Shell 读取了用户配置,非交互 Shell 只读取部分配置,launchd 则从一个更精简的环境启动。修复重点不是继续往配置文件里追加 PATH,而是让所有自动化任务经过同一个明确入口。

先保存三种入口的环境证据

不要先改配置。分别在远程终端、CI 任务和 launchd 中采集相同信息,确认差异究竟发生在 PATH、当前目录还是工具选择上。

#!/bin/zsh
set -eu

printf 'user=%s
' "$(id -un)"
printf 'uid=%s
' "$(id -u)"
printf 'shell=%s
' "${SHELL:-unset}"
printf 'home=%s
' "${HOME:-unset}"
printf 'pwd=%s
' "$PWD"
printf 'path=%s
' "${PATH:-unset}"

for tool in zsh git xcodebuild ruby python3; do
  printf '%s=' "$tool"
  command -v "$tool" || printf 'missing
'
done

/usr/bin/xcode-select -p 2>/dev/null || true
/usr/bin/sw_vers

把它保存为 scripts/inspect-env.sh,通过三个入口执行,并分别保留输出。不要使用完整的 env 直接采集生产任务,因为其中可能包含令牌或临时凭据。

如果两次执行解析出的工具绝对路径不同,即使版本号暂时相同,也应视为环境漂移。后续升级可能让两条路径得到不同结果。

用最小环境复现

可以用 env -i 模拟一个不读取用户习惯配置的任务:

/usr/bin/env -i \
  HOME="$HOME" \
  USER="$USER" \
  PATH="/usr/bin:/bin:/usr/sbin:/sbin" \
  /bin/zsh ./scripts/inspect-env.sh

若问题在这里稳定复现,就不必反复重装工具。应转向检查入口脚本和 PATH 定义。

建立唯一的任务入口脚本

自动化任务不应依赖 .zshrc。该文件面向交互操作,常含提示符、别名、终端检测和只在登录会话成立的逻辑。为仓库建立独立入口,例如 scripts/run-ci.sh

#!/bin/zsh
set -euo pipefail

export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export LANG="en_US.UTF-8"
export LC_ALL="en_US.UTF-8"

repo_root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$repo_root"

required_tools=(git xcodebuild)
for tool in "${required_tools[@]}"; do
  if ! command -v "$tool" >/dev/null 2>&1; then
    printf 'required tool missing: %s
' "$tool" >&2
    exit 127
  fi
done

exec ./scripts/build.sh

PATH 的顺序必须固定,并保留系统目录。若任务依赖额外工具,先在机器上用 command -v 确认真实位置,再加入入口脚本,而不是把整段用户配置复制进来。

入口脚本还负责切换到仓库根目录。这样,构建脚本里的相对路径不会受到 CI 执行器临时目录或 launchd 默认目录影响。

让 launchd 只负责启动

launchd 配置应保持简单:指定解释器、入口脚本、工作目录和日志文件,不在 plist 中拼装复杂命令,也不要在其中保存敏感值。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "file://localhost/System/Library/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.example.ci-runner</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/zsh</string>
    <string>/Users/runner/project/scripts/run-ci.sh</string>
  </array>
  <key>WorkingDirectory</key>
  <string>/Users/runner/project</string>
  <key>StandardOutPath</key>
  <string>/Users/runner/Library/Logs/ci-runner.out.log</string>
  <key>StandardErrorPath</key>
  <string>/Users/runner/Library/Logs/ci-runner.err.log</string>
</dict>
</plist>

加载前先校验语法:

plutil -lint ~/Library/LaunchAgents/com.example.ci-runner.plist
launchctl bootstrap "gui/$(id -u)" \
  ~/Library/LaunchAgents/com.example.ci-runner.plist
launchctl kickstart -k \
  "gui/$(id -u)/com.example.ci-runner"

修改 plist 后,先用 bootout 卸载原任务,再重新 bootstrap。只执行 kickstart 不会让已加载的旧配置自动刷新。

处理工具版本与敏感变量

路径固定不等于版本固定。入口脚本应在任务开始时输出非敏感的工具版本、解析路径和工作目录,便于比较失败任务,但不要输出全部环境变量。

检查项 推荐做法 失败时保留
工具解析 command -v 工具绝对路径
工具版本 调用官方版本参数 版本文本
工作目录 主动 cd 到仓库根 pwd 输出
系统工具链 检查当前开发目录 路径与退出码
敏感变量 只判断是否存在 变量名,不记录值

敏感值应由受控凭据流程在任务运行时提供。脚本可以使用 ${TOKEN:?TOKEN is required} 让缺失立即失败,但不能通过 set -xenv 或错误回显暴露内容。诊断完成后也要检查日志是否意外记录了请求头、命令参数或临时文件路径。

用验收矩阵关闭问题

修复后必须从三个入口执行同一脚本,不能只在当前终端验证。

  1. 在交互式终端直接运行 ./scripts/run-ci.sh
  2. 使用 env -i 仅传入 HOME、USER 和基础 PATH 后运行。
  3. 通过 launchctl kickstart 启动并检查退出状态与两份日志。
  4. 对比三次执行的仓库根目录、工具绝对路径、版本和退出码。
  5. 重启一次用户会话后重新执行,确认任务不依赖临时导出的变量。

常见误区是把修复写进 .zshrc,然后发现手动测试恢复、CI 仍然失败;或者在 plist 中重复维护一套 PATH,几周后与仓库脚本再次分叉。更稳妥的边界是:调度器只启动,入口脚本定义环境,业务脚本执行构建。三层职责分开后,环境差异就能被记录、复现和审查。

在 OwnAMac 的远程 Mac 上落地这套方法时,也应先确认任务实际运行用户和仓库路径,再生成 plist。不要直接复制示例中的用户名。最终验收标准不是“终端里能跑”,而是最小环境与调度环境都能解析到同一组工具并返回一致结果。

常见问题

为什么命令在远程终端可用,CI 中却显示 command not found?

交互式 Shell 可能读取了用户配置文件并扩展 PATH,而非交互任务或 launchd 通常不会读取同一组文件。应在任务入口脚本中显式定义 PATH,并用 command -v 验证工具解析结果。

可以把访问令牌直接写进 launchd 的 plist 吗?

不建议。plist 容易被日志、备份或诊断包带出。敏感值应由受控凭据流程在运行时注入,脚本只检查变量是否存在,不打印变量内容。

如何确认修复不是只对当前终端生效?

分别在交互式终端、env -i 构造的最小环境和 launchd 任务中运行同一验收脚本,对比 PATH、工作目录、工具绝对路径及退出码。三种入口结果一致才算完成。

OwnAMac 云端 Mac

把下一次构建放到独享 Apple Silicon 物理节点

按任务规模选择三档配置与五个节点,计算和存储不与其他租户共享。所有节点全年 365 天正常运行,实际可用状态以控制台实时返回为准。

选择配置并订购