An archive containing both an iOS app and a watchOS app can still fail delivery even after the main app builds successfully: the watch app may not be embedded, its build number may be one version behind, the extension may point to an old Bundle ID, or a nested component may not be signed correctly. These issues should not remain undiscovered until the export or submission stage. A more reliable approach is to inspect the final package structure immediately after the cloud Mac generates each xcarchive and use the result as a pipeline gate.
Why Validate the Final Archive
Project files describe the intended output; the xcarchive is the actual deliverable. Different configuration files, build scripts, and environment variables can override version numbers or product identifiers. Reading project settings alone cannot prove that the watchOS app appears in the main app’s Watch directory, nor can it prove that the extension is correctly associated with its containing app.
Use the Release archive as the validation target rather than an intermediate DerivedData directory. First, generate the artifact with the same workspace, Scheme, and configuration used by the production pipeline:
set -euo pipefail
ROOT="$PWD"
OUT="$ROOT/out"
ARCHIVE="$OUT/Client.xcarchive"
rm -rf "$ARCHIVE"
mkdir -p "$OUT"
xcodebuild \
-workspace Client.xcworkspace \
-scheme Client \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath "$ARCHIVE" \
clean archive
clean archive is appropriate for baseline validation. Routine pipelines can adjust it according to their caching strategy, but the validation script must always read the archive generated by the current job and must never fall back to a “most recently generated” directory.
The gate should answer “What is inside the package being prepared for delivery?” rather than “What should the project theoretically produce?”
Build a Component Inventory from the Bundle Structure
The typical path is Products/Applications/*.app. Its Watch directory contains the watchOS app, and the watch app’s PlugIns directory contains the extension. Do not hard-code product names in the script; fixed paths can easily break after a Target is renamed.
IOS_APP=$(find "$ARCHIVE/Products/Applications" \
-maxdepth 1 -type d -name '*.app' -print -quit)
test -n "${IOS_APP:-}" || {
printf '%s
' "iOS app not found"
exit 1
}
WATCH_APP=$(find "$IOS_APP/Watch" \
-maxdepth 1 -type d -name '*.app' -print -quit)
test -n "${WATCH_APP:-}" || {
printf '%s
' "watchOS app not found"
exit 1
}
WATCH_EXTENSION=$(find "$WATCH_APP/PlugIns" \
-maxdepth 1 -type d -name '*.appex' -print -quit)
printf 'ios=%s
watch=%s
extension=%s
' \
"$IOS_APP" "$WATCH_APP" "${WATCH_EXTENSION:-embedded}"
Some newer projects do not have a separate .appex layer, so the absence of an extension is not automatically an error. The gate should follow the product structure declared by the repository: legacy layouts require all three component layers, while newer single-target layouts require only the iOS app and watchOS app. Store a short manifest of the expected structure in the repository instead of trying to infer the project type from its directories.
Validate Version and Identifier Relationships
Run plutil -lint on each Info.plist first, then read CFBundleIdentifier, CFBundleShortVersionString, and CFBundleVersion. For a single release, the iOS and watchOS marketing versions and build numbers should generally match exactly. If the team uses independent build numbers, the permitted rules should still be defined explicitly in the script.
| Check | Recommended rule | Risk if validation fails |
|---|---|---|
| Marketing version | Identical for iOS and watchOS | Inconsistent App Store version relationship |
| Build number | Identical within the same release job | Watch app is treated as an older build |
| Companion identifier | Points to the main iOS app ID | Companion relationship cannot be established after installation |
| Extension ownership | Points to the current watchOS app ID | Extension is paired with the wrong containing app |
read_plist() {
/usr/libexec/PlistBuddy -c "Print :$2" "$1"
}
IOS_PLIST="$IOS_APP/Info.plist"
WATCH_PLIST="$WATCH_APP/Info.plist"
plutil -lint "$IOS_PLIST" "$WATCH_PLIST"
IOS_ID=$(read_plist "$IOS_PLIST" CFBundleIdentifier)
WATCH_ID=$(read_plist "$WATCH_PLIST" CFBundleIdentifier)
IOS_VERSION=$(read_plist "$IOS_PLIST" CFBundleShortVersionString)
WATCH_VERSION=$(read_plist "$WATCH_PLIST" CFBundleShortVersionString)
IOS_BUILD=$(read_plist "$IOS_PLIST" CFBundleVersion)
WATCH_BUILD=$(read_plist "$WATCH_PLIST" CFBundleVersion)
COMPANION_ID=$(read_plist "$WATCH_PLIST" WKCompanionAppBundleIdentifier)
test "$IOS_VERSION" = "$WATCH_VERSION"
test "$IOS_BUILD" = "$WATCH_BUILD"
test "$COMPANION_ID" = "$IOS_ID"
Do not replace exact comparison with a rule stating that the watchOS Bundle ID must begin with the iOS Bundle ID. A team may use explicit identifiers, and a similar prefix does not prove that the association is correct. Read the expected value from the release configuration and validate the complete contents of WKCompanionAppBundleIdentifier.
Support Layouts That Include an Extension
If the archive contains an .appex, also read its WKAppBundleIdentifier and require that value to equal the current watchOS app’s Bundle ID. Record the extension’s own version and build number as well, preventing an individual Target from being omitted from the shared versioning script.
Verify Signing and Executable Architectures
A successfully signed outer app does not guarantee that every nested component can be verified. Instead of applying only a recursive check to the main app, enumerate the actual components so the logs clearly identify the item that failed:
verify_component() {
local item="$1"
codesign --verify --strict --verbose=2 "$item"
codesign -d --entitlements :- "$item" >/dev/null
}
verify_component "$IOS_APP"
verify_component "$WATCH_APP"
if test -n "${WATCH_EXTENSION:-}"; then
verify_component "$WATCH_EXTENSION"
fi
Next, use each component’s CFBundleExecutable to locate its binary and record the architectures with file or lipo -archs. The gate does not need to hard-code one architecture set permanently, because toolchains and deployment targets change. A more reliable rule is to reject empty executables and unexpected simulator artifacts while saving the current architecture inventory for review.
Also check nested components for symlinks that escape the bundle, duplicate Bundle IDs, and undeclared extra extensions. These issues often result from copy scripts that use overly broad wildcards.
Turn Validation into a Reliable CI Gate
The script’s exit code blocks the pipeline; the report explains why. Every job should record component-relative paths, Bundle IDs, versions, build numbers, architectures, signature verification status, and the archive checksum. Do not collect certificate private keys, the complete set of environment variables, or full user-directory paths in the report.
Split the process into four steps:
- Generate a unique archive directory and prohibit reuse of artifacts from the previous job.
- Discover components and compare them with the expected structure stored in the repository.
- Validate metadata, relationship fields, signatures, and executable architectures.
- Write a text or JSON report, then run the export job.
When first enabling the gate, consider running it several times in report-only mode without blocking the pipeline to confirm that both legacy and current project layouts are covered. Once the rules are stable, treat missing components, version mismatches, and signature failures as hard errors. If multiple archives are built concurrently on the same cloud Mac, assign each job its own archivePath and report directory so one job cannot read another job’s results.
The goal is not to add another procedural check. It is to let the pipeline prove that the main app, watch app, and extension all come from the same release context, have clearly defined relationships, and that the final archive is ready to proceed to the export stage.
Frequently asked questions
Can project-setting checks replace xcarchive validation?
No. Only the final archive reflects configuration overrides, build scripts, actual component embedding, and the signatures produced by the pipeline.
Should the iOS and watchOS versions match?
For one release, keep the marketing version and build number aligned. If they are managed separately, encode the permitted relationship as an explicit CI rule.
Run your Mac mini workflows continuously in the cloud
Choose RunAMac M4, a rental period, and a deployment region to run development, build, or experimental workloads on a dedicated physical machine.