技術執行單

雲端 Mac CI 的 Info.plist 最終產物稽核

雲端 Mac CI 的 Info.plist 最終產物稽核

同一個 iOS 專案在本機封存時一切正常,換到雲端 Mac 的 Release 流水線後,卻可能帶入錯誤的 Bundle ID、除錯用 URL Scheme,或缺少相機權限說明。問題通常不在儲存庫中的 Info.plist,而在 Xcode 合併建置設定、產生的組態與 target 屬性後,實際寫入 App 的最終結果。可靠的 CI 應稽核建置產物,而不是只對原始檔進行文字檢查。

先確認實際交付的組態

現代 Xcode 專案可能啟用自動產生 Info.plist,也可能由不同的 target、configuration 和 .xcconfig 提供值。INFOPLIST_FILE 指向的檔案只是輸入來源之一,PRODUCT_BUNDLE_IDENTIFIERMARKETING_VERSIONCURRENT_PROJECT_VERSION 等設定都會在建置時參與合併。

先指定明確的 workspace、scheme、configuration 與輸出目錄來執行建置:

set -euo pipefail

ROOT="$(pwd)"
DERIVED_DATA="$ROOT/.ci/DerivedData"

xcodebuild \
  -workspace Example.xcworkspace \
  -scheme Example \
  -configuration Release \
  -sdk iphoneos \
  -derivedDataPath "$DERIVED_DATA" \
  CODE_SIGNING_ALLOWED=NO \
  build

此處停用簽署只適合快速稽核組態,不代表正式發布封存時也應停用簽署。建置完成後,不要猜測 App 路徑;可以從建置設定中讀取產品目錄與名稱:

SETTINGS="$(mktemp)"
xcodebuild \
  -workspace Example.xcworkspace \
  -scheme Example \
  -configuration Release \
  -sdk iphoneos \
  -derivedDataPath "$DERIVED_DATA" \
  -showBuildSettings > "$SETTINGS"

BUILD_DIR="$(awk -F ' = ' '/ TARGET_BUILD_DIR = /{print $2; exit}' "$SETTINGS")"
WRAPPER_NAME="$(awk -F ' = ' '/ WRAPPER_NAME = /{print $2; exit}' "$SETTINGS")"
APP_PATH="$BUILD_DIR/$WRAPPER_NAME"
PLIST_PATH="$APP_PATH/Info.plist"

test -d "$APP_PATH"
test -f "$PLIST_PATH"
plutil -lint "$PLIST_PATH"

稽核對象應是本次工作剛產生的 App。若重複使用上一次建置留下的固定路徑,門禁可能會針對舊產物得出錯誤結論。

將預期值納入版本控制

不要把大量預期值硬編碼在 CI 平台介面中。更容易審查的做法,是為每個發布環境保存一份小型 JSON,例如 ci/plist-release.json

{
  "bundleIdentifier": "com.example.product",
  "urlSchemes": ["example"],
  "requiredUsageKeys": [
    "NSCameraUsageDescription",
    "NSPhotoLibraryUsageDescription"
  ],
  "allowedBackgroundModes": ["remote-notification"]
}

這裡只記錄適合公開提交至儲存庫的規則,不要存放權杖、私鑰或簽署密碼。版本號與建置編號通常由流水線參數產生,腳本應檢查格式,以及這些值是否與建置輸入一致,而不是長期寫死某個數字。

不同的 App target、擴充功能與測試宿主應各自使用專屬規則。不要讓主 App 的 Bundle ID 規則誤判 Widget,也不要因為擴充功能不需要相機權限,就重複使用包含相機說明的清單。

撰寫可觸發失敗的稽核腳本

plutil -extract 適合讀取字典與陣列,PlistBuddy 適合讀取簡單純量。以下腳本展示最小骨架:

#!/bin/bash
set -euo pipefail

PLIST_PATH="${1:?missing Info.plist path}"
EXPECTED_BUNDLE_ID="${EXPECTED_BUNDLE_ID:?missing bundle id}"
EXPECTED_VERSION="${EXPECTED_VERSION:?missing version}"
EXPECTED_BUILD="${EXPECTED_BUILD:?missing build number}"

read_key() {
  /usr/libexec/PlistBuddy -c "Print :$1" "$PLIST_PATH" 2>/dev/null
}

require_key() {
  local key="$1"
  local value
  value="$(read_key "$key" || true)"
  if [[ -z "$value" ]]; then
    printf 'Missing required key: %s
' "$key" >&2
    exit 1
  fi
}

[[ "$(read_key CFBundleIdentifier)" == "$EXPECTED_BUNDLE_ID" ]]
[[ "$(read_key CFBundleShortVersionString)" == "$EXPECTED_VERSION" ]]
[[ "$(read_key CFBundleVersion)" == "$EXPECTED_BUILD" ]]

require_key NSCameraUsageDescription
require_key NSPhotoLibraryUsageDescription

SCHEMES_JSON="$(plutil -extract CFBundleURLTypes json -o - "$PLIST_PATH")"
printf '%s' "$SCHEMES_JSON" | grep -q '"example"'

MODES="$(plutil -extract UIBackgroundModes raw -o - "$PLIST_PATH" 2>/dev/null || true)"
if [[ "$MODES" == *"audio"* ]]; then
  printf 'Unexpected background mode: audio
' >&2
  exit 1
fi

正式環境使用的腳本還應輸出鍵名、預期值與實際值,但不要列印完整的 plist。權限文案、查詢 Scheme 與第三方組態中可能包含內部識別資訊,將原始內容直接上傳至日誌會擴大暴露範圍。

正向規則與反向規則必須並存

「必須存在」只能找出遺漏的組態,無法發現除錯項目外洩。建議同時維護禁止清單,例如禁止 Release 產物包含測試伺服器標記、除錯 URL Scheme、檔案共享開關或未核准的背景模式。

對於陣列欄位,不要將簡單的字串包含判斷作為最終實作。更穩妥的方式是透過 plutil -extract ... json 輸出 JSON,再交由 Ruby、Python 或專案現有腳本逐項比對,並明確處理欄位不存在、型別錯誤與重複值這三種情況。

同時檢查 App、擴充功能與封存產物

一份封存可能同時包含主 App、Widget、通知服務擴充功能與其他 .appex。只檢查主 App,可能會遺漏擴充功能的識別碼、版本號或權限組態。封存完成後,可以走訪所有 bundle:

find "$ARCHIVE_PATH/Products/Applications" \
  \( -name "*.app" -o -name "*.appex" \) -print0 |
while IFS= read -r -d '' bundle; do
  plist="$bundle/Info.plist"
  plutil -lint "$plist"
  printf 'Auditing %s
' "$bundle"
done

建議設置兩道門禁:一般 Release build 完成後先執行快速檢查,以便及早取得回饋;archive 完成後,再檢查封存中的產物,作為是否發布的判定依據。第二道門禁不能省略,因為封存動作可能使用不同的 configuration、匯出參數或流水線變數。

檢查對象 重點欄位 失敗處理
主 App 識別碼、版本、權限文案、URL Scheme 阻擋封存
App Extension 識別碼前綴、版本、擴充功能類型 阻擋封存
最終 Archive 所有 bundle 與正式環境禁用項目 阻擋發布

處理常見誤判並保留證據

最常見的誤判來自空字串、布林值型別與陣列順序。權限說明不能只判斷鍵是否存在,還必須去除首尾空白並確認內容非空;布林值應按照 plist 型別讀取,不要將字串 "false" 視為布林值;URL Scheme 與背景模式通常只需比較集合,不應依賴陣列順序。

腳本失敗時,保留一份經過脫敏處理的稽核報告,內容包括 scheme、configuration、SDK、App 路徑、失敗鍵名與雜湊值。不要保存完整的建置環境,也不要將所有環境變數寫入日誌。若同一台雲端 Mac 執行多個工作,每個工作都應使用獨立的 DerivedData,並在稽核前確認產物的修改時間屬於目前工作。

最後,將稽核腳本視為程式碼維護:規則變更必須經過合併審查,並為「缺少鍵、型別錯誤、禁止值、遺漏擴充功能」各準備一個測試案例。如此一來,Info.plist 問題就會在流水線中成為明確且可重現的失敗,而不是等到封存或提交階段才靠人工猜測。

常見問題

為什麼不能只稽核儲存庫中的 Info.plist?

Xcode 會合併建置設定、產生值與不同組態檔案。實際隨 App 交付的是產物內的 Info.plist,因此它才是最終依據。

哪些欄位最適合先納入 CI 門禁?

先檢查 CFBundleIdentifier、版本、建置號碼、權限說明、URL Scheme 與 UIBackgroundModes,並阻擋缺值及正式版禁用值。

應該在 archive 前還是完成後執行?

一般 build 後執行可快速回饋;發布流程還要在 archive 完成後再次稽核歸檔內的 App,並以該結果為準。

MacVPSGo 雲端 Mac

需要一台獨享 Apple Silicon 建置機?

按日、週、月或季租用獨享實體節點,用於 Xcode 建置、iOS CI、遠端開發與自動化工作。

選擇配置並訂購