TL;DR
SwiftPM 依赖里的静态库一旦被套进 framework 外壳,Xcode 就会把它嵌进 app;而 App Store 会校验
Frameworks/下每个 bundle 的MinimumOSVersion——iOS 要求、macOS 不要求,所以同一次构建会一半成功一半被拒。两条修复各自都够:在构建末尾删掉这个从不加载的外壳,或者给它的Info.plist补上这个键。
同一次 Xcode Cloud 构建,macOS 归档顺利上传,iOS 归档在上传那一步被 App Store Connect 拒了:
1 | ITMS-90530: Invalid MinimumOSVersion - Apps that only support 64-bit devices must |
被点名的 libssl.framework 来自一个 SwiftPM 依赖里的 OpenSSL。诡异之处在于:这个 framework 从来不会被加载——它里面是一个静态库,符号早已链进主二进制,可执行文件里没有任何指向它的 load command。一个不参与运行的东西,却让整个包进不了 App Store。
更让人先入为主的是,同一次构建里 macOS 那半边一切正常。于是最初的判断很自然地跑偏了:既然 iOS 单独失败,那问题多半出在这次 iOS 相关的改动上。
先说两条错路
那次构建里唯一显眼的新东西,是刚换成 Icon Composer 的应用图标。于是有了两个假设:
- 顶层
Info.plist缺CFBundleIconName; - 图标 PNG 带了 alpha 通道。
两条都被同一个办法证伪:把上一次成功上传的包拉下来做对照。归档产物可以从构建服务下载,解开 IPA 直接读:
1 | /usr/libexec/PlistBuddy -c 'Print :CFBundleIconName' \ |
被 App Store 接受的那个包同样没有 CFBundleIconName,图标同样不带 alpha。两个假设当场出局。
这里有个方法层面的教训:在没有拿到服务端的真实报错之前,任何”最近改了什么”的推断都只是猜测。构建服务的日志、.xcresult 里都没有 ITMS 级别的原因——归档本身 0 error,失败发生在归档之后的上传环节。真正的错误文本只在 App Store Connect 的投递通知里。拿到它,五分钟就能定位;拿不到,可以猜一整天。
真正的差异只有一处
拿新旧两个 IPA 逐项对比,Info.plist 除了 build 号和图标条目外没有实质差别。差别在别处:
1 | ls "旧包/Payload/MyApp.app/Frameworks" |
新包里多了一个 60 KB 的 libssl.framework,它的 Info.plist 只有几个 CFBundle* 键,没有 MinimumOSVersion。而这个 bundle 的来源,是 OpenSSL 依赖在一次大版本升级里换了打包形态。
四条理论
这条因果链要连起来,需要四件互相独立的事实。少任何一条,都会把原因归到别处去。
1. xcframework 有两种打包形态,后果完全不同
xcodebuild -create-xcframework 接受两类输入:
| 形态 | 命令 | 产物 | Xcode 的处理 |
|---|---|---|---|
| 库 | -library libssl.a -headers include/ | 裸的静态库与头文件 | 只链接,没有 bundle 可嵌入 |
| 框架 | -framework libssl.framework | framework 目录(含 Info.plist) | 链接并拷贝进 app 的 Frameworks/ |
决定”要不要往 app 里放东西”的是产物形态,不是库本身是静态还是动态。-create-xcframework 生成的元数据里没有 static/dynamic 标记,Xcode 见到 framework 形状就按可嵌入处理。
2. 静态库套上 framework 外壳,仍然是静态库
那个 OpenSSL 依赖 4.x 的每一片是「framework 外壳 + 静态 ar 归档」。链接器把归档里的目标文件静态链进主二进制,运行时根本不需要那个 bundle:
1 | otool -L "MyApp.app/MyApp" | grep -c libssl |
没有 load command,符号却在。也就是说,Frameworks/libssl.framework 是纯粹的死重:装到用户设备上、参与签名、参与校验,却永远不会被 dyld 打开。
3. Apple 校验 app 里的每一个 bundle,且两个平台用不同的键
上传时的服务端校验会遍历 Frameworks/ 下的每个 bundle,要求它声明自己支持的最低系统版本。这是分发规则,与该 bundle 是否会被加载无关。
| 平台 | 键 |
|---|---|
| macOS | LSMinimumSystemVersion |
| iOS / iPadOS / tvOS / watchOS / visionOS / Mac Catalyst | MinimumOSVersion |
macOS 侧不要求 MinimumOSVersion。同一次构建里 macOS 成功、iOS 被拒,原因就在这里——不是 iOS 归档有问题,而是两个平台的校验规则不同。这个不对称正是最容易把人带偏的地方。
4. Mach-O 有两代版本载入命令
要给出正确的版本值,得先能从二进制里读出来。目标文件用两种方式记录最低系统版本:
| 载入命令 | 字段 | 出现场合 |
|---|---|---|
LC_BUILD_VERSION | minos | 当前工具链的常规输出 |
LC_VERSION_MIN_MACOSX / _IPHONEOS / _TVOS / _WATCHOS | version | 旧式输出,至今仍能在某些 slice 上遇到 |
而且工具链会自行抬高下限。那个依赖的构建脚本给 iOS 传的是 11.0,但因为包含 arm64e,对应 slice 的实际 minos 是 14.0:
1 | otool -l libssl.framework/libssl | grep -A4 LC_BUILD_VERSION | grep minos |
所以版本值应该从二进制里读,而不是照抄构建脚本里的常量——两处各写一份,早晚会漂移。
为什么以前没事,又为什么现在才炸
翻上游仓库的历史,打包脚本里只差一行:
1 | # 旧版本 |
旧版本产出裸静态库,app 里压根没有这个 bundle,也就没有可校验的 Info.plist。切换发生在一次为了「让 module map 随框架一起分发」的提交里,随新的大版本发布。
至于为什么隔了两周才暴露:依赖升级进来之后,那段时间只发过 macOS 侧的包。这是 OpenSSL 大版本升级之后的第一次 iOS 上传。
这类”沉睡的破坏”有个共同特征:变更与暴露之间隔着一道发布节奏。依赖升级当天所有测试都是绿的,本地构建、模拟器运行、单元测试都不碰上传校验这一层。只有真正走一次分发,问题才会现身。
两条修复
应用侧:不发布没人加载的东西
在应用 target 的构建末尾把这个外壳删掉,排在 Embed Frameworks 之后:
1 | set -e |
守卫比删除本身更重要。 判据不是「名字叫 libssl 就删」,而是「可执行文件里有没有指向它的 load command」。将来上游若真的改成动态库,脚本会打一条 note 并原样保留,不会把要用的东西删掉。一条会在前提变化时自动停手的清理脚本,和一条无条件 rm -rf 的脚本,差别就在这里。
上游:让外壳合法
更根本的修复在打包脚本里:生成 Info.plist 时补上版本键,值从刚打包进去的二进制读。
1 | minimum_os_version_for_library() { |
几个取舍:
- 从二进制读而不是照抄常量,因为工具链会自行抬高下限;
- 两种载入命令都认,因为旧式
LC_VERSION_MIN_*至今仍会出现; - 取各架构的最小值,因为框架整体支持的下限是各片里最低的那个;
- 读不到就报错退出,而不是写入空字符串——空值正是被拒绝的那个状态。
macOS 用 LSMinimumSystemVersion,其余(含 Mac Catalyst)用 MinimumOSVersion。这个补丁已经提给上游并合入:Lakr233/openssl-spm#5。
怎么单独验证上游那条修复
应用侧的修复很好验证:删掉外壳,重新走一次上传,通过即可。但上游那条呢?它合并之后并没有立刻发版,产物还是旧的。
于是构造一个对照实验:保留被嵌入的外壳,只让它合法。
- 用打过补丁的脚本,把原版二进制重新封装一次,逐片读回确认键值正确;
- 把重打的产物挂到自己 fork 的 release 上;
- 应用侧回到没有删除阶段的那个提交,让外壳照旧被嵌入;
- 走一次真实上传。
结果是通过——这就单独证明了上游补丁本身足够,不需要应用侧配合。
这里踩到一个 SwiftPM 的坑值得记下:依赖的 identity 由仓库名决定,同一个 identity 指向两个不同 URL 会导致解析冲突。fork 和原仓库都叫 openssl-spm,只把根工程指向 fork,中间那层依赖仍然指着原仓库,解析就会卡住。要么整条链一起切,要么都不切。这条链上有三层,最后是三层一起改才跑通的。
对照实验还有个副产品:它把”两条修复各自足够”这件事变成了可验证的事实,而不是推理。既然各自足够,就不需要在合并顺序上纠结,也不必等上游发版才敢发自己的包。
一份可复用的排查清单
遇到同类的上传拒绝,按这个顺序走:
第一步,拿到真实报错。投递通知邮件或 App Store Connect 的 Activity 页面。在此之前不要开始猜。
第二步,和上一次成功的包对照。归档产物可以下载,解开之后重点看:
1 | app 里到底嵌了什么 |
第三步,区分”编译问题”和”分发问题”。归档成功、上传失败,说明代码没问题,问题在包的形状或元数据上。前者去看编译日志,后者去看包本身——这两类问题的排查路径几乎没有交集。
写在最后
这次排查里,最耗时间的不是修复,而是从一个不对称现象(macOS 成功、iOS 失败)推出错误的方向。真正的分水岭是”拿到 ITMS 原文”和”和上一次成功的包做对照”这两步:前者把猜测变成事实,后者把变量收敛到唯一的一处差异。
至于那个从不会被加载却能让整个包被拒的 framework,它提醒的是另一件事:app bundle 是一个会被完整审视的容器。里面每一个 bundle 都要能自我说明,无论它是否参与运行。
参考资料
- Apple Developer Documentation:Creating a multiplatform binary framework bundle
- Apple Developer Documentation:MinimumOSVersion
- Apple Developer Documentation:LSMinimumSystemVersion
- Lakr233/openssl-spm#5:Declare a minimum OS version in each framework
本文由 Claude Code 依据排查过程中的记录整理而成。