Oakmini Engineering Notes

用区分大小写的 APFS 卷排查构建路径问题

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

用区分大小写的 APFS 卷排查构建路径问题

同一份仓库在开发机上正常,在远端构建任务里却报 Module not found,或者打包后少了一份资源。先不要反复清缓存。macOS 常见的默认文件系统不区分文件名大小写,Config.jsonconfig.json 可能被当成同一路径;一旦脚本、依赖或部署目标按大小写严格匹配,隐藏的问题才会出现。最稳妥的排查方式不是改系统盘,而是在云端 Mac 上挂载一个隔离的区分大小写 APFS 卷。

先确认故障是否与路径大小写有关

先从失败日志中找路径,而不是只看最后一行退出码。以下现象值得优先检查:

现象 可能原因 验证动作
本地可导入,构建任务找不到模块 import 与真实文件名不同 git ls-files 核对完整路径
两个资源提交后只剩一个 名称仅大小写不同 在区分大小写卷中重新克隆
清理后首次构建失败 旧产物掩盖错误引用 删除输出目录后完整构建
打包阶段覆盖文件 脚本统一转换了名称 检查复制与重命名步骤

同时执行 diskutil info /,记录当前系统卷的 File System Personality。这一步只用于确认环境,不要直接转换正在运行的系统卷。

路径问题必须在干净工作区复现。把旧缓存复制到测试卷,会再次掩盖原始错误。

创建隔离的 APFS 测试卷

稀疏映像按实际写入增长,适合临时检查。下面创建上限为 80GB 的测试卷;若项目包含大型依赖,应按仓库、依赖和产物峰值预留空间。

mkdir -p "$HOME/apfs-lab"
hdiutil create \
  -size 80g \
  -type SPARSEBUNDLE \
  -fs "Case-sensitive APFS" \
  -volname BuildCase \
  "$HOME/apfs-lab/BuildCase.sparsebundle"

hdiutil attach "$HOME/apfs-lab/BuildCase.sparsebundle"
diskutil info "/Volumes/BuildCase"

检查输出中是否明确显示大小写敏感,并确认挂载点是 /Volumes/BuildCase。不要把访问密钥、签名材料或长期数据写入这个临时卷。

在新卷中重新获取仓库

不要从原工作区直接拖拽复制,因为冲突文件可能已经丢失。应从受控仓库重新克隆,并创建独立的构建目录:

mkdir -p /Volumes/BuildCase/work
cd /Volumes/BuildCase/work
git clone "$REPOSITORY_URL" project
cd project
git status --short

git status 此时应为空。若克隆阶段已经报目标路径存在,通常意味着仓库树中存在折叠后重名的路径。

审计 Git 文件名与脚本引用

下面的脚本只读取 Git 跟踪路径,将其转成 Unicode 规范形式后按大小写折叠分组。它不会修改文件:

from collections import defaultdict
import subprocess
import unicodedata

raw = subprocess.check_output(
    ["git", "ls-files", "-z"],
    text=True
)
groups = defaultdict(list)

for path in raw.split(""):
    if not path:
        continue
    key = unicodedata.normalize("NFC", path).casefold()
    groups[key].append(path)

found = False
for paths in groups.values():
    if len(paths) > 1:
        found = True
        print("COLLISION")
        for path in paths:
            print(f"  {path}")

raise SystemExit(1 if found else 0)

保存为 tools/check_path_case.py 后执行 python3 tools/check_path_case.py。退出码为 1 时,应统一命名并通过 git mv 提交。仅修改字母大小写时,可先改成中间名,再改成目标名,避免原工作区忽略变化:

git mv Sources/config.json Sources/config.tmp
git mv Sources/config.tmp Sources/Config.json

文件名无冲突并不代表引用正确。继续搜索构建脚本、资源清单、项目配置和测试夹具中的旧路径,尤其注意自动生成文件与手写配置之间的差异。

把检查接入可重复构建

测试任务应先验证挂载点,再运行路径审计,最后从空目录构建。这样不会因为测试卷未挂载而把文件写回系统盘。

set -euo pipefail

test -d /Volumes/BuildCase
cd /Volumes/BuildCase/work/project
python3 tools/check_path_case.py

rm -rf .build-output
mkdir .build-output
./scripts/build.sh "$PWD/.build-output"

若项目没有统一构建入口,就先把现有命令封装到 scripts/build.sh,并显式传入输出目录。依赖缓存可以单独挂载,但首次验证应禁用旧缓存;通过后再逐项恢复,以判断是哪一层引入了错误路径。

常见踩坑与收尾检查

排查完成前,逐项确认:

  • 仓库是在测试卷中重新克隆,而不是从旧目录复制。
  • 文件名修复使用 git mv,提交中能看到明确的重命名。
  • 脚本没有把路径强制转成全小写或全大写。
  • 资源复制命令遇到同名目标时会失败,而不是静默覆盖。
  • 构建产物、依赖目录和日志都位于预期挂载点。
  • 干净构建与增量构建各执行一次,结果一致。

完成验证后先导出必要日志,再卸载测试卷:

hdiutil detach "/Volumes/BuildCase"

确认无需保留数据后,才删除 BuildCase.sparsebundle。若要长期作为质量门禁,应保留创建脚本而不是保留测试数据,并在每次任务开始时检查卷格式、剩余容量和挂载路径。

常见问题

需要把云端 Mac 的系统盘改成区分大小写吗?

不需要。更稳妥的做法是创建 APFS 稀疏映像并挂载为独立测试卷,只把仓库和构建目录放进去,验证完成后卸载即可。

为什么 Git 没有直接报告文件名大小写冲突?

Git 会记录路径大小写,但默认不区分大小写的工作区可能把两个路径映射到同一文件。应在区分大小写的卷中重新克隆,并对受版本控制的路径做折叠比较。

需要独享 Mac mini 物理节点

查看可用配置、节点和固定计费周期,将文章步骤落到可持续使用的云端 Mac 环境。

立即租用 Mac mini