Technical Runbook

Audit the Final Info.plist in Cloud Mac CI

Audit the Final Info.plist in Cloud Mac CI

The same iOS project may archive successfully on a local machine yet pick up the wrong Bundle ID, a debug URL Scheme, or no camera permission description when it runs through a Release pipeline on a cloud Mac. The problem is usually not the Info.plist committed to the repository. It is the final result written into the App after Xcode merges build settings, generated configuration, and target properties. A reliable CI pipeline should audit the built product rather than perform text checks only against source files.

Verify the configuration that will actually ship

Modern Xcode projects may generate Info.plist automatically or obtain values from different targets, configurations, and .xcconfig files. The file referenced by INFOPLIST_FILE is only one input. Settings such as PRODUCT_BUNDLE_IDENTIFIER, MARKETING_VERSION, and CURRENT_PROJECT_VERSION also participate in the merge at build time.

Start by running a build with an explicit workspace, scheme, configuration, and output directory:

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

Disabling code signing here is appropriate only for a quick configuration audit. It does not mean signing should also be disabled for a release archive. Once the build finishes, do not guess the App path. Read the product directory and name from the build settings instead:

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"

The audit must inspect the App produced by the current job. Reusing a fixed path left behind by a previous build can cause the gate to report an incorrect result for a stale artifact.

Keep expected values under version control

Do not hard-code a large set of expected values in the CI platform's interface. A more reviewable approach is to store a small JSON file for each release environment, such as ci/plist-release.json:

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

Only rules that are safe to commit publicly should go into this file. Do not store tokens, private keys, or signing passwords in it. Version and build numbers are usually generated from pipeline parameters. The script should validate their format and confirm that they match the build inputs instead of permanently hard-coding a particular number.

Each App target, extension, and test host should have its own rules. Do not let the main App's Bundle ID rule incorrectly reject a Widget, and do not reuse a checklist containing a camera permission description for an extension that does not need camera access.

Write an audit script that can fail the build

plutil -extract works well for reading dictionaries and arrays, while PlistBuddy is convenient for simple scalar values. The following script provides a minimal framework:

#!/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

A production script should also report the key name, expected value, and actual value, but it should not print the entire plist. Permission descriptions, query Schemes, and third-party configuration may contain internal identifiers. Uploading them verbatim to the build log increases the exposure risk.

Maintain both positive and negative rules

Rules that say a value “must exist” can detect missing configuration, but they cannot detect leaked debug settings. Maintain a denylist as well. For example, Release artifacts should not contain test server markers, debug URL Schemes, file-sharing switches, or unapproved background modes.

Do not use a simple substring check as the final implementation for array fields. A more robust approach is to produce JSON with plutil -extract ... json, then compare each item using Ruby, Python, or an existing project script. The comparison should explicitly handle missing fields, incorrect types, and duplicate values.

Audit the App, its extensions, and the archive

An archive can contain the main App, a Widget, a notification service extension, and other .appex bundles. Checking only the main App can miss an extension with an incorrect identifier, version number, or permission configuration. After archiving, iterate over every 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

Use two gates: run a fast check after a normal Release build for early feedback, then inspect the archived products after archive completes and use that result to determine whether the release may proceed. The second gate cannot be omitted because archiving may use a different configuration, export parameters, or pipeline variables.

Audit target Key fields Action on failure
Main App Identifier, version, permission descriptions, URL Scheme Block archiving
App Extension Identifier prefix, version, extension type Block archiving
Final Archive Every bundle and production denylist item Block release

Avoid common false positives and preserve evidence

The most common false positives involve empty strings, Boolean types, and array ordering. For permission descriptions, checking that the key exists is not enough: trim leading and trailing whitespace and confirm that the resulting value is not empty. Read Boolean values according to their plist type rather than treating the string "false" as a Boolean. URL Schemes and background modes are normally compared as sets, so their array order should not matter.

When the script fails, retain a redacted audit report containing the scheme, configuration, SDK, App path, failed key name, and hash. Do not preserve the entire build environment or dump every environment variable into the log. If one cloud Mac runs multiple jobs, give each job its own DerivedData directory and verify before the audit that the artifact's modification time belongs to the current job.

Finally, maintain the audit script as code. Route rule changes through merge review, and prepare a test case for each of these conditions: a missing key, an incorrect type, a forbidden value, and an omitted extension. Info.plist problems will then become explicit, reproducible pipeline failures instead of issues that require guesswork during archiving or submission.

Frequently asked questions

Why is auditing the source Info.plist insufficient?

Xcode can merge generated values, build settings, and configuration-specific files. The Info.plist embedded in the built app is the value that actually ships.

Which Info.plist keys should a CI gate check first?

Start with CFBundleIdentifier, version and build numbers, permission descriptions, URL schemes, and UIBackgroundModes. Fail on missing or production-forbidden values.

Should the audit run before or after archiving?

Run it after a normal build for fast feedback, then run it again against the app inside the completed archive. Release decisions should use the archive result.

MacVPSGo Cloud Mac

Need a dedicated Apple Silicon build machine?

Rent a dedicated physical node by the day, week, month, or quarter for Xcode builds, iOS CI, remote development, and automation.

Choose a configuration and order