包含通知服務、分享或 Widget Extension 的 iOS 專案,經常會遇到這類問題:主 App 可以順利編譯並封存,卻在匯出、安裝或發佈檢查階段才被判定為 Extension 無效。根本原因通常不在原始碼,而是 .app 與 .appex 之間的版本號、部署目標、嵌入位置或簽署設定出現偏差。將檢查接在雲端 Mac 的封存工作之後,就能在成品離開建置節點前得出明確結論。
先定義封存驗收邊界
App Extension 並不是獨立交付項目。它必須位於主 App 的 PlugIns 目錄中,並與主 App 建立可驗證的嵌入關係。CI 至少應檢查以下項目:
| 檢查項目 | 主 App | App Extension | 建議規則 |
|---|---|---|---|
CFBundleVersion |
必須存在 | 必須存在 | 完全一致 |
CFBundleShortVersionString |
必須存在 | 必須存在 | 完全一致 |
MinimumOSVersion |
必須存在 | 必須存在 | 團隊統一時保持一致 |
NSExtension |
不要求 | 必須存在 | 字典存在且可讀取 |
| 程式碼簽署 | 有效 | 有效 | 通過嚴格驗證 |
| 嵌入位置 | Applications |
PlugIns/*.appex |
不接受散落的副本 |
並非所有專案都必須採用相同的最低系統版本;但如果團隊並未刻意維護不同的部署目標,直接要求一致會更容易發現設定偏差。若確實需要不同版本,應將允許範圍寫入儲存庫,而不是在腳本中靜默忽略。
「建置成功」只代表各個 Target 能夠產生成品;「封存合格」還必須證明這些成品以正確關係組成可交付的 App。
產生唯一待檢查的 xcarchive
Gate 應讀取封存檔,而不是 DerivedData 中的中間目錄。後者可能殘留前一次建置產生的 .appex,也無法代表最終的嵌入結構。請先刪除固定的封存路徑,再執行一次 Release 封存:
set -euo pipefail
ARCHIVE_PATH="$PWD/build/App.xcarchive"
rm -rf "$ARCHIVE_PATH"
xcodebuild \
-workspace App.xcworkspace \
-scheme App \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath "$ARCHIVE_PATH" \
clean archive
不要讓不同分支共用同一個封存目錄。平行工作可以使用提交雜湊或 CI 工作編號建立獨立路徑,但在進入後續腳本前,只應傳入一個明確的 .xcarchive。
先確認嵌入結果
封存完成後,主 App 通常位於 Products/Applications,Extension 則位於主 App 套件內的 PlugIns。先使用 find 查看實際結果:
find build/App.xcarchive/Products/Applications \
\( -name '*.app' -o -name '*.appex' \) \
-print
如果專案已宣告 Extension Target,卻找不到 .appex,請優先檢查主 App Target 的 Embed App Extensions 建置階段,以及目前的 Scheme 是否會建置該 Extension。不要從其他目錄複製 Extension 來「補齊」封存檔。
使用腳本核對中繼資料與簽署
以下 Bash 腳本相容於 macOS 內建工具。它會找出主 App、逐一讀取各 Extension 的 Info.plist、比較版本與最低系統版本,並分別驗證簽署:
#!/bin/bash
set -euo pipefail
ARCHIVE="${1:?usage: validate-extensions.sh App.xcarchive}"
APP=$(find "$ARCHIVE/Products/Applications" -maxdepth 1 -name '*.app' -print -quit)
if [ -z "$APP" ]; then
printf '%s
' "main app not found"
exit 1
fi
read_plist() {
/usr/libexec/PlistBuddy -c "Print :$2" "$1/Info.plist"
}
host_build=$(read_plist "$APP" CFBundleVersion)
host_version=$(read_plist "$APP" CFBundleShortVersionString)
host_minimum=$(read_plist "$APP" MinimumOSVersion)
count=0
failed=0
while IFS= read -r extension; do
count=$((count + 1))
ext_build=$(read_plist "$extension" CFBundleVersion)
ext_version=$(read_plist "$extension" CFBundleShortVersionString)
ext_minimum=$(read_plist "$extension" MinimumOSVersion)
[ "$ext_build" = "$host_build" ] || failed=1
[ "$ext_version" = "$host_version" ] || failed=1
[ "$ext_minimum" = "$host_minimum" ] || failed=1
/usr/libexec/PlistBuddy \
-c "Print :NSExtension" \
"$extension/Info.plist" >/dev/null
codesign --verify --strict --verbose=2 "$extension"
done < <(find "$APP/PlugIns" -maxdepth 1 -name '*.appex' -print)
[ "$count" -gt 0 ] || {
printf '%s
' "no app extensions found"
exit 1
}
codesign --verify --deep --strict --verbose=2 "$APP"
[ "$failed" -eq 0 ] || {
printf '%s
' "extension metadata mismatch"
exit 1
}
將腳本儲存為 ci/validate-extensions.sh,執行 chmod +x 後,在封存步驟完成後呼叫它。如果儲存庫同時產生「含 Extension」與「不含 Extension」兩個 Scheme,應透過參數宣告預期的 Extension 數量,不能一律將零個 Extension 視為成功。
將版本來源收斂至建置設定
最常見的偏差來自手動維護多個 Info.plist。主 App 更新了 Build Number,Extension 卻仍保留舊值,而原始碼編譯不會因此失敗。更穩妥的做法是讓所有 Target 使用同一組建置變數:
MARKETING_VERSION = 4.8.0
CURRENT_PROJECT_VERSION = 8204
IPHONEOS_DEPLOYMENT_TARGET = 17.0
對應的 plist 值應使用 $(MARKETING_VERSION)、$(CURRENT_PROJECT_VERSION) 和 $(IPHONEOS_DEPLOYMENT_TARGET)。如果設定由 .xcconfig 管理,主 App 與 Extension 應共同包含基礎檔案,再由少量 Target 專屬檔案覆寫真正需要不同的鍵。
不要用封存後重新簽署來掩蓋問題
codesign --deep 適合用來驗證整個套件,不應被當成通用修復命令。封存後遞迴重新簽署可能改變原有的嵌入關係,也會使 CI 成品與專案設定脫節。發現簽署失敗時,應回到 Target 的簽署設定、Build Phases 與匯出設定進行修正,再重新產生完整封存檔。
失敗時依序保留證據
Gate 失敗後,不要立即清理工作目錄。請先保留下列資訊:
.xcarchive內所有.app與.appex的相對路徑;- 每個套件的版本號、最低系統版本與 Bundle Identifier;
codesign --verify --strict --verbose=2的完整輸出;- 本次使用的 Scheme、Configuration,以及實際展開後的建置設定;
- 封存工作使用的提交識別碼與 Xcode 版本。
可使用以下命令匯出建置設定,方便比較主 App 與 Extension 實際取得的變數:
xcodebuild \
-workspace App.xcworkspace \
-scheme App \
-configuration Release \
-showBuildSettings > build/build-settings.txt
最終的 Gate 應達成兩點:失敗訊息能直接指出哪一個 .appex、哪一個鍵不一致;修正必須發生在專案設定中,而不是在成品產生之後。如此一來,無論雲端 Mac 節點如何更換,驗收依據都會隨儲存庫一同保存,封存結果也能重複檢查。
常見問題
為什麼 Xcode 封存成功後仍可能因 App Extension 失敗?
封存主要確認各目標能夠建置;匯出與安裝還會檢查版本、部署目標、擴充宣告、嵌入關係及簽署完整性,因此錯誤可能較晚才顯現。
主程式與 App Extension 的版本必須完全一致嗎?
CFBundleVersion 應一致,CFBundleShortVersionString 也建議由同一組建置變數產生,以便追蹤發佈版本與問題制品。
可以在封存後重新簽署擴充功能嗎?
不建議把補簽當成修復方式。應修正工程設定、嵌入階段或簽署配置,再由乾淨的工作區重新建立封存。
獨享實體節點
將開發與建置任務交給獨享雲端 Mac
依任務選擇機型、節點區域與租用週期。每個實例都對應獨享實體機,採用非虛擬化環境。