An iOS project that runs correctly in a local simulator can still fail at installation after being archived on a HopVM cloud Mac. A precompiled Framework may contain an x86_64 slice, an extension may be linked for the wrong platform, or a nested dynamic library may silently raise the minimum supported OS version. Instead of investigating after delivery, scan the exported .app directly and turn these binary-level facts into a pipeline gate.
Why inspect the final exported package?
Xcode project settings only describe how the project is intended to be built. The final package shows what will actually be delivered. The main executable, App Extensions, Frameworks, and .dylib files can each have their own Mach-O headers, and binary packages downloaded by dependency managers do not necessarily inherit the main project’s settings.
At a minimum, the gate should answer three questions:
- Do binaries targeting physical iOS devices contain only approved architectures?
- Does every Mach-O target iOS rather than the simulator or macOS?
- Does any nested binary require a newer minimum OS version than the main App declares?
Do not treat a successful Archive as delivery acceptance. The linker only verifies that the current target can be assembled into a product; it does not determine whether every nested file complies with the team’s release baseline.
Inspect the final package produced by xcodebuild -exportArchive, rather than scanning only DerivedData. The export process reorganizes Frameworks, extensions, and signed content, making the exported package the closest representation of what will actually be delivered.
Freeze inputs and acceptance baselines
Start by fixing the archive, export, and report directories so that the script cannot scan leftover files from a previous job. Working-directory isolation is especially important when multiple pipelines use the same cloud Mac sequentially.
set -euo pipefail
ARCHIVE_PATH="$PWD/output/App.xcarchive"
EXPORT_PATH="$PWD/output/export"
REPORT_PATH="$PWD/output/macho-report.tsv"
rm -rf "$EXPORT_PATH"
mkdir -p "$EXPORT_PATH"
xcodebuild -exportArchive \
-archivePath "$ARCHIVE_PATH" \
-exportPath "$EXPORT_PATH" \
-exportOptionsPlist "$PWD/ci/ExportOptions.plist"
APP_PATH="$(find "$EXPORT_PATH" -maxdepth 2 -type d -name '*.app' -print -quit)"
test -n "$APP_PATH"
Product baselines should live in the repository instead of being scattered across the CI configuration interface. For example, store the allowed architectures and minimum version in ci/macho-policy.env:
EXPECTED_ARCHS="arm64"
EXPECTED_PLATFORM="IOS"
DECLARED_MIN_IOS="17.0"
DECLARED_MIN_IOS must match the product’s actual support policy. The version shown here is only a scripting example and must not be copied directly as a product decision.
Recursively enumerate every Mach-O file
Searching by file extension alone is insufficient because a Framework’s primary binary usually has no extension. A more reliable approach is to traverse all files and use file to determine whether each one is a Mach-O binary.
: > "$REPORT_PATH"
failure=0
while IFS= read -r -d '' candidate; do
if ! file -b "$candidate" | grep -q 'Mach-O'; then
continue
fi
archs="$(lipo -archs "$candidate" 2>/dev/null || true)"
build="$(vtool -show-build "$candidate" 2>/dev/null || true)"
platform="$(awk '/platform/{print $2; exit}' <<<"$build")"
minos="$(awk '/minos/{print $2; exit}' <<<"$build")"
printf '%s %s %s %s
' \
"${candidate#"$APP_PATH"/}" "$archs" "$platform" "$minos" \
>> "$REPORT_PATH"
if [[ "$archs" != "$EXPECTED_ARCHS" ]]; then
printf 'architecture mismatch: %s (%s)
' "$candidate" "$archs" >&2
failure=1
fi
if [[ "$platform" != "$EXPECTED_PLATFORM" ]]; then
printf 'platform mismatch: %s (%s)
' "$candidate" "$platform" >&2
failure=1
fi
done < <(find "$APP_PATH" -type f -print0)
exit "$failure"
The report uses tab-separated fields, so it can be retained as a build artifact and easily converted into a table later. Failure messages must include both the file path and the actual value. Reporting only “architecture check failed” forces whoever investigates the issue to rerun the entire job.
Do not modify packages inside the gate
Running lipo -remove immediately after finding x86_64 may seem convenient, but it modifies already signed content and conceals a problem in the dependency production process. Instead, determine whether the file came from a source build, a binary dependency, or a copy script. Then fix the upstream source and run Archive again.
Compare minimum OS versions
First, read the main App’s product declaration:
PLIST="$APP_PATH/Info.plist"
APP_MIN_IOS="$(/usr/libexec/PlistBuddy \
-c 'Print :MinimumOSVersion' "$PLIST")"
printf 'declared minimum iOS: %s
' "$APP_MIN_IOS"
Next, read minos from the LC_BUILD_VERSION of every Mach-O file. Use semantic version comparison rather than ordinary string comparison; otherwise, 17.10 may be incorrectly ordered before 17.9.
| Target | Source | Failure condition |
|---|---|---|
| Main App declaration | MinimumOSVersion in Info.plist |
Does not match the repository baseline |
| Main executable | minos in LC_BUILD_VERSION |
Higher than the product declaration |
| Frameworks and dynamic libraries | Their respective LC_BUILD_VERSION values |
Higher than the product declaration |
| App Extension | Extension Info.plist and Mach-O | Declared or actual value exceeds the baseline |
The system’s built-in version sorting can perform the comparison:
version_gt() {
[[ "$1" != "$2" ]] &&
[[ "$(printf '%s
%s
' "$1" "$2" | sort -V | tail -n 1)" == "$1" ]]
}
if version_gt "$minos" "$APP_MIN_IOS"; then
printf 'minimum iOS mismatch: %s requires %s
' \
"$candidate" "$minos" >&2
failure=1
fi
If vtool does not return a platform or version, do not silently skip the file. First use otool -l to save the complete load commands, then determine whether the file is an older-format binary, a malformed file, or a script misclassification.
Integrate the gate into CI and handle common false positives
Run the check after Archive and export but before upload, and always upload macho-report.tsv. Even when the job fails, preserve the report through the CI system’s failure-artifact mechanism.
Most false positives fall into three categories:
- The script scans symbol files or the archive directory instead of the final
.app. - Platform names use different capitalization across tool outputs, but the script compares them with direct string equality.
- A policy intended for macOS helper tools is applied to an iOS App, incorrectly rejecting valid universal binaries.
Policies should therefore be maintained by product type. Do not use one ruleset for iOS products, simulator test packages, and macOS tools. If the pipeline produces several artifact types, run the check separately for each export directory and write the artifact type into the first column of the report.
Finally, verify the signing structure:
codesign --verify --deep --strict --verbose=2 "$APP_PATH"
This does not replace Mach-O inspection, but it can detect later problems such as binaries accidentally modified by scripts or invalid nested signatures. The complete sequence should be export, scan, version comparison, signature verification, and report retention—only then should delivery be allowed. With this process, every failure points to a specific file, field, and remediation path instead of leaving the team to guess after installation fails.
Frequently asked questions
Why is checking only the main app binary insufficient?
Frameworks, app extensions, and dynamic libraries contain separate Mach-O files. One nested binary with the wrong platform, architecture, or minimum iOS version can break installation or launch.
What should I do if x86_64 appears in an iOS artifact?
Trace the file back to its dependency build or archive step and fix that source. Do not rely on stripping the slice with lipo immediately before delivery.
Which value defines the minimum supported iOS version?
Check both MinimumOSVersion in Info.plist and minos in LC_BUILD_VERSION. Every nested binary should require a version no higher than the app’s declared minimum.
Reproduce your workflow with a cloud Mac you can start and stop per project
Choose from three Apple Silicon configurations and rent by the day, week, month, or quarter. Each device is a dedicated physical machine, not a virtual machine; actual availability is confirmed in real time by the control panel.