同一份工程在开发者电脑上归档成功,换到云端 Mac CI 却提示资源不存在,最容易被忽略的原因之一是符号链接。仓库里看似普通的文件,实际可能指向本机目录、未提交目标或工作区之外的位置。Xcode 在复制资源、执行脚本或封装依赖时才访问它,于是问题直到构建后半段才暴露,日志还常把它表现成普通的“找不到文件”。
先定义允许什么链接
不要先写扫描脚本,先把策略写清楚。适合持续构建的默认规则通常只有三条:链接必须使用相对路径,解析后的目标必须存在,并且目标必须留在当前工作区内。少数工具生成的内部链接可以例外,但例外应精确到完整相对路径。
符号链接门禁的目标不是消灭链接,而是消除对某台机器目录结构的隐式依赖。
例如 Config/current.json -> release.json 可以稳定地随仓库检出;SDK/current -> /Users/dev/SDK 则把本机状态带进了流水线。../Shared/file 也不能只看文本判断,它可能仍在仓库内,也可能越过 CI 分配的工作目录,因此必须解析规范化路径后再比较边界。
| 检查项 | 应通过 | 应阻断 |
|---|---|---|
| 链接形式 | 仓库内相对路径 | 绝对路径 |
| 目标状态 | 目标存在且可读 | 断链或循环链接 |
| 路径边界 | 解析后位于工作区 | 指向工作区外 |
| 例外规则 | 完整相对路径 | 宽泛目录前缀 |
从 Git 索引确认链接身份
检查模式而不是文件后缀
Git 用模式 120000 记录符号链接,链接目标则作为文件内容保存。先从索引列出它们,避免把普通文本文件或构建后生成的链接混在一起。
git ls-files -s | awk '$1 == "120000" { print $4 }'
git config --show-origin --get core.symlinks || true
git status --porcelain=v1
在 macOS 上,检出后的对象应当真的是链接。若仓库状态里突然出现链接被替换成普通文件,先检查打包、解压或同步步骤是否改写了文件类型。门禁还应在干净检出后运行一次 git diff --exit-code,确认初始化脚本没有悄悄改动索引中的链接文本。
找出未跟踪目标
链接本身被提交,不代表目标也已提交。对每个受 Git 跟踪的链接,应确认目标存在,并用 git ls-files --error-unmatch 判断仓库内目标是否受版本控制。若目标本来就由构建生成,应把检查移到生成步骤之后,同时保留生成前“允许暂时不存在”的精确清单。
用边界脚本阻断断链与越界
下面的脚本递归检查指定目录。它拒绝绝对链接、无法解析的目标,以及解析后位于根目录之外的目标。保存为 ci/check_symlinks.py,在依赖安装之后、调用 xcodebuild 之前执行。
import os
import sys
from pathlib import Path
root = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
failures = []
for path in root.rglob("*"):
if not path.is_symlink():
continue
raw = os.readlink(path)
relative = path.relative_to(root)
if os.path.isabs(raw):
failures.append((relative, "absolute", raw))
continue
try:
target = path.resolve(strict=True)
except (FileNotFoundError, RuntimeError, OSError) as error:
failures.append((relative, "broken", str(error)))
continue
try:
target.relative_to(root)
except ValueError:
failures.append((relative, "outside", str(target)))
for item in failures:
print("\t".join(map(str, item)))
sys.exit(1 if failures else 0)
执行方式如下:
python3 ci/check_symlinks.py "$PWD"
不要用简单的字符串前缀比较路径。/tmp/job-10 是 /tmp/job-1 的字符串前缀,却不是它的子目录;规范化后使用路径层级比较,才能避免这种误判。
在构建前后各检查一次
源码扫描只能发现仓库已有的问题,依赖管理器、代码生成器和自定义 Build Phase 还可能创建新链接。因此建议设置两个检查点。
构建前检查工作区
依赖解析和代码生成完成后,扫描整个工作区,但排除明确属于工具内部实现且不会进入产物的缓存目录。排除规则也要固定在仓库中,不能由 runner 上的临时环境变量任意扩大。随后记录链接清单,便于比较构建过程中新增了什么。
find "$PWD" -type l -print | LC_ALL=C sort > "$TMPDIR/symlinks-before.txt"
python3 ci/check_symlinks.py "$PWD"
构建后检查产物
归档完成后,对导出的 .app 或解包后的产物根目录再次运行同一脚本。合法链接必须同时满足“目标仍在产物内部”和“路径位于允许清单”。如果第三方框架确实包含内部链接,应允许具体路径,而不是放行整个 Frameworks 目录。
再比较构建前后的清单。新增链接若来自脚本阶段,应能对应到明确的生成命令;无法解释的新增项先阻断发布,再从构建日志定位创建者。
处理常见误报并形成门禁
最常见的误报来自临时目录、依赖缓存和归档内部结构。处理顺序应是先缩小扫描根目录,再建立精确例外,最后才考虑排除目录。不要因为一个依赖产生链接,就跳过整个依赖树,否则真正的断链也会被一起隐藏。
门禁失败时至少输出链接相对路径、失败类型和解析目标,并把扫描脚本的退出码直接传给 CI。修复后用一次全新检出验证,不能只在已经执行过初始化脚本的工作区重跑。最终检查项可以收敛为:Git 模式正确、目标受控、链接为相对路径、解析后不越界、构建新增链接可解释、归档例外精确匹配。做到这六项,符号链接就从偶发环境问题变成了可审计的工程契约。
常见问题
为什么符号链接在本地可用,到了云端 Mac CI 却会断开?
常见原因是链接目标依赖本机绝对路径、目标文件未被 Git 跟踪,或仓库在不同目录检出。门禁应同时检查链接文本、目标是否存在以及解析后的路径是否仍在工作区内。
是否应该禁止仓库中的所有符号链接?
不必。指向仓库内稳定相对路径的链接可以保留,但应拒绝断链、绝对链接和越过工作区边界的链接,并对确有必要的例外使用精确路径允许清单。
归档中的合法符号链接怎样避免被误报?
先验证链接解析后仍位于归档根目录,再按完整相对路径匹配允许清单。不要按文件名或目录前缀宽泛放行,否则新增的异常链接可能绕过检查。
按工作负载选择云端 Mac
核对芯片、内存、存储、租期和节点后,订购一台 Apple Silicon 独享物理机。