同一份构建脚本在 SSH 终端里运行正常,交给云端 Mac CI 后却报 Permission denied,最常见的误判是“再执行一次 chmod +x 就好”。真正的问题通常跨越了四层:任务入口的 umask、Git 索引保存的可执行位、复制与解压工具的权限语义,以及脚本实际使用的解释器。只修工作区里的一个文件,下次干净检出仍会复发。
先把权限问题分成四类
排查前先记录失败对象和操作类型。无法执行脚本、无法写入目录、密钥权限过宽、归档解压后模式变化,处理方向完全不同。
用 stat 同时查看八进制模式、属主与文件类型:
target="${1:?path required}"
stat -f 'mode=%Sp octal=%OLp owner=%Su group=%Sg type=%HT path=%N' "$target"
ls -ldeO@ "$target"
第二条命令还能暴露 ACL、文件旗标和扩展属性。若普通权限看似正确却仍失败,不要立即提权;先确认父目录是否可遍历、文件所在卷是否允许执行,以及进程使用的是哪个账号。
rwx不是唯一判据。路径上的每一级目录都需要相应权限,ACL 也可能覆盖普通模式位。持续使用sudo只会把工作区变成混合属主,令后续任务更难复现。
建立最小现场记录
至少保存 id、umask、当前目录、目标文件的 stat 结果和挂载信息。不要把环境变量全集写进日志,其中可能含有令牌。现场记录应聚焦身份、路径与权限,不收集凭据内容。
在任务入口固定 umask
umask 只影响新建对象,不会回写已有文件。常见基线 022 会让普通文件默认成为 644、目录成为 755;受控的同组协作目录可评估 002,但不能把它当成通用修复。任务入口应显式设置,并通过新建探针验证:
set -euo pipefail
umask 022
probe_dir="$(mktemp -d)"
probe_file="$probe_dir/probe"
: > "$probe_file"
file_mode="$(stat -f '%OLp' "$probe_file")"
dir_mode="$(stat -f '%OLp' "$probe_dir")"
test "$file_mode" = "644"
test "$dir_mode" = "700"
rm -rf "$probe_dir"
mktemp -d 为保护临时内容通常创建 700 目录,因此它不能用来断言普通目录应为 755。如果要测试目录基线,应在已知父目录下执行 mkdir 后单独检查。这个差异也是权限测试经常误报的原因。
让 Git 保存正确的执行意图
Git 主要跟踪文件是否可执行,不完整保存所有 Unix 权限。脚本在某台机器上被手动改成 755,并不代表索引已经记录。检查索引而不是只看工作区:
git ls-files --stage |
awk '$1 == "100755" {print $4}' |
sort
需要提交执行位时使用:
git update-index --chmod=+x scripts/build.sh
git diff --summary
git diff --cached --summary
反过来,配置文件或文档意外带上执行位,也应以 git update-index --chmod=-x 修正。对 .sh 文件还要检查首行。推荐使用环境中确定存在的解释器,例如 #!/bin/zsh 或 #!/usr/bin/env bash,并避免脚本带有 CRLF 行尾。
在合并门禁中核对脚本
可以维护一份明确的可执行脚本清单,再与索引结果比较。不要简单规定“所有 .sh 都必须可执行”,因为被 source 的库文件未必需要执行位。规则应表达用途,而不是依赖扩展名猜测。
控制复制、压缩与解压的权限语义
cp、ditto、rsync 和不同归档格式对模式位、ACL、扩展属性的处理并不相同。构建缓存只应保存可再生成的数据;交付包则应在解压后重新验收权限。
复制工作区时,先决定是否需要保留元数据。若只需要源文件内容,应避免无意继承旧属主、ACL 或扩展属性。若必须保持可执行位,则在临时目录做一次往返测试:
src="scripts/build.sh"
tmp="$(mktemp -d)"
ditto "$src" "$tmp/build.sh"
before="$(stat -f '%OLp' "$src")"
after="$(stat -f '%OLp' "$tmp/build.sh")"
test "$before" = "$after"
rm -rf "$tmp"
归档验收不能只检查文件是否存在。至少验证入口脚本可执行、普通配置不可执行、敏感文件不允许组或其他用户读取。解压目录应为全新目录,避免旧文件模式掩盖归档本身的问题。
把修复变成可执行策略
稳定方案不是在失败后批量执行 chmod -R 777,而是建立少量、可解释的断言。下面的检查会拒绝可被其他用户写入的文件,并确认入口脚本可执行:
set -euo pipefail
entry="scripts/build.sh"
test -f "$entry"
test -x "$entry"
while IFS= read -r -d '' file; do
mode="$(stat -f '%OLp' "$file")"
other_write=$((8#$mode & 2))
if (( other_write != 0 )); then
printf 'world-writable file: %s mode=%s\n' "$file" "$mode" >&2
exit 1
fi
done < <(find . -type f -not -path './.git/*' -print0)
把策略脚本放在仓库中,由本地预检和 CI 共用。失败输出只写路径、期望模式与实际模式,便于定位又不会泄露内容。在 RentMini 的独享物理节点上运行任务时,也应在每个新工作区重新执行这些断言,而不是假设上一轮环境保持不变。
最后做一次干净检出验收:删除临时工作区,重新克隆仓库,不执行人工修复,直接运行权限检查和最小构建。只有这条路径通过,权限修复才真正进入版本控制,而不是留在某次远程会话里。
常见问题
CI 脚本已经写了 chmod +x,为什么仍会出现 Permission denied?
先确认 chmod 是否发生在实际执行文件上,再检查脚本所在卷是否允许执行、首行解释器是否存在,以及后续复制或解压步骤是否重新丢失了可执行位。
云端 Mac CI 应该统一使用什么 umask?
多数单用户构建任务可从 022 开始;需要同组协作写入的工作区可评估 002。关键不是盲目选择数值,而是在任务入口显式设置并验证最终权限。
如何阻止文件权限漂移进入主分支?
在 CI 中同时检查 Git 索引里的可执行位、脚本首行、敏感文件权限和归档解包结果,发现异常立即返回非零状态并阻断合并。
为下一次构建准备独享物理 Mac mini
选择 M4 配置、租期与节点。每个订单对应独享物理设备,实际可用状态以控制台实时返回为准。