云端 Mac CI 的 umask 与文件权限漂移治理

云端 Mac CI 的 umask 与文件权限漂移治理

同一份构建脚本在 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 只会把工作区变成混合属主,令后续任务更难复现。

建立最小现场记录

至少保存 idumask、当前目录、目标文件的 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 的库文件未必需要执行位。规则应表达用途,而不是依赖扩展名猜测。

控制复制、压缩与解压的权限语义

cpdittorsync 和不同归档格式对模式位、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

为下一次构建准备独享物理 Mac mini

选择 M4 配置、租期与节点。每个订单对应独享物理设备,实际可用状态以控制台实时返回为准。

选择配置并租用