云端 Mac 构建产物打包:用 ditto 保住元数据

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

云端 Mac 构建产物打包:用 ditto 保住元数据

构建在云端 Mac 上顺利结束,产物下载到交付机后却出现脚本不可执行、符号链接变成普通文件,或应用包校验失败,这类问题往往不在编译阶段,而在最后一次压缩与解压。macOS 文件除了内容和路径,还可能携带扩展属性、访问控制列表、资源分支与符号链接关系。交付流程若只验证“压缩包能打开”,这些变化就会被带到发布阶段。

先定义需要保真的对象

不要直接对整个工作区执行压缩。先创建只包含交付内容的暂存目录,并明确哪些元数据属于产物契约。

常见对象包括应用包、框架、动态库、命令行工具、配置模板与符号文件。缓存目录、测试临时文件、版本控制目录和本地日志不应混入。暂存目录还能避免打包过程中源目录继续被构建任务修改。

set -euo pipefail

ROOT="$PWD"
SOURCE="$ROOT/build/Release"
STAGE="$ROOT/artifacts/stage"
ARCHIVE="$ROOT/artifacts/release.zip"

rm -rf "$STAGE"
mkdir -p "$STAGE"
ditto "$SOURCE/MyProduct.app" "$STAGE/MyProduct.app"
ditto "$SOURCE/MyFramework.framework" "$STAGE/MyFramework.framework"

第一轮检查应记录普通文件、目录和符号链接的数量。符号链接尤其容易在跨工具传递时被展开,导致归档体积变化或相对路径失效。

find "$STAGE" -type f | wc -l
find "$STAGE" -type d | wc -l
find "$STAGE" -type l -print

打包前盘点 macOS 元数据

使用 ls -le@ 查看权限、ACL 与扩展属性,使用 xattr 展开关键对象的属性内容。不要把所有陌生属性都当成垃圾。

ls -le@ "$STAGE"
xattr -lr "$STAGE" > "$ROOT/artifacts/xattr-before.txt"
find "$STAGE" -type l -exec sh -c '
  for item do
    printf "%s -> %s\n" "$item" "$(readlink "$item")"
  done
' sh {} + > "$ROOT/artifacts/symlinks-before.txt"

不要把 xattr -cr 当成通用修复。递归清理虽然能让某些表面错误暂时消失,却会同时删除取证信息。正确顺序是先盘点、判断来源,再对已确认不需要的单个属性做最小范围处理。

还要检查可执行文件的权限位。脚本在仓库中可执行,不代表复制到暂存目录后仍然如此。可用 find "$STAGE" -type f -perm -111 留下基线;若某个脚本必须执行,应把权限要求写入验收脚本,而不是依赖人工记忆。

用 ditto 创建可交付压缩包

ditto 是 macOS 自带工具,适合处理应用包和框架目录。创建 ZIP 时启用资源与扩展属性保存,并保留暂存目录这一层,能让接收方获得稳定的顶层结构。

rm -f "$ARCHIVE"
ditto -c -k \
  --sequesterRsrc \
  --keepParent \
  "$STAGE" \
  "$ARCHIVE"

shasum -a 256 "$ARCHIVE" > "$ARCHIVE.sha256"
du -h "$ARCHIVE"

归档文件名应固定,内部顶层目录也应固定。不要把时间戳、机器名或临时工作区绝对路径写进目录名,否则下游脚本不得不猜测入口。若流水线需要唯一标识,可把提交号放在外层制品记录中,而不是改变包内布局。

对来源不受控的文件,还应在进入暂存目录前检查文件名中的换行符、不可见字符和异常 Unicode 组合。普通空格可以安全处理,但脚本必须始终给变量加双引号,避免路径被 Shell 拆分。

在独立目录解包并比较

验收不能在原目录覆盖解包,否则缺失文件可能被旧文件掩盖。每次都使用全新的目录,并让 ditto 负责展开。

VERIFY="$ROOT/artifacts/verify"

rm -rf "$VERIFY"
mkdir -p "$VERIFY"
ditto -x -k "$ARCHIVE" "$VERIFY"

test -d "$VERIFY/stage/MyProduct.app"
test -d "$VERIFY/stage/MyFramework.framework"

find "$VERIFY/stage" -type l -exec sh -c '
  for item do
    printf "%s -> %s\n" "$item" "$(readlink "$item")"
  done
' sh {} + > "$ROOT/artifacts/symlinks-after.txt"

diff -u \
  "$ROOT/artifacts/symlinks-before.txt" \
  "$ROOT/artifacts/symlinks-after.txt"

文件内容校验应分别对打包前和解包后的目录生成清单。路径中可能包含空格,因此不要用未经引用的循环。下面的 Python 片段按相对路径排序,并对普通文件计算 SHA-256。

from hashlib import sha256
from pathlib import Path
import sys

root = Path(sys.argv[1])

for path in sorted(root.rglob("*"), key=lambda p: str(p.relative_to(root))):
    if path.is_file() and not path.is_symlink():
        digest = sha256(path.read_bytes()).hexdigest()
        print(digest, path.relative_to(root))

分别对 stage 与 verify/stage 运行,再用 diff 比较输出。这样能同时发现内容变化、文件遗漏和意外新增。

把结构与签名检查变成门禁

如果产物包含已签名的应用包或框架,解包后再执行签名验证。只在压缩前验证一次不够,因为问题可能由归档工具引入。

codesign --verify --deep --strict \
  "$VERIFY/stage/MyProduct.app"

codesign -d --verbose=4 \
  "$VERIFY/stage/MyProduct.app" 2>&1 |
  sed -n '1,20p'

--deep 适合做最终完整性检查,但不能代替逐层定位。失败时应先确定是主应用、嵌入框架还是辅助可执行文件,再对具体路径运行 codesign --verify --strict --verbose=4。同时比较打包前后的 xattr 输出,确认变化是否来自归档步骤。

建议把验收失败条件固定为:顶层目录不一致、文件清单不同、符号链接目标变化、必要执行位丢失、关键扩展属性异常,或签名验证失败。任何一项出现都应停止发布,而不是重新压缩后碰运气。

常见踩坑与交付清单

最常见的错误是混用多个工具:先由脚本执行普通 ZIP,再由上传程序自动解包重压。链路越长,元数据语义越难追踪。应指定唯一打包工具,并把原始压缩包直接交给制品存储。

第二类错误是边构建边打包。编译器仍在写文件时,清单和压缩包可能来自不同时间点。正确做法是等待构建进程成功退出,将结果复制到只读暂存目录,再开始归档。

第三类错误是只在同一台机器上测试。至少要删除旧验收目录,重新解包并从包内路径启动检查;条件允许时,再由独立任务执行同一套脚本。BerryVM 上的长期流水线可以把盘点文件、压缩包哈希和验收日志作为同一批次的交付记录,但日志中不应包含凭据或未经脱敏的环境变量。

最终清单应保持简短:暂存目录已冻结,符号链接已记录,扩展属性已盘点,压缩包哈希已生成,独立解包完成,文件清单一致,执行位符合预期,适用的签名检查通过。做到这些,打包就不再是构建结束后的随手一步,而是一道能够重复执行和准确失败的工程门禁。

常见问题

为什么不直接使用 zip -r 打包 macOS 构建产物?

普通 zip 流程容易忽略资源分支、扩展属性、访问控制信息或符号链接语义。若产物包含应用包、框架或工具链目录,应优先使用 ditto,并对解包结果单独验收。

打包前可以直接执行 xattr -cr 清除所有扩展属性吗?

不建议。递归清除会同时删除可能参与交付语义或问题取证的属性。应先用 xattr 盘点,再针对已确认无用的属性做最小范围处理。

校验压缩包哈希是否足以证明产物正确?

不足。压缩包哈希只能证明文件本身未变化,还应在独立目录解包后核对目录结构、符号链接、文件清单、关键扩展属性,以及适用时的代码签名。

独享物理 Mac

把下一项开发任务放到云端 Mac 上

选择 Berry M4 或 Berry M4 Pro,核对五个节点、租用周期和附加选项后提交订单。

选择配置并订购