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:
- The relative paths of every
.appand.appexinside the.xcarchive; - The version numbers, minimum OS version, and Bundle Identifier of each bundle;
- The complete output from
codesign --verify --strict --verbose=2; - The Scheme, Configuration, and fully expanded build settings used for the job;
- 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.