同一条 Xcode 构建任务连续运行数天后,突然开始在写入 DerivedData、更新依赖缓存或删除旧产物时报告 Permission denied。重新检出仓库可能暂时恢复,但下一次并行任务又会复发。此时不要先怀疑 Xcode,也不要直接扩大目录权限;更常见的根因是某个脚本换了执行用户、继承了不同的 umask,或给工作区留下了额外 ACL。
先保存失败现场
权限问题最怕被“清理后重跑”抹掉。先记录任务的执行身份、主目录、当前目录和默认权限掩码,再检查第一个失败路径,不必立即扫描整个磁盘。
printf 'user=%s
' "$(id -un)"
printf 'groups=%s
' "$(id -Gn)"
printf 'home=%s
' "$HOME"
printf 'cwd=%s
' "$PWD"
printf 'umask=%s
' "$(umask)"
target="${FAILED_PATH:?set FAILED_PATH first}"
stat -f 'owner=%Su group=%Sg mode=%Sp path=%N' "$target"
ls -led "$target"
stat 回答“谁拥有它、基础模式是什么”,ls -le 则会显示 ACL。目录模式看起来允许写入,并不代表访问一定成功:父目录缺少执行权限、ACL 中存在限制项,或者文件属于另一个任务用户,都可能阻断操作。
只对首个失败路径及其父目录逐层检查。对整个工作区执行递归改权既会破坏现场,也可能把密钥、缓存和构建产物暴露给无关进程。
区分三类权限漂移
所有者发生变化
最常见的情况是某个步骤通过提权命令安装依赖或复制文件,随后把产物留给了其他用户。可以先列出不属于当前任务用户的内容:
workspace="${WORKSPACE:?set WORKSPACE first}"
find "$workspace" -x ! -user "$(id -un)" -print
若输出集中在单个任务目录,应回查创建它的脚本,而不是用递归 chown 掩盖来源。CI 工作区最好从创建开始就由任务用户拥有,必须执行高权限操作时,也不要把输出写回仓库、缓存或 DerivedData。
ACL 超出预期
Finder 操作、迁移脚本或复制工具可能保留 ACL。ls -le 中基础权限行下方若出现编号条目,就应确认其来源。只有在目录确定为可重建的临时工作区时,才可定点移除 ACL:
job_dir="${JOB_DIR:?set JOB_DIR first}"
chmod -RN "$job_dir"
不要对用户主目录或凭据目录执行该命令。修复后再次运行 ls -led,确认 ACL 已消失且基础模式仍符合要求。
umask 不一致
SSH 交互会话、CI 守护进程和独立脚本不一定读取相同的 Shell 配置。若一个任务以 077 创建缓存,后续任务即使属于同组也可能无法复用。与其依赖启动文件,不如在任务入口明确声明:
umask 022
install -d -m 0755 "$JOB_DIR"
install -d -m 0755 "$JOB_DIR/DerivedData"
install -d -m 0755 "$JOB_DIR/Artifacts"
敏感材料应使用独立目录和更严格的模式,不能为了共享构建缓存而统一放宽。
每个任务使用独立工作区
并行任务共用一个 DerivedData 或归档目录时,一个任务的清理动作可能撞上另一个任务正在写入的文件。建议用仓库标识、提交标识和任务编号组成目录,并将路径显式传给构建命令。
run_id="${CI_RUN_ID:?set CI_RUN_ID first}"
root="$HOME/ci-runs/$run_id"
src="$root/source"
derived="$root/DerivedData"
artifacts="$root/Artifacts"
umask 022
install -d -m 0755 "$src" "$derived" "$artifacts"
xcodebuild \
-workspace App.xcworkspace \
-scheme App \
-configuration Release \
-derivedDataPath "$derived" \
archive \
-archivePath "$artifacts/App.xcarchive"
共享缓存和任务输出应分开:依赖下载缓存可以只读复用,DerivedData、归档和日志则按任务隔离。这样既减少权限互相污染,也能让失败现场在任务结束前保持完整。
| 路径类型 | 建议归属 | 生命周期 | 是否允许并行写入 |
|---|---|---|---|
| 源码检出 | 单个任务 | 单次运行 | 否 |
| DerivedData | 单个任务 | 单次运行 | 否 |
| 归档与日志 | 单个任务 | 验收后清理 | 否 |
| 下载缓存 | 固定任务用户 | 跨任务 | 仅由缓存流程更新 |
在构建前后设置验收门槛
权限检查应成为任务入口的一部分,而不是出错后的人工操作。构建前确认目录可写、所有者正确且没有意外 ACL;构建后检查是否生成了异主文件。下面的检查会在发现漂移时直接终止任务:
test -d "$JOB_DIR"
test -w "$JOB_DIR"
unexpected_owner="$(
find "$JOB_DIR" -x ! -user "$(id -un)" -print -quit
)"
if [ -n "$unexpected_owner" ]; then
printf 'unexpected owner: %s
' "$unexpected_owner" >&2
exit 1
fi
if ls -led "$JOB_DIR" | tail -n +2 | grep -q '^[[:space:]]*[0-9]:'; then
printf 'unexpected ACL on job directory
' >&2
exit 1
fi
若团队需要共享缓存,应单独定义缓存写入者、更新时机和原子替换方式,不要让所有构建任务同时修改同一目录。任务完成后的清理也只应删除本次 run_id 对应的路径,并先校验路径位于预期根目录之下。
把修复落到脚本源头
一次可靠的修复,应能回答四个问题:哪个步骤创建了异常文件,创建时使用哪个用户,继承了什么 umask,为什么写入了共享目录。找到答案后,把目录创建、执行身份和输出位置写进脚本,而不是依赖人工登录后的环境。
在 RunnerVM 的云端 Mac 上执行 CI 时,同样应在任务开头输出最小环境快照,并在控制台确认当前可选配置。机器重启或任务迁移后,只要入口脚本能够重新建立目录与权限基线,构建就不需要依赖上一次运行遗留的状态。最终目标不是消除所有权限限制,而是让每个文件的创建者、读写范围和清理责任都可预测。
常见问题
遇到 Permission denied 时可以直接对整个工作区执行 chmod 777 吗?
不建议。它会掩盖所有者、ACL 和任务隔离问题,还会扩大其他进程修改构建文件的范围。应先用 stat 与 ls -le 检查具体路径,再只修复已确认的任务目录。
为什么同一脚本在 SSH 中成功,在 CI 任务中却失败?
两者可能使用不同的执行用户、HOME、umask、PATH 和启动环境。应在任务开头记录 id、HOME、umask 与工作目录,并在脚本内显式设置权限基线。
让下一次构建运行在独享物理 Mac 上
按任务周期选择云端 Mac 节点,在控制台管理订单、连接信息与支持工单。