工程文章

雲端 Mac 的 iOS App Extension 嵌入一致性檢查

雲端 Mac 的 iOS App Extension 嵌入一致性檢查

包含通知服務、分享或 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 失敗後,不要立即清理工作目錄。請先保留下列資訊:

  1. .xcarchive 內所有 .app.appex 的相對路徑;
  2. 每個套件的版本號、最低系統版本與 Bundle Identifier;
  3. codesign --verify --strict --verbose=2 的完整輸出;
  4. 本次使用的 Scheme、Configuration,以及實際展開後的建置設定;
  5. 封存工作使用的提交識別碼與 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

依任務選擇機型、節點區域與租用週期。每個實例都對應獨享實體機,採用非虛擬化環境。

選擇雲端 Mac 方案