云端 Mac CI 的 UTF-8 与 Unicode 文件名治理

云端 Mac CI 的 UTF-8 与 Unicode 文件名治理

同一份 iOS 工程在开发者终端里能编译,换到云端 Mac 的非交互式 CI 后,却可能在读取中文配置、解析带重音字符的资源名或截取日志时失败。最麻烦的是,这类问题常被误判为依赖损坏:文件明明存在,脚本却提示找不到;日志肉眼相同,Git 里却出现两个不同路径。根因通常不是 UTF-8 本身,而是任务入口、语言运行时和仓库文件名对编码的理解没有对齐。

先确认差异发生在哪一层

不要一上来修改系统设置。先把问题拆成四层:任务进程的 locale、脚本运行时的默认编码、管道中单个命令的行为,以及文件名的 Unicode 规范化形式。

在交互式终端和 CI 任务开头分别运行:

printf 'shell=%s\n' "$SHELL"
locale
printf 'LANG=%s\nLC_ALL=%s\n' "${LANG:-unset}" "${LC_ALL:-unset}"
python3 -c 'import locale,sys; print(locale.getpreferredencoding(False), sys.getfilesystemencoding())'
ruby -e 'p [Encoding.default_external, Encoding.default_internal]'

把输出作为普通构建产物保存,不要只留在滚动日志里。若终端显示 en_US.UTF-8,而任务中变量为空,问题就在启动边界;若 locale 一致但 Ruby 仍按其他编码读取文件,则应检查脚本是否显式传入了错误选项。

“机器支持 UTF-8”不等于“每个任务都使用 UTF-8”。CI 的真实配置是进程收到的环境,而不是登录终端看到的环境。

在任务入口固定 UTF-8

最稳妥的做法是在 runner 包装脚本或流水线任务入口设置环境,而不是依赖 .zshrc。非交互式 Shell 通常不会按开发者预期加载这些文件,launchd 启动的进程也可能拥有另一套环境。

#!/bin/zsh
set -euo pipefail

export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8

locale
exec "$@"

将脚本保存为 ci/run-utf8.zsh,赋予执行权限,再让所有构建命令通过它进入:

chmod +x ci/run-utf8.zsh
ci/run-utf8.zsh ./ci/build.zsh

不要全局执行 export LC_ALL=C 来“修复”日志。C locale 适合需要稳定字节排序的单条命令,但会改变字符分类、大小写转换和正则行为。确有需要时,把作用域缩到命令本身,例如 LC_ALL=C sort input.txt

同时检查脚本自己的读取方式

Python 应显式写 encoding="utf-8",Ruby 可使用 File.read(path, encoding: "UTF-8")。Shell 读取逐行文本时用 IFS= read -r,避免反斜杠和首尾空白被改写。JSON、plist 或 YAML 应交给对应解析器,不要用 grepsed 猜结构。

检查 Unicode 文件名规范化

字符 é 可以是一个码位,也可以由字母和组合符组成。两种形式看起来相同,字节却不同。仓库在 macOS、Linux 和压缩包之间流转时,这种差异可能表现为重复资源、找不到脚本或仅修改大小写的提交无法正确落地。

在 CI 中加入只读检查:

from pathlib import Path
import sys
import unicodedata

bad = []

for path in Path(".").rglob("*"):
    if ".git" in path.parts:
        continue
    raw = path.as_posix()
    normalized = unicodedata.normalize("NFC", raw)
    if raw != normalized:
        bad.append((raw, normalized))

for raw, normalized in bad:
    print(f"non-nfc: {raw!r} -> {normalized!r}")

sys.exit(1 if bad else 0)

保存为 ci/check_unicode_paths.py,在依赖安装前执行。检查器只报告,不自动重命名,因为规范化后的路径可能与已有文件冲突。修复时先确认 Git 索引中的真实名称:

git -c core.quotepath=false ls-files
git -c core.quotepath=false status --short

然后通过 git mv 完成独立提交。若只是大小写变化,可先移动到临时名称,再移动到目标名称。完成后必须在全新目录克隆并构建,不能用已经受文件系统缓存影响的旧工作区验收。

让日志保留原始证据

编码故障经过 tee、日志采集器或 JSON 转义后,原始字节可能被替换,最终只剩下问号或替代字符。排查阶段应同时保留可读日志和关键文件的字节视图:

file -I Config/环境.json
xxd -g 1 -l 96 Config/环境.json
plutil -lint App/Info.plist

不要把 file 的判断当成唯一结论,它只能提供线索。真正读取文件的程序仍应明确指定 UTF-8,并对解码失败返回非零状态。构建脚本也不要用 cmd | tee build.log 后忽略前一个命令的退出码;在 zsh 中可读取 $pipestatus,或将关键命令与日志采集拆开。

日志还应记录失败路径的转义形式。Python 的 repr()、Ruby 的 String#dump 都比直接打印更适合识别组合字符、换行和不可见空格。

建立合并前门禁与修复顺序

一套可维护的门禁只需要检查确定事实,不应擅自改仓库。建议按以下顺序执行:

  1. 输出 locale 与各运行时编码。
  2. 检查仓库路径是否为 NFC。
  3. 检查规范化后是否出现重名。
  4. 用明确编码读取关键文本配置。
  5. 在全新工作区执行最小构建。
  6. 保存环境探针、路径报告与失败日志。

若任务只在某个执行器失败,先比较环境探针,再比较 Git 索引,不要先清空所有缓存。若路径报告出现异常,先修文件名并建立独立提交;若路径正常而解析失败,再检查文件内容的编码声明和读取参数。这样能把环境问题、仓库问题和工具问题分开处理。

RentMini 上的任务也应把这组检查放进仓库,由项目自己版本化,而不是依赖某台云端 Mac 的手工设置。入口脚本、Unicode 检查器和日志证据都随代码评审后,重新建立工作区或更换节点时才能得到一致行为。

常见问题

为什么终端里正常的脚本到了 CI 就出现乱码?

CI 通常从非交互式进程启动,不会读取终端使用的初始化文件。应在任务入口显式设置 LANG 和 LC_ALL,并打印 Ruby、Python 等运行时实际采用的编码。

发现非 NFC 文件名后可以直接批量重命名吗?

不建议直接修改。先生成报告并检查大小写与规范化后的名称是否冲突,再通过独立提交执行 git mv,最后在全新检出目录重新构建。

按需使用云端 Mac

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

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

选择配置并租用