The same commit archives successfully on a developer’s machine and also reports success on a cloud Mac or in a nightly pipeline, yet the two artifacts have different hashes. Do not immediately blame the Xcode cache. Signing data, compression timestamps, generated source code, script output order, and absolute paths can all become part of the artifact. A more effective approach is to create two fully isolated build directories on the same ArmVMS node, run two consecutive unsigned archives, and compare the differences from the outside in.
Define the reproducibility boundary first
Achieving byte-for-byte identical signed IPA files is difficult because signature data, provisioning profiles, and packaging metadata can change. Project validation should therefore be split into two layers:
- Content layer: The same commit, toolchain, dependency lockfiles, and build parameters must produce identical application resources and executables.
- Delivery layer: The export method, signing configuration, entitlement declarations, and packaged files must meet release requirements, without requiring the entire IPA to have the same hash.
First prove that the unsigned application content is stable, then inspect signing and export. Comparing only the overall hashes of two IPA files can confirm that they differ, but it cannot reveal where the difference originated.
Before starting, record the commit hash, Xcode path, SDK, Scheme, Configuration, and lockfiles such as Package.resolved and Podfile.lock. The working tree must be clean, and uncommitted generated files must also be included in the investigation.
Run two archives in isolated directories
The two builds must not share DerivedData. Otherwise, the second build may consume intermediate artifacts left by the first. The following script fixes the locale and time zone, assigns a separate directory to each archive, and disables code signing:
#!/bin/zsh
set -euo pipefail
export LC_ALL=C
export LANG=C
export TZ=UTC
ROOT="$PWD"
SCHEME="ExampleApp"
for RUN in A B; do
rm -rf "$ROOT/.repro/$RUN"
mkdir -p "$ROOT/.repro/$RUN"
xcodebuild archive \
-workspace "$ROOT/ExampleApp.xcworkspace" \
-scheme "$SCHEME" \
-configuration Release \
-destination "generic/platform=iOS" \
-derivedDataPath "$ROOT/.repro/$RUN/DerivedData" \
-archivePath "$ROOT/.repro/$RUN/App.xcarchive" \
CODE_SIGNING_ALLOWED=NO \
COMPILER_INDEX_STORE_ENABLE=NO
done
If the project must read environment variables during a script phase, pass stable values explicitly instead of inheriting temporary settings from an interactive Shell. Both archives should also use the same DEVELOPER_DIR so that the default Xcode path cannot switch between runs.
Narrow the differences with a file inventory
Do not begin with a hexadecimal comparison of the binaries. First verify that both .app bundles contain the same paths:
APP_A=".repro/A/App.xcarchive/Products/Applications/ExampleApp.app"
APP_B=".repro/B/App.xcarchive/Products/Applications/ExampleApp.app"
find "$APP_A" -type f | sed "s#^$APP_A/##" | LC_ALL=C sort > /tmp/files-a.txt
find "$APP_B" -type f | sed "s#^$APP_B/##" | LC_ALL=C sort > /tmp/files-b.txt
diff -u /tmp/files-a.txt /tmp/files-b.txt
A different file count usually indicates unstable inputs in a Run Script phase, resource generator, or copy rule. Once the paths match, calculate hashes file by file and exclude the signature directory, which is known to change:
(
cd "$APP_A"
find . -type f ! -path "./_CodeSignature/*" -print0 |
sort -z | xargs -0 shasum -a 256
) > /tmp/hash-a.txt
(
cd "$APP_B"
find . -type f ! -path "./_CodeSignature/*" -print0 |
sort -z | xargs -0 shasum -a 256
) > /tmp/hash-b.txt
diff -u /tmp/hash-a.txt /tmp/hash-b.txt
| First difference | Check first |
|---|---|
| Info.plist | Automatically inserted dates, hostnames, paths, or pipeline numbers |
| Assets.car | Resource inputs, toolchain version, and resource directory order |
| Executable | Compiler flags, generated source code, object files, and link order |
| Framework | Dependency lockfiles, binary dependency versions, and embedding scripts |
| Text or JSON | Unsorted collections, random identifiers, and locale-dependent formatting |
Inspect configuration and Mach-O in detail
Property lists can differ at the byte level solely because their keys are ordered differently. Copy the files, normalize them with plutil -convert xml1, and then perform a text comparison. Do not modify the archived originals directly, or later evidence will be contaminated.
If the executables differ, inspect their UUIDs first:
dwarfdump --uuid "$APP_A/ExampleApp"
dwarfdump --uuid "$APP_B/ExampleApp"
xcodebuild \
-workspace ExampleApp.xcworkspace \
-scheme ExampleApp \
-configuration Release \
-showBuildSettings > /tmp/build-settings.txt
Different UUIDs only show that the linker outputs differ; they do not automatically prove that the application logic changed. Continue by comparing OTHER_SWIFT_FLAGS, OTHER_LDFLAGS, search paths, deployment targets, and the actual tool paths in use. Generated code that embeds the current date, a temporary directory, or the iteration result of an unordered dictionary will also make every subsequent compilation step drift.
Pay particular attention to #filePath, debug mappings, and absolute workspace paths in script output. When the CI working directory changes, using a consistent checkout path or an appropriate debug prefix map can reduce path-related differences. Do not remove information required for debugging merely to make hashes match.
Turn the check into a maintainable build gate
The gate should not simply require that “the hashes of two IPA files must match.” A more robust strategy is to preserve a difference report and evaluate changes by layer.
Differences that must block the build
If the same commit produces missing files, changed resource content, changed entitlement declarations, a different embedded Framework version, or an unstable main executable with identical inputs, block the build immediately and retain both archives.
Differences that can be reviewed separately
The signature directory, export logs, and explicitly identified packaging timestamps are delivery-layer differences and can be reviewed separately. However, the paths allowed to change must be narrowly defined. Broad rules such as “ignore all plist files” are not acceptable.
After fixing the issue, rerun the verification in two newly created empty directories rather than reusing old DerivedData. Large projects do not need to perform two archives for every commit. The check can be triggered when the Xcode version, dependency lockfiles, generators, Run Script phases, or release configuration changes. This produces more than a fragile hash rule: it creates a chain of build evidence that identifies the layer where a difference occurred.
Frequently asked questions
Why not compare only the hashes of two signed IPA files?
Signatures, provisioning data, and packaging timestamps can differ by design. Compare unsigned application contents first, then validate export and signing separately.
Do different Mach-O UUIDs prove that source code changed?
No. Object ordering, generated sources, compiler arguments, linker inputs, and tool paths can also change the linked binary.
Should reproducibility checks run on every commit?
Small projects may run them regularly. Large projects can target changes to toolchains, lockfiles, build scripts, or release configuration.
Choose a dedicated Apple Silicon physical node for your next build
Rent ArmVMS M4 and ArmVMS M4 Pro by the day, week, month, or quarter. Review the configuration, node, and add-ons before creating your order.