工程筆記

在雲端 Mac 上建立 iOS 權限簽章預檢

在雲端 Mac 上建立 iOS 權限簽章預檢

有時封存作業在 Xcode 中顯示成功,卻在上傳時因推播環境、關聯網域或 App 識別碼不符而失敗。問題通常不在編譯,而是三個來源未能對齊:專案宣告的權限、描述檔允許的權限,以及最終寫入 App 的簽章權限。雲端 Mac 經常由指令碼重複使用,同一個節點可能處理多個分支與環境,因此不能只查看儲存庫中的 .entitlements 檔案,而應直接檢查準備交付的 .xcarchive

先確定檢查對象

以下流程以已產生的封存檔為輸入。請先找出主 App,不要直接檢查 DerivedData 中的暫存產物,因為它可能尚未完成最終簽章。

ARCHIVE_PATH="$HOME/builds/MyApp.xcarchive"
APP_PATH="$ARCHIVE_PATH/Products/Applications/MyApp.app"

test -d "$APP_PATH"
test -f "$APP_PATH/embedded.mobileprovision"
codesign --verify --strict --verbose=2 "$APP_PATH"

如果 App 包含擴充功能,也必須分別驗證每個 .appex。主 App 驗證通過,不代表通知擴充功能、Widget 或共享擴充功能使用了相容的 App 識別碼與權限。

find "$APP_PATH/PlugIns" -type d -name '*.appex' -print0 2>/dev/null |
while IFS= read -r -d '' item; do
  codesign --verify --strict --verbose=2 "$item"
done

預檢應以「即將上傳的產物」為判斷依據,而不是「專案看起來設定正確」。前者是結果,後者只是輸入。

擷取兩份實際權限清單

codesign 可以讀取最終簽章中的 entitlements,security cms 則可解碼 App 內嵌的描述檔。將兩者儲存為 plist,後續比較時便不必依賴 Xcode 介面。

WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT

codesign -d --entitlements :- "$APP_PATH" \
  > "$WORK_DIR/app-entitlements.plist" 2>/dev/null

security cms -D -i "$APP_PATH/embedded.mobileprovision" \
  > "$WORK_DIR/profile.plist"

plutil -lint "$WORK_DIR/app-entitlements.plist"
plutil -lint "$WORK_DIR/profile.plist"

不要直接拿描述檔的頂層欄位與 App 比較。允許的權限位於 Entitlements 字典中;建立時間、名稱等欄位只適合用來輔助定位,並不代表實際簽章結果。

自動比較關鍵欄位

以下指令碼會檢查五類常見問題。字串支援描述檔中的 * 萬用字元範圍;對於陣列權限,App 中的每一項都必須獲得描述檔允許。指令碼在失敗時會傳回非零狀態,適合接在封存步驟之後執行。

python3 - "$WORK_DIR/app-entitlements.plist" "$WORK_DIR/profile.plist" <<'PY'
import fnmatch
import plistlib
import sys

with open(sys.argv[1], "rb") as f:
    app = plistlib.load(f)

with open(sys.argv[2], "rb") as f:
    profile = plistlib.load(f)["Entitlements"]

keys = [
    "application-identifier",
    "com.apple.developer.team-identifier",
    "aps-environment",
    "com.apple.developer.associated-domains",
    "get-task-allow",
]

def allowed(actual, expected):
    if isinstance(actual, list) and isinstance(expected, list):
        return all(any(fnmatch.fnmatch(str(v), str(rule)) for rule in expected) for v in actual)
    if isinstance(actual, str) and isinstance(expected, str):
        return fnmatch.fnmatch(actual, expected)
    return actual == expected

failed = []
for key in keys:
    if key not in app:
        continue
    if key not in profile or not allowed(app[key], profile[key]):
        failed.append(key)

if failed:
    print("Entitlement mismatch:", ", ".join(failed))
    raise SystemExit(1)

print("Entitlement preflight passed")
PY

這個指令碼只會比較 App 實際宣告的欄位。如果專案規定某個欄位必須存在,例如正式版套件必須包含 aps-environment,還應加入必填欄位清單,並在欄位缺少時同樣判定失敗。

依症狀追查設定來源

症狀 優先檢查 常見根本原因
推播功能異常 aps-environment 封存環境與描述檔用途不一致
Universal Links 失效 com.apple.developer.associated-domains 網域項目未寫入最終簽章,或擴充功能採用了不同設定
上傳時回報識別碼不符 application-identifier Bundle Identifier、團隊前綴或 Target 設定混用
發行套件仍可偵錯 get-task-allow 使用了不適合交付的建置設定
擴充功能安裝失敗 主 App 與 .appex 權限 擴充功能識別碼、App 群組或描述檔組合不相容

發生失敗時,應先保留封存檔與兩份 plist,再檢查 CODE_SIGN_ENTITLEMENTSPRODUCT_BUNDLE_IDENTIFIER 及目前的建置設定。不要立即重新簽章並覆蓋現場,否則最有價值的差異證據會消失。

關聯網域與 App 群組都是陣列,項目順序通常不重要,因此應比較集合的包含關係;布林欄位則必須以實際布林值讀取,不能把字串 "false" 視為 false。這也是應使用 plistlib 而不是 grep 的原因。

整合至雲端建置驗收流程

將指令碼儲存為 Scripts/verify_entitlements.sh,並在 xcodebuild archive 成功後、匯出或上傳前執行。每次工作都應使用獨立的封存目錄,並將檢查結果、App 的簽章摘要與失敗欄位儲存為建置附件。記錄檔只應包含欄位名稱與去識別化後的識別碼,不要輸出私密金鑰內容或完整的存取憑證。

建議將驗收拆成四個步驟:驗證套件結構、擷取簽章權限、解碼描述檔,以及執行政策比較。任何一步失敗都應停止後續交付,但仍需保留 .xcarchive 供事後檢討。若專案包含多個 Target,則應分別針對主 App 與每個擴充功能執行,不能重複使用主 App 的檢查結果。

最後再進行一次人工抽查:確認目前工作使用的是預期 Scheme 與 Configuration、正式產物的 get-task-allowfalse、推播環境符合交付目標、關聯網域未混入測試位址,且所有擴充功能均已通過獨立簽章驗證。如此便能將原本拖到上傳階段才出現的錯誤,提前收斂為可重複、可追蹤的建置檢查。

常見問題

為什麼只檢查專案內的 entitlements 檔案還不夠?

該檔案只是簽章前的輸入,建置設定、描述檔及簽章程序都可能改變最終結果,應以封存 App 的實際輸出為準。

哪些權限最適合優先加入自動預檢?

建議先檢查 application-identifier、團隊識別碼、aps-environment、關聯網域與 get-task-allow。

獨享實體節點

讓下一次建置在雲端 Mac 上執行。

比較三種 Apple Silicon 設定,從五個海外節點中選擇適合目前工作流程的區域。

選擇租用方案