工程实践

云端 Mac 上排查扩展属性导致的 iOS 签名失败

云端 Mac 上排查扩展属性导致的 iOS 签名失败

归档在本地能够完成,换到云端 Mac 却在签名阶段报出 resource fork, Finder information, or similar detritus not allowed。这类故障通常不是证书或描述文件失效,而是某个图片、脚本产物或复制目录携带了 macOS 扩展属性。它会随着资源复制进入 App 包,直到签名工具执行严格检查时才暴露。

先确认失败属于哪一层

不要看到“签名失败”就立即重建证书。先从完整构建日志中找到第一条 codesign 错误,记录失败对象是主 App、Framework、扩展还是资源包。若日志明确出现 FinderInfoResourceForkdetritus,排查重点应转向文件元数据。

扩展属性常见于以下路径:

  • 从图形文件管理器复制进仓库的图片、字体或压缩包;
  • 从共享目录解压后直接提交的资源;
  • 构建脚本用 cp -R 搬运的预生成目录;
  • 未纳入版本控制、但长期留在工作目录里的本地产物;
  • 缓存恢复时连同文件元数据一起写回的依赖。

删除 DerivedData 只能清掉构建输出。如果污染位于源码或外部输入中,下一次构建仍会稳定复现。

先保存失败命令、目标路径和日志,再做清理。否则即使构建偶然恢复,也无法判断真正的污染源。

建立只读扫描基线

检查仓库输入

xattr -lr 会递归列出路径及其属性。先把结果写入日志,不要边扫描边删除:

set -euo pipefail

ROOT="${SRCROOT:-$PWD}"
REPORT="${TMPDIR:-/tmp}/ios-xattr-source.log"

xattr -lr "$ROOT" > "$REPORT" 2>&1 || true

grep -E \
  'com\.apple\.(FinderInfo|ResourceFork)' \
  "$REPORT" || true

仓库体积较大时,可排除 .git、DerivedData 和依赖缓存,再逐项检查实际参与打包的目录。重点不是命中数量,而是回答三个问题:属性附着在哪个文件、文件由谁产生、它通过哪个 Build Phase 进入包内。

命中位置 优先检查 处理原则
Assets 或资源目录 文件导入方式、解压工具 修复源文件
脚本输出目录 cpditto 参数 重建暂存目录
DerivedData 缓存恢复、旧产物 清缓存后复现
Framework 内部 下载与解包步骤 重新获取并验收

检查归档包

若归档已经生成,直接扫描 .app 能确认污染是否进入交付物:

ARCHIVE_PATH="$PWD/build/App.xcarchive"
APP_PATH="$ARCHIVE_PATH/Products/Applications/App.app"
REPORT="$PWD/build/xattr-app.log"

test -d "$APP_PATH"
xattr -lr "$APP_PATH" > "$REPORT" 2>&1 || true

if grep -Eq 'com\.apple\.(FinderInfo|ResourceFork)' "$REPORT"; then
  echo "forbidden extended attributes found"
  exit 1
fi

这里使用固定示例路径。实际流水线应从归档目录解析唯一的 .app,并在找不到或找到多个候选项时直接失败,避免检查错包。

清理时保留可追溯性

不建议一上来对整个工作区执行 xattr -cr。它会移除所有扩展属性,既扩大修改范围,也可能掩盖下载、解压或缓存步骤中的问题。更稳妥的方法是只删除已经确认会破坏签名的两类属性:

TARGET="$PWD/StagingPayload"

find "$TARGET" -print0 |
while IFS= read -r -d '' item; do
  xattr -d com.apple.FinderInfo "$item" 2>/dev/null || true
  xattr -d com.apple.ResourceFork "$item" 2>/dev/null || true
done

TARGET 最好是一次性暂存副本,而不是开发者的原始工作区。完成清理后重新扫描;仍有命中就停止构建,不要把 || true 放到最终验收条件上。

若属性来自压缩包,应修复解包后的验收步骤;若来自构建脚本,应让脚本每次创建空暂存目录,再复制明确的文件清单。仅在末尾补一条递归清理命令,会让污染继续存在并在其他任务中复发。

把检查接入归档流程

构建前检查输入

构建前门禁只扫描会进入 App 的资源目录,不必扫描整个主目录。命中时输出相对路径和属性名,然后以非零状态退出。这样可以在耗时归档开始前失败,也便于定位最近变更。

检查脚本应纳入版本控制,并固定运行环境:

  1. 使用脚本自身位置解析仓库根目录;
  2. 对包含空格的路径使用空字符分隔;
  3. 将报告写入本次任务独立的产物目录;
  4. 不在扫描阶段修改文件;
  5. 对不存在的目标目录直接报错。

归档后验证签名

扩展属性扫描通过后,再让系统签名工具验证归档:

APP_PATH="$PWD/build/App.xcarchive/Products/Applications/App.app"

codesign --verify \
  --strict \
  --verbose=2 \
  "$APP_PATH"

如果 App 内包含独立 Framework 或扩展,还应遍历这些嵌套代码对象分别验证。不要只检查主包后就默认内部组件安全。验证日志、归档路径和源码提交号应一起保存,便于区分“源码相同但输入缓存不同”的情况。

用验收清单结束排查

一次可靠修复应同时满足以下条件:

  • 源码与外部输入的扫描结果已经留档;
  • 污染文件的产生或复制路径已经确定;
  • 清理只作用于明确属性或一次性暂存副本;
  • DerivedData 与相关缓存清除后仍能重新归档;
  • 最终 App 包内没有 FinderInfo 和 ResourceFork;
  • 主 App 与嵌套代码对象均通过严格签名验证;
  • 同一提交在全新工作目录中可以重复通过。

扩展属性故障难排,不是因为命令复杂,而是它横跨源码、文件系统元数据、缓存和签名四层。把“先记录、再定位、后清理、终验收”固化为门禁后,这类问题就不再依赖人工登录节点临时处理,也不会因为更换工作目录而失去线索。

常见问题

为什么删除 DerivedData 后签名错误仍会出现?

如果 FinderInfo 或 ResourceFork 已附着在源码资源、脚本复制目录或下载文件上,重新构建仍会把它带入 App 包。应先扫描源码与外部输入,再清理 DerivedData 并重新归档。

可以直接对整个仓库执行 xattr -cr 吗?

不建议。递归清除会同时删除与故障无关的属性。更稳妥的做法是先记录命中项,只删除 FinderInfo 和 ResourceFork,或在一次性暂存副本中执行全量清理。

清理后怎样确认归档可以交付?

重新生成归档后,先扫描 App 包内的目标扩展属性,再运行 codesign --verify --strict。两项都通过,并保存日志与归档校验值,才算完成验收。

NowMini M4

在独享物理节点上运行下一项任务

使用 M4、16GB RAM 与 256GB SSD 的云端 Mac,按任务周期选择新加坡、东京、首尔或香港节点。

立即租用云端 Mac