
If a crash report arrives without readable stack frames, you are debugging blind. The fix is mechanical: every CI release build should upload its dSYM bundles immediately after xcodebuild archive, before the IPA leaves the pipeline. This guide gives you the exact shell steps for Firebase Crashlytics and Sentry, backed by Apple's Building your app to include debugging information and Firebase's Get deobfuscated crash reports.
Buying recommendation: run this pipeline on a Figment M-series Mac (from $95/month; exact chip, RAM and disk are confirmed in the quote — see /mac-hosting). M-series is the right call because recent iOS simulator images and Xcode 27 require Apple silicon. The $29 virtual Intel macOS VM (2 vCPU, 8 GiB RAM, 80 GiB disk) can run Xcode 26 or earlier and will archive and sign iOS targets that build with those toolchains; use it when you want a cheap shadow runner or a validation job, and promote release archives to the M-series host.
Xcode emits a dSYM alongside every binary. It maps raw memory addresses to file names, function names, and line numbers. Without the matching dSYM, Crashlytics and Sentry show hex offsets instead of Swift frames.
The hard constraint is ordering: the dSYM must reach the crash reporter before the first crash from that build version is ingested. If a TestFlight tester crashes within minutes of install, the window to upload after the fact is already closing. Pushing from CI right after the archive step removes the human loop.
Bitcode is no longer a reason to defer the upload. Apple deprecated Bitcode submissions starting with Xcode 14 (see Apple's Xcode 14 release notes), so the dSYM your runner produces is the final dSYM — there is no App Store recompilation step that invalidates it.
An .xcarchive has a fixed layout:
MyApp.xcarchive/
dSYMs/
MyApp.app.dSYM/
Contents/
Resources/
DWARF/
MyApp
MyFramework.framework.dSYM/
...
Products/
Applications/
MyApp.app
In CI, pin the archive path with -archivePath so later steps can find it deterministically:
xcodebuild archive \
-scheme "MyApp" \
-destination "generic/platform=iOS" \
-archivePath "$RUNNER_TEMP/MyApp.xcarchive"
Replace MyApp with the scheme name in your own .xcodeproj/.xcworkspace. Export ARCHIVE_PATH="$RUNNER_TEMP/MyApp.xcarchive" so the upload step consumes the same value.
Do not hard-code one .dSYM path. Embedded frameworks and SwiftPM dependencies emit their own:
find "$ARCHIVE_PATH/dSYMs" -name "*.dSYM" -type d
That gives one line per bundle. Pipe it into xargs to upload them all in a single pass.
Drop this in at ci/upload-dsyms.sh and call it as a post-archive step. It handles Firebase Crashlytics (via the upload-symbols helper bundled with the SDK) and Sentry (via sentry-cli debug-files upload). set -euo pipefail makes a failed upload fail the pipeline — no silent "green" build with missing symbols.
#!/usr/bin/env bash
# ci/upload-dsyms.sh
# Usage: bash ci/upload-dsyms.sh <path-to.xcarchive>
#
# Crashlytics env:
# GOOGLE_SERVICE_INFO_PLIST absolute path to GoogleService-Info.plist
# FIREBASE_UPLOAD_SYMBOLS absolute path to the upload-symbols binary
# Sentry env (set in CI secret manager, not in the repo):
# SENTRY_AUTH_TOKEN
# SENTRY_ORG
# SENTRY_PROJECT
set -euo pipefail
ARCHIVE_PATH="${1:?Usage: $0 <path-to.xcarchive>}"
DSYM_DIR="${ARCHIVE_PATH}/dSYMs"
if [[ ! -d "${DSYM_DIR}" ]]; then
echo "ERROR: dSYMs directory not found at ${DSYM_DIR}" >&2
exit 1
fi
echo "==> dSYM bundles:"
find "${DSYM_DIR}" -name "*.dSYM" -type d
if [[ -n "${FIREBASE_UPLOAD_SYMBOLS:-}" && -n "${GOOGLE_SERVICE_INFO_PLIST:-}" ]]; then
echo "==> Uploading to Firebase Crashlytics..."
find "${DSYM_DIR}" -name "*.dSYM" -type d -print0 | \
xargs -0 -I {} "${FIREBASE_UPLOAD_SYMBOLS}" \
-gsp "${GOOGLE_SERVICE_INFO_PLIST}" \
-p ios \
{}
echo "==> Firebase upload complete."
else
echo "INFO: Crashlytics env not set; skipping."
fi
if [[ -n "${SENTRY_AUTH_TOKEN:-}" ]]; then
echo "==> Uploading to Sentry..."
sentry-cli debug-files upload \
--auth-token "${SENTRY_AUTH_TOKEN}" \
--org "${SENTRY_ORG:?SENTRY_ORG is required}" \
--project "${SENTRY_PROJECT:?SENTRY_PROJECT is required}" \
"${DSYM_DIR}"
echo "==> Sentry upload complete."
else
echo "INFO: SENTRY_AUTH_TOKEN not set; skipping."
fi
The script is an unexecuted example; validate it against your own project before trusting the exit codes. Credentials stay in the CI secret manager — nothing is written to disk, and no environment dump is produced.
The upload-symbols binary ships with FirebaseCrashlytics. Its path depends on how you integrate Firebase:
| Integration | upload-symbols path |
|---|---|
| CocoaPods | ${PODS_ROOT}/FirebaseCrashlytics/upload-symbols |
| Swift Package Manager | …/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/upload-symbols |
| Manual xcframework | alongside the FirebaseCrashlytics.xcframework bundle |
Point FIREBASE_UPLOAD_SYMBOLS at that path in your CI config. Required flags per the Firebase docs: -gsp <path-to-GoogleService-Info.plist> and -p ios, with the dSYM path as the trailing argument. Firebase's recommended invocation pattern is exactly the find … | xargs … upload-symbols -gsp … -p ios {} shape used above.
Install sentry-cli on the runner:
brew install getsentry/tools/sentry-cli
# or: curl -sL https://sentry.io/get-sentry-cli | bash
Sentry's current command is sentry-cli debug-files upload (the older upload-dif alias is being retired — see Sentry's Apple debug information files guide). The authoritative invocation:
sentry-cli debug-files upload \
--auth-token "$SENTRY_AUTH_TOKEN" \
--org "$SENTRY_ORG" \
--project "$SENTRY_PROJECT" \
"$ARCHIVE_PATH/dSYMs"
Add --wait on release jobs if you want the step to block until Sentry finishes processing and returns a definitive status.
Upload calls fail: tokens expire, services have incidents, networks hiccup. Archive the dSYMs/ directory as a CI artifact first so you can re-upload later without rerunning the whole build:
- name: Upload dSYMs as artifact
uses: actions/upload-artifact@v4
with:
name: dsyms-${{ github.sha }}
path: ${{ runner.temp }}/MyApp.xcarchive/dSYMs
retention-days: 30
Tag the artifact with the commit SHA so you can match it to a specific build version weeks later.
After the pipeline publishes a build:
fatalError("test crash")).If frames are still raw, check two things before anything else:
dwarfdump --uuid MyApp.app.dSYM matches the binary's UUID from the archive.A "Missing dSYM" banner in Crashlytics means the upload never landed or the wrong bundle was pushed for that UUID.
| Step | Action |
|---|---|
xcodebuild archive | Writes .xcarchive with dSYMs/ |
| Store CI artifact | Captures dSYMs/ before any network call |
upload-dsyms.sh | Finds every .dSYM and pushes to Crashlytics and/or Sentry |
| Verify | Test-crash on TestFlight shows symbolicated frames |
Running archive, dSYM capture, and uploads on the same Figment Mac keeps intermediate paths local — no cross-runner artifact shuffling. Pick the M-series plan when you need Xcode 27 or Apple-silicon-only simulators; use the $29 Intel VM as an auxiliary runner for Xcode 26-or-earlier builds. Compare plans at /mac-hosting or start provisioning at /mac-hosting/start.
Related guides: