云端 Mac CI 脚本权限与换行符治理

CI/CD 实践 ·约 7 分钟阅读

云端 Mac CI 脚本权限与换行符治理

构建脚本在开发者电脑上运行正常,推到云端 Mac 后却报 Permission denied、bad interpreter,或者在同一行末尾出现难以解释的 $'\r'。这类故障通常与业务代码无关,而是 Git 文件模式、换行符和实际解释器没有形成统一契约。先保存失败命令、提交号与脚本路径,再按下面的顺序检查,通常比反复清缓存更快。

先确认失败发生在哪一层

不要看到“脚本失败”就立即加 chmod +x。Shell 脚本至少有四层状态:工作区中的权限、Git 索引记录的模式、文件实际字节,以及 runner 调用它的方式。

script="ci/build.sh"

ls -l "$script"
git ls-files -s -- "$script"
file "$script"
head -n 1 "$script" | cat -vet

git ls-files -s 输出首列为 100755,表示 Git 记录了可执行位;100644 表示普通文件。cat -vet 若在行尾显示 ^M,说明文件混入 CRLF。首行还要确认是有效 shebang,例如 #!/bin/zsh 或 #!/usr/bin/env bash。

调用方式也必须记录。bash ci/build.sh 不要求文件具备可执行位,而 ./ci/build.sh 会检查权限并读取 shebang。Xcode 构建阶段、任务调度脚本和本地终端若使用了不同方式,就可能产生“只在 CI 失败”的错觉。

修复目标不是让某一次任务碰巧通过,而是让仓库明确记录脚本应由谁解释、是否允许直接执行,以及必须采用哪种换行。

用 Git 索引修正可执行位

直接执行的入口脚本应写入可执行位;被 source 或显式交给解释器的片段则通常保持 100644。先列一份入口清单,避免把所有 .sh 都改成可执行文件。

git update-index --chmod=+x ci/build.sh
git update-index --chmod=+x ci/test.sh
git diff --summary
git ls-files -s -- ci/build.sh ci/test.sh

如果 git diff --summary 没有显示模式变化,检查本机是否关闭了权限跟踪:

git config --get core.fileMode
git config core.fileMode true

core.fileMode 只影响当前工作区如何发现变化,真正随提交传播的是 Git 索引。不要把 runner 启动时执行 chmod -R +x . 当长期方案,它会掩盖仓库错误,还可能让配置、密钥材料或普通数据文件获得多余权限。

可以在 ci/entrypoints.txt 中逐行登记允许直接执行的脚本。验收时只检查这份清单,新增入口必须同时更新登记文件,代码审查也能看出权限边界为何变化。

固定 LF 并谨慎规范化历史文件

仓库根目录的 .gitattributes 应成为换行规则的唯一来源。对于常见的构建文件,可采用以下基线:

*.sh       text eol=lf
*.command  text eol=lf
*.zsh      text eol=lf
*.xcconfig text eol=lf
*.pbxproj  text eol=lf
*.png      binary
*.zip      binary

规则加入后不会自动改写已提交文件。应在干净分支中执行:

git add --renormalize .
git status --short
git diff --check
git diff --cached --stat

先审查差异,再单独提交规范化结果,不要把它与功能修改混在一起。若一次出现大量工程文件变化,应抽查内容确实只有换行调整。压缩包、代码生成器和外部脚本仍可能重新带入 CRLF,因此 .gitattributes 不是扫描门禁的替代品。

检查结果 常见原因 正确动作
100644 且直接执行 索引未记录权限 用 git update-index --chmod=+x 修正
行尾出现 ^M 文件采用 CRLF 规范化为 LF 并提交
bad interpreter shebang 含 CR 或路径无效 修正首行并确认解释器存在
本地成功、CI 失败 调用方式或 Shell 不同 打印调用命令并显式选择 Shell

把解释器差异变成显式契约

macOS 上的交互式默认 Shell 与 CI 实际执行 Shell 不一定相同。脚本若使用 Bash 数组、[[ ]] 或进程替换,就不应由 sh 隐式解释;依赖 zsh 语法时也要直接声明。

#!/usr/bin/env bash
set -euo pipefail

repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"

不要依赖交互式配置文件提供 PATH、别名或环境变量。CI 入口应自行设置最小 PATH,并用 command -v xcodebuild、command -v ruby 等方式验证工具是否存在。需要的环境变量应由任务配置注入,脚本只检查名称,不回显敏感值。

还要避免用 sh script.sh 调用带 Bash shebang 的文件,因为显式的 sh 会绕过 shebang。DebugMac 节点上的任务也应以仓库声明为准,而不是根据某次会话里恰好可用的 Shell 推断执行环境。

建立提交前与 CI 双重门禁

本地钩子能缩短反馈时间,但不能作为唯一防线,因为钩子不会随普通克隆自动启用。CI 应在依赖安装和 Xcode 构建之前运行轻量检查。

#!/usr/bin/env bash
set -euo pipefail

while IFS= read -r script; do
  [[ -z "$script" ]] && continue
  mode="$(git ls-files -s -- "$script" | awk '{print $1}')"
  [[ "$mode" == "100755" ]] || {
    printf 'entrypoint is not executable: %s\n' "$script" >&2
    exit 1
  }
done < ci/entrypoints.txt

if git grep -Il $'\r' -- '*.sh' '*.zsh' '*.command' > /tmp/crlf-files; then
  printf 'CRLF detected:\n' >&2
  cat /tmp/crlf-files >&2
  exit 1
fi

门禁还应检查登记文件中的路径确实存在,并拒绝未提交的规范化差异。最终验收可按四项执行:全新检出、直接运行入口脚本、确认 git status --porcelain 为空、在非交互式会话完成一次最小构建。这样权限与换行问题会在昂贵的依赖下载和编译开始前暴露,也不会靠修改 runner 现场来维持。

常见问题

脚本已经使用 bash 执行,为什么还要管理可执行位?

通过 bash script.sh 调用时不依赖可执行位,但直接执行、构建阶段调用和部分任务调度器会检查权限。应明确哪些文件是入口脚本,并把 100755 记录在 Git 索引中。

修改 .gitattributes 后是否会自动修复仓库中的 CRLF?

不会。需要执行 git add --renormalize .,检查差异后提交规范化结果;同时在 CI 中继续扫描,避免生成脚本或外部压缩包再次带入 CRLF。

Debug M4

在独享 Apple Silicon 物理节点上执行下一次构建

选择租期与节点区域,核对 Mac Mini M4、16GB RAM、256GB SSD 和应付 USD 金额后提交订单。

立即租用云端 Mac