Engineering article

Validate iOS App Extension Embedding on a Cloud Mac

Validate iOS App Extension Embedding on a Cloud Mac

An iOS project that includes a notification service, share extension, or widget extension can fail in a familiar way: the main app builds and archives successfully, but the extension is reported as invalid during export, installation, or release validation. The root cause is usually not the source code. More often, the version numbers, deployment targets, embedding locations, or signatures of the .app and .appex have drifted apart. Running validation immediately after the archive job on a cloud Mac provides a clear result before the artifact leaves the build node.

Define the archive acceptance criteria first

An App Extension is not a standalone deliverable. It must reside in the main app’s PlugIns directory and maintain a verifiable embedding relationship with the host app. At a minimum, CI should check the following:

Check Main app App Extension Recommended rule
CFBundleVersion Required Required Must match exactly
CFBundleShortVersionString Required Required Must match exactly
MinimumOSVersion Required Required Keep identical when standardized across the team
NSExtension Not required Required Dictionary must exist and be readable
Code signature Valid Valid Must pass strict verification
Embedding location Applications PlugIns/*.appex Reject stray copies

The minimum OS version does not have to match in every project. However, unless the team intentionally maintains different deployment targets, requiring an exact match makes configuration drift easier to detect. When differences are intentional, define the permitted range in the repository instead of silently ignoring it in the script.

A successful build only proves that each Target can produce an artifact. A valid archive must also prove that those artifacts form a correctly assembled, distributable application.

Create a single xcarchive for validation

The gate should inspect the archive, not an intermediate DerivedData directory. DerivedData may contain a stale .appex from a previous build and does not represent the final embedding structure. Delete the fixed archive path first, then perform a fresh Release archive:

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

Do not let different branches share the same archive directory. Parallel jobs can create isolated paths using a commit hash or CI job number, but only one specific .xcarchive should be passed to the subsequent script.

Inspect the embedded output first

After archiving, the main app is normally under Products/Applications, while extensions are stored in the app bundle’s PlugIns directory. Use find to inspect the actual output first:

find build/App.xcarchive/Products/Applications \
  \( -name '*.app' -o -name '*.appex' \) \
  -print

If the project defines an extension Target but no .appex is found, first inspect the main app Target’s Embed App Extensions build phase and confirm that the current Scheme builds the extension. Do not copy an extension from another directory to “complete” the archive.

Validate metadata and signatures with a script

The following Bash script uses tools included with macOS. It locates the main app, reads each extension’s Info.plist, compares the version and minimum OS values, and verifies each signature separately:

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

Save the script as ci/validate-extensions.sh, run chmod +x, and invoke it after the archive step. If the repository produces one Scheme with extensions and another without them, pass the expected number of extensions as a parameter instead of treating zero extensions as universally valid.

Consolidate version values in build settings

The most common source of drift is maintaining multiple Info.plist files manually. The main app’s Build Number may be updated while an extension keeps the old value, and source compilation will not fail because of it. A more reliable approach is to make every Target use the same set of build variables:

MARKETING_VERSION = 4.8.0
CURRENT_PROJECT_VERSION = 8204
IPHONEOS_DEPLOYMENT_TARGET = 17.0

Set the corresponding plist values to $(MARKETING_VERSION), $(CURRENT_PROJECT_VERSION), and $(IPHONEOS_DEPLOYMENT_TARGET). If configuration is managed through .xcconfig, have the main app and extensions include the same base file, then use a small number of Target-specific files to override only the keys that genuinely need to differ.

Do not hide problems by re-signing after archiving

codesign --deep is useful for verifying an entire bundle, but it should not be treated as a general-purpose repair command. Recursive re-signing after archiving can alter the original embedding relationship and cause the CI artifact to diverge from the project configuration. When signature verification fails, correct the Target’s signing settings, Build Phases, and export configuration, then generate a complete new archive.

Preserve evidence in order after a failure

When the gate fails, do not clean the workspace immediately. Preserve the following information first:

  1. The relative paths of every .app and .appex inside the .xcarchive;
  2. The version numbers, minimum OS version, and Bundle Identifier of each bundle;
  3. The complete output from codesign --verify --strict --verbose=2;
  4. The Scheme, Configuration, and fully expanded build settings used for the job;
  5. The commit identifier and Xcode version used by the archive job.

Use the following command to export build settings so you can compare the variables actually received by the main app and extensions:

xcodebuild \
  -workspace App.xcworkspace \
  -scheme App \
  -configuration Release \
  -showBuildSettings > build/build-settings.txt

The final gate should achieve two things: failure messages must identify the specific .appex and mismatched key, and fixes must be made in the project configuration rather than after artifact generation. This keeps the acceptance criteria in the repository regardless of which cloud Mac node runs the job, while ensuring that every archive can be validated again.

Frequently asked questions

Why can an App Extension fail after Xcode archives successfully?

Archiving primarily proves that targets can build. Export and installation also validate versions, deployment targets, extension metadata, embedding relationships, and signatures.

Must the app and its extensions use the same build number?

CFBundleVersion should match. Generating CFBundleShortVersionString from the same central build settings also makes releases and artifacts easier to trace.

Should CI re-sign a broken extension after archiving?

No. Fix the build settings, embed phase, or signing configuration, then create a new archive from a clean workspace instead of masking configuration drift.

Dedicated physical node

Run development and build tasks on a dedicated cloud Mac

Choose the model, node region, and rental period for each task. Every instance runs on a dedicated physical machine in a non-virtualized environment.

Choose a Cloud Mac plan