Engineering article

Audit iOS Dynamic Library Dependencies and Exported Symbols in Cloud Mac CI

Audit iOS Dynamic Library Dependencies and Exported Symbols in Cloud Mac CI

An iOS project may compile successfully on a development branch, yet its Archive can quietly pick up dynamic frameworks, absolute links to directories on a developer machine, or global symbols that were intended for internal use only. Reviewing project files rarely captures all of these changes because the actual deliverable is the linked Mach-O binary. A more reliable approach is to have cloud Mac CI inspect the archived artifacts directly and turn dependency and symbol changes into reviewable diffs.

Define the changes to block first

This type of gate should not decide whether a particular library is “good” or “bad.” Instead, it should answer three verifiable questions:

  1. What do the main executable and embedded frameworks actually depend on?
  2. Do bundled libraries referenced through @rpath really exist inside the app bundle?
  3. Are newly added global symbols part of the intended interface?

A baseline is not a permanent allowlist. It is a manually approved snapshot of an artifact. The baseline can be updated after a toolchain upgrade, dependency update, or interface change, but that update must be reviewed together with the code changes.

Run these checks after creating a Release Archive. Debug builds may contain diagnostic libraries and produce different optimization results, so they do not represent the final delivery boundary. During the initial rollout, generate reports without blocking builds. Once the team has reviewed and approved the baseline, make subsequent differences fail the pipeline.

Collect binaries from the Archive

Create a clean, standalone archive first so that historical artifacts from a developer machine cannot be reused:

rm -rf out/App.xcarchive
xcodebuild \
  -workspace App.xcworkspace \
  -scheme App \
  -configuration Release \
  -destination 'generic/platform=iOS' \
  -archivePath "$PWD/out/App.xcarchive" \
  clean archive

If signing is handled by a separate downstream job, set CODE_SIGNING_ALLOWED=NO when appropriate for the project. Do not add this override mechanically to every project. For projects that use specific capabilities or custom build phases, first verify that an unsigned archive is complete.

The main app is typically located under Products/Applications in the archive. At a minimum, inspect the app’s main executable and the executable in every framework under the Frameworks directory. Extension targets should also be included separately because each has its own dependency boundary.

Object Primary checks Common issue
Main app System libraries, bundled frameworks, global symbols Non-system absolute path
Dynamic framework Transitive dependencies, install name, public symbols Dependency not shipped in the bundle
App Extension Independent dependency and symbol baseline Incorrectly reusing assumptions from the main app

Build dependency and symbol inventories

The following script reads the main executable and embedded frameworks, then writes separately sorted inventories of their dependencies and global symbols. Sorting prevents changes in tool output order from creating meaningless diffs.

set -euo pipefail

archive="${1:?archive path required}"
app=$(find "$archive/Products/Applications" -maxdepth 1 -name '*.app' -print -quit)
test -n "$app"

mkdir -p audit/current
main=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$app/Info.plist")
printf '%s
' "$app/$main" > audit/current/binaries.txt

if test -d "$app/Frameworks"; then
  while IFS= read -r framework; do
    executable=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$framework/Info.plist")
    printf '%s
' "$framework/$executable"
  done < <(find "$app/Frameworks" -maxdepth 1 -name '*.framework' -type d | sort)
fi >> audit/current/binaries.txt

: > audit/current/dependencies.txt
: > audit/current/symbols.txt

while IFS= read -r binary; do
  name=${binary#"$app/"}
  otool -L "$binary" |
    sed '1d' |
    awk -v n="$name" '{$1=$1; print n "	" $1}' \
    >> audit/current/dependencies.txt

  xcrun nm -gU "$binary" |
    awk -v n="$name" 'NF {print n "	" $NF}' \
    >> audit/current/symbols.txt
done < audit/current/binaries.txt

LC_ALL=C sort -u -o audit/current/dependencies.txt audit/current/dependencies.txt
LC_ALL=C sort -u -o audit/current/symbols.txt audit/current/symbols.txt

When committing the baseline, retain only relative object names rather than recording the cloud Mac’s working directory. The pipeline can then run:

diff -u audit/baseline/dependencies.txt audit/current/dependencies.txt
diff -u audit/baseline/symbols.txt audit/current/symbols.txt

Turn the report into a reliable gate

A full-text diff alone can be overly sensitive. A more practical strategy is to enforce hard rules first and then inspect changes against the baseline. Dependencies beginning with /System/Library/Frameworks/, /usr/lib/, or a controlled @rpath/ are generally valid; other absolute paths should fail immediately. For @rpath/Example.framework/Example, the script should also verify that the corresponding framework exists in the bundle instead of allowing it solely because the prefix is valid.

Symbol changes are better suited to manual review. Mangled Swift names can be very long, so do not infer risk from string length. Focus on sudden large groups of global symbols, debugging helper interfaces, test entry points, and C or Objective-C symbols that should be hidden but have become visible across modules.

Reduce false positives

Pin the Xcode version and Release configuration, and make sure every comparison uses the same scheme. If the project contains multiple extensions, maintain a separate baseline for each binary rather than merging all results into one set. When a toolchain upgrade changes system symbols, update the baseline in the upgrade merge request so that the origin of later differences remains explainable.

Trace failures back through the artifacts

When a new dependency appears, first use otool -L to identify the binary that introduced it, then inspect the target’s linker settings, conditional compilation, and dependency embedding phase. If an @rpath target is missing, check whether the framework was linked but never copied into the bundle. For unexpected symbols, use nm -gU to locate the object, then inspect visibility declarations, bridging code, and linker export settings.

Do not overwrite the baseline immediately after a failure. The correct sequence is to identify the source of the change, determine whether it affects the delivery boundary, fix or approve the change, and only then regenerate the baseline. A gate built this way does not depend on the temporary state of any particular cloud Mac, and it leaves a reviewable record of the binary boundary for every release.

Frequently asked questions

Why not inspect only the dependencies declared by the project?

Declarations are not the final artifact. Conditional builds, linker flags, and dependency scripts can alter an archive, so the gate should inspect its Mach-O binaries.

Should every exported-symbol change block a release?

Not permanently. The gate should require review; after an intentional API change or toolchain upgrade is confirmed, the updated baseline can be committed.

Can a symbol inventory detect secrets embedded in an app?

Not by itself. It reveals interface and linkage changes, while secret scanning must also cover source files, configuration, resources, and build logs.

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.

Choose a Cloud Mac plan