CI 任务经常需要挂载 DMG:检查安装包布局、读取测试样本、验证签名后的交付目录,或比较两个磁盘映像的内容。单次手工执行通常没有问题,真正的故障出现在任务并发、脚本异常退出或上一次作业留下挂载卷时。此时固定读取 /Volumes/Product 的脚本,可能悄悄读到另一个任务的内容,而不是立即报错。
默认挂载为何会在并发时失控
hdiutil attach App.dmg 会根据映像内的卷标选择挂载名称。若同名卷已经存在,macOS 可能创建带编号的新路径。脚本仍访问原来的固定目录时,测试结果就失去可信度。
另一个常见问题是只在成功路径执行 detach。只要校验命令失败、任务被终止或后续步骤提前返回,清理命令便不会运行。长期复用的云端 Mac 节点会逐渐积累残留卷,并出现卷标冲突、文件被占用或工作目录判断错误。
挂载点应被视为任务自己的临时资源,而不是机器上的公共路径。创建、使用、卸载和诊断都应由同一个任务负责。
开始改造前,先记录当前状态:
/usr/bin/hdiutil info
/bin/df -h
/bin/ls -la /Volumes
hdiutil info 用于确认映像、设备与挂载路径的对应关系;df 只能说明文件系统占用,不能替代映像关系检查。
为每个任务分配唯一挂载点
挂载目录应包含稳定且唯一的任务标识。不要只使用仓库名,因为同一仓库可能同时运行多个分支或重试任务。标识还要过滤斜杠、空格等路径字符。
下面的脚本先验证映像,再以只读方式挂载,并确保正常结束、命令失败或收到终止信号时都尝试卸载:
#!/bin/bash
set -euo pipefail
IMAGE="${1:?usage: mount-image.sh path/to/file.dmg}"
RAW_JOB_ID="${CI_JOB_ID:-local-$$}"
JOB_ID="${RAW_JOB_ID//[^a-zA-Z0-9._-]/_}"
MOUNT="${TMPDIR%/}/sdkmac-image-${JOB_ID}"
ATTACHED=0
cleanup() {
local status=$?
trap - EXIT INT TERM
if (( ATTACHED == 1 )); then
if ! /usr/bin/hdiutil detach "$MOUNT"; then
printf 'unable to detach %s\n' "$MOUNT" >&2
fi
fi
/bin/rmdir "$MOUNT" 2>/dev/null || true
exit "$status"
}
trap cleanup EXIT INT TERM
/usr/bin/hdiutil verify "$IMAGE"
/bin/mkdir -p "$MOUNT"
/usr/bin/hdiutil attach \
-nobrowse \
-readonly \
-mountpoint "$MOUNT" \
"$IMAGE"
ATTACHED=1
test -r "$MOUNT"
/usr/bin/find "$MOUNT" -maxdepth 2 -type f -print
-nobrowse 避免挂载卷进入图形界面的常规浏览流程,-readonly 则防止检查步骤意外改写样本。脚本使用 rmdir 而不是 rm -rf:如果卸载失败,前者不会穿过仍然挂载的目录删除内容。
只读不是所有场景的答案
需要测试写入行为时,不要直接把共享基准映像改成可写。先为当前任务复制映像,确认副本位于任务专属临时目录,再挂载副本。这样失败任务不会污染下一次运行的基准输入。
| 使用目的 | 建议模式 | 验收重点 |
|---|---|---|
| 检查安装包或样本 | 只读挂载 | 文件存在、权限和摘要 |
| 验证写入流程 | 任务专属可写副本 | 写入结果与卸载完整性 |
| 比较两份映像 | 两个独立只读挂载点 | 路径不重名、比较顺序固定 |
让卸载失败留下可排查证据
卸载失败通常不是 hdiutil 本身随机出错,而是某个进程仍把当前目录、打开文件或工作路径留在卷内。不要立即使用强制卸载掩盖原因,先查占用者:
/usr/sbin/lsof +D "$MOUNT" 2>/dev/null || true
/usr/bin/hdiutil info
/bin/ps -axo pid,ppid,command
lsof +D 在大型目录上可能较慢,因此只应在失败分支执行。重点检查测试进程、日志收集器、压缩工具,以及通过 cd 进入挂载目录后没有退出的 shell。修复方式通常是等待子进程结束、关闭文件句柄,并在卸载前显式切回任务工作目录。
清理函数还应保留原始退出码。若卸载失败覆盖了真正的测试错误,流水线只会显示清理异常,最初的失败原因反而丢失。更稳妥的做法是同时保存测试状态与清理状态,并把两者分别写入任务摘要。
建立残留卷巡检而非盲目删除
节点开始新任务前,可以检查由本流水线命名的挂载目录,但不能看到名字相似就直接删除。先确认该目录确实是挂载点,再核对是否仍有对应任务运行,以及映像是否属于当前工作空间。
建议把巡检结果分成三类:
- 当前任务拥有的卷:由退出陷阱正常卸载。
- 其他活跃任务的卷:记录后跳过,禁止跨任务清理。
- 找不到活跃任务的候选残留卷:收集
hdiutil info、占用进程和创建上下文,确认后再卸载。
强制卸载只能作为人工确认后的最后手段。自动脚本如果仅凭目录年龄操作,可能中断耗时较长但仍正常工作的任务。目录时间也不等于挂载时间,复制文件或读取元数据都可能改变判断依据。
把映像处理纳入流水线验收
一套可复用的磁盘映像步骤至少应检查以下项目:
- 输入文件存在,并在挂载前完成
hdiutil verify。 - 每个任务使用独立目录,不依赖映像卷标生成路径。
- 默认只读,需要写入时创建任务专属副本。
- 所有退出路径都安装清理陷阱,子进程也能收到终止信号。
- 卸载前离开挂载目录,并等待读取该卷的进程结束。
- 卸载失败时保存设备、路径与进程证据,不执行递归删除。
- 任务结束后确认挂载点消失,且没有误清理其他并发任务。
在 SDKMac 的独享物理 Mac 节点上,这套方法同样适用于交互式排查与无人值守流水线。关键不在于增加更多清理命令,而在于为每个挂载卷建立明确的所有者、生命周期和失败证据。做到这一点后,DMG 检查才不会因为机器复用或并发执行而产生难以复现的结果。
常见问题
为什么不能让 DMG 使用默认的 /Volumes 挂载名称?
并发任务可能挂载同名卷,系统会追加编号,脚本若继续读取固定路径就可能访问错误内容。应为每个任务显式创建唯一挂载目录。
hdiutil detach 失败时可以直接删除挂载目录吗?
不可以。应先确认占用进程并重试正常卸载;目录仍处于挂载状态时执行递归删除,可能删除映像内可写数据或产生更难恢复的现场。
CI 任务应该默认以什么方式挂载测试映像?
只读取安装包、样本或基准文件时应使用只读挂载。只有测试明确需要修改映像内容时,才为单个任务创建独立的可写副本。
为持续构建选择一台云端 Mac
比较 SDKMac M4 与 SDKMac M4 Pro 的配置、区域和四种计费周期,再创建订单。