Engineering Practices

Reproducible iOS Archives with Two-Build Difference Analysis

Reproducible iOS Archives with Two-Build Difference Analysis

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:

  1. Content layer: The same commit, toolchain, dependency lockfiles, and build parameters must produce identical application resources and executables.
  2. 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.

ArmVMS Cloud Mac

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.

Choose a configuration and create an order