iOS & Android Build Flows — Deep Dive Guide
📱
Enterprise reference guide · Covers Xcode 16, CocoaPods 1.16, AGP 8.x, Gradle 8.x · Updated June 2026
Xcode 16 CocoaPods 1.16 AGP 8.x Gradle 8.x Updated June 2026
1 📱 iOS Build Flow
iOS Build Pipeline
Every iOS build—whether in CI or locally—follows the same chain: source checkout → dependency install → compile → test → archive → export IPA → distribute. Each stage is a strict gate: if one fails, nothing downstream runs.
iOS Full Pipeline — Animated
Stage 1 — Checkout
The CI runner clones the repo (or performs a shallow fetch) and restores submodules. Environment variables like BUILD_NUMBER, branch name, and commit SHA are injected here. LFS assets must be pulled explicitly.
# Jenkins SCM or shell
git clone --depth 1 $REPO_URL
git submodule update --init --recursive
⚠ Shallow clones (--depth 1) break git describe for version bumping. Use --unshallow before tagging.
Stage 2 — pod install
CocoaPods resolves the dependency graph from Podfile, downloads sources, and generates the .xcworkspace. The lock file must be committed so all builds use the exact same versions.
# Always use the workspace after this point
pod install --repo-update --deployment
# --deployment: treats Podfile.lock as source of truth
# --repo-update: updates local git-based specs repos (~/.cocoapods/repos/).
# Note: the CDN (cdn.cocoapods.org) is fetched lazily on demand and is
# unaffected by this flag — since CocoaPods 1.8, the CDN is the default
# source and does not require a repo update.
ℹ The generated Pods/ directory should be gitignored. Only Podfile.lock is committed.
→ See Tab 3 for CocoaPods deep-dive
Stage 3 — Compile
Xcode compiles Swift/Obj-C source files using whole-module compilation — parallelism is across targets and compilation units, not individual files. Each target builds its own framework. Static libraries are linked. Bitcode was deprecated in Xcode 14 and removed in Xcode 15. Current builds do not emit or accept Bitcode.
xcodebuild \
-workspace App.xcworkspace \
-scheme MyApp \
-configuration Release \
-destination 'generic/platform=iOS' \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGNING_ALLOWED=NO \
build
Stage 4 — Test
Unit tests run on a simulator. UI tests can run on a simulator or physical device. XCTest results are exported as .xcresult bundles. Test parallelism is controlled by -maximum-parallel-testing-workers.
xcodebuild test \
-workspace App.xcworkspace \
-scheme MyAppTests \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-resultBundlePath TestResults.xcresult \
-enableCodeCoverage YES
Stage 5 — Archive
The archive step compiles a release build and packages it into an .xcarchive bundle. This is a signed build containing the .app, dSYMs for crash symbolication, and embedded provisioning profile.
xcodebuild archive \
-workspace App.xcworkspace \
-scheme MyApp \
-configuration Release \
-archivePath build/MyApp.xcarchive \
CODE_SIGN_IDENTITY="iPhone Distribution" \
PROVISIONING_PROFILE_SPECIFIER="My Profile"
Stage 6 — Export IPA
The export step re-signs the archive using the distribution method defined in ExportOptions.plist and packages it as an .ipa file ready for upload or direct device install.
xcodebuild -exportArchive \
-archivePath build/MyApp.xcarchive \
-exportOptionsPlist ExportOptions.plist \
-exportPath build/export/
The ExportOptions.plist controls the distribution method: app-store enterprise ad-hoc development
→ See Tab 4 for code signing & certificates
Stage 7 — Distribute
App Store / TestFlight
Upload via the App Store Connect API. altool was deprecated in 2023 and fully removed in Xcode 16. Use xcrun notarytool for notarization and the App Store Connect API or Fastlane deliver for distribution. TestFlight processes the build asynchronously (~10 min).
⚠ altool removed in Xcode 16. Use xcrun notarytool or fastlane deliver instead.
# App Store Connect API (current — altool removed in Xcode 16)
xcrun notarytool submit build/export/MyApp.ipa \
--key ~/.appstoreconnect/private_keys/AuthKey_<KeyID>.p8 \
--key-id $KEY_ID \
--issuer-id $ISSUER_ID \
--wait
# Or via Fastlane (recommended)
fastlane deliver --ipa build/export/MyApp.ipa \
--api_key_path ~/.appstoreconnect/api_key.json
Enterprise / In-House
The .ipa signed with an Enterprise certificate can be distributed via a web server or MDM. Users install via a manifest .plist link opened in Safari. No App Store review needed.
# manifest.plist pointing to IPA URL
# itms-services://?action=download-manifest
# &url=https://server/manifest.plist
Ad Hoc
Signed for specific UDIDs registered in the provisioning profile. Up to 100 registered devices per device type per account. The device count resets once annually — devices can only be removed during the annual reset window, not freely cycled throughout the year. Deployed via tools like Diawi, Firebase App Distribution, or direct install.
# ExportOptions.plist excerpt
<key>method</key>
<string>ad-hoc</string>
# Note: compileBitcode key removed — Bitcode deprecated Xcode 14,
# removed Xcode 15. Do not include this key in new ExportOptions.plist.
→ See Tab 4 for code signing & certificates
Key Environment Variables in CI
| Variable | Used For | Example |
|---|---|---|
| CODE_SIGN_IDENTITY | Selects which certificate to sign with | iPhone Distribution: Acme Corp |
| PROVISIONING_PROFILE_SPECIFIER | Profile name or UUID to embed | MyApp_Enterprise |
| DEVELOPMENT_TEAM | Apple Team ID for automatic signing | A1B2C3D4E5 |
| CODE_SIGNING_REQUIRED | Disable signing for compile-only CI | NO |
| ONLY_ACTIVE_ARCH | Build all architectures (not just host) | NO |
| COMPILER_INDEX_STORE_ENABLE | Skip Xcode indexing in CI (faster) | NO |
2 🤖 Android Build Flow
Android Build Pipeline
Android builds are driven by Gradle — a JVM-based build system. The pipeline transforms source + resources into a bytecode-optimised, signed APK or AAB. Unlike iOS, there is no archive step: the artifact is produced directly by the build task.
Android Full Pipeline — Animated
Gradle Sync — How It Works
Gradle downloads all dependencies declared in build.gradle (or build.gradle.kts) from Maven Central, Google’s Maven repo, or your private Nexus/JFrog Artifactory. Dependencies resolve transitively.
// build.gradle (app module)
dependencies {
implementation 'androidx.core:core-ktx:1.13.1'
implementation 'com.google.firebase:firebase-bom:33.0.0'
testImplementation 'junit:junit:4.13.2'
}
// Use a private Nexus mirror in CI:
maven { url "https://nexus.example.internal/repo" }
D8 / R8 — DEX & Shrinking
D8 is the debug DEX compiler: it converts Java .class files into ART-compatible .dex bytecode (Android Runtime — Dalvik was replaced by ART in Android 5.0). R8 is used in release builds only: it subsumes D8 while also shrinking (removing unused code), minifying (renaming classes/methods), and obfuscating — all in one pass. D8 compiles to DEX for all builds; R8 replaces D8 in release builds only (shrink + obfuscate).
# In build.gradle (proguard equivalent)
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile(
'proguard-android-optimize.txt'),
'proguard-rules.pro'
}
}
AAPT2 — Resources
AAPT2 (Android Asset Packaging Tool) compiles all resources (layouts, drawables, strings) into a binary format and links them into a resource table. It assigns resource integer IDs (via R.java in legacy projects, or R class via non-transitive R classes in AGP 8.0+ projects with android.nonTransitiveRClass=true) so the app can reference them at runtime.
Two-phase: Compile (per-file → .flat intermediate) then Link (all intermediates → resources.arsc + final APK base).
✓ AAPT2 supports incremental builds: only changed resources are recompiled, not the whole resource set.
APK vs AAB
APK is the traditional install package. It contains all code and assets for all device configurations.
AAB (Android App Bundle) is the modern format. Google Play splits it into device-specific APKs at delivery time — smaller downloads, mandatory for new Play apps since 2021.
# Build a release AAB for Play Store
./gradlew bundleRelease
# Build a release APK (Enterprise/Firebase)
./gradlew assembleRelease
Signing — Keystores
Keystore Structure
Android signing uses a Java Keystore (.jks or .keystore). The keystore file holds one or more key entries, each protected by its own password. CI injects these as secrets.
# Create a release keystore (do once, store safely)
keytool -genkey -v \
-keystore release.jks \
-keyalg RSA -keysize 2048 \
-validity 10000 \
-alias myapp
# Note: Google recommends at least 4096-bit RSA keys for new keystores
# (or use EC: -keyalg EC -keysize 256). Play Console warns on keys below 2048 bits.
Signing in Gradle
signingConfigs {
release {
storeFile file("release.jks")
storePassword System.env.STORE_PASS
keyAlias "myapp"
keyPassword System.env.KEY_PASS
}
}
buildTypes {
release {
signingConfig signingConfigs.release
}
}
⚠ Never commit the keystore to source control. If lost, you cannot update your app on Play Store — the signing key is permanent and irreplaceable.
Warning: Never commit the keystore to source control — even if the file path looks local. For CI pipelines, inject the keystore as a base64-encoded environment variable and decode it to a temp file.
# CI-safe keystore injection — inject KEYSTORE_BASE64 as a secret env var
echo "$KEYSTORE_BASE64" | base64 --decode > /tmp/release.jks
./gradlew bundleRelease \
-Pandroid.injected.signing.store.file=/tmp/release.jks \
-Pandroid.injected.signing.store.password="$STORE_PASS" \
-Pandroid.injected.signing.key.alias="$KEY_ALIAS" \
-Pandroid.injected.signing.key.password="$KEY_PASS"
# Clean up immediately after signing
rm -f /tmp/release.jks
→ See Tab 4 for certificate management
bundletool — Local AAB Testing
AABs cannot be directly installed on a device — use bundletool for local testing. For Google Play distribution, upload the .aab directly — Play handles APK splitting per device configuration.
# AABs cannot be directly installed — use bundletool for local testing
# Download bundletool: https://github.com/google/bundletool/releases
# Build a universal APK set from AAB (for device testing)
java -jar bundletool.jar build-apks \
--bundle=app-release.aab \
--output=app.apks \
--ks=release.jks \
--ks-pass=pass:$STORE_PASS \
--ks-key-alias=$KEY_ALIAS \
--key-pass=pass:$KEY_PASS
# Install to connected device
java -jar bundletool.jar install-apks --apks=app.apks
# Get APK size estimate (useful for CI reporting)
java -jar bundletool.jar get-size total --apks=app.apks
Jenkins Pipeline Example — Android
// Jenkinsfile — Android Build Pipeline
pipeline {
agent { label 'android-agent' }
environment {
KEYSTORE_BASE64 = credentials('android-release-keystore-b64')
STORE_PASS = credentials('android-keystore-store-pass')
KEY_ALIAS = credentials('android-keystore-key-alias')
KEY_PASS = credentials('android-keystore-key-pass')
}
stages {
stage('Checkout') { steps { checkout scm } }
stage('Build') {
steps {
sh '''
echo "$KEYSTORE_BASE64" | base64 --decode > /tmp/release.jks
./gradlew bundleRelease \
-Pandroid.injected.signing.store.file=/tmp/release.jks \
-Pandroid.injected.signing.store.password="$STORE_PASS" \
-Pandroid.injected.signing.key.alias="$KEY_ALIAS" \
-Pandroid.injected.signing.key.password="$KEY_PASS" \
--no-daemon
rm -f /tmp/release.jks
'''
}
}
stage('Test') { steps { sh './gradlew test --no-daemon' } }
}
post {
always { archiveArtifacts artifacts: 'app/build/outputs/**/*.aab', allowEmptyArchive: true }
}
}
Gradle Dependency Locking
Dependency locking for Android is the equivalent of Podfile.lock for CocoaPods — it ensures reproducible builds by pinning exact resolved versions.
// build.gradle — enable dependency locking for reproducible builds
dependencyLocking {
lockAllConfigurations()
}
# Generate/update lock files (commit these to source control)
./gradlew dependencies --write-locks
# Verify existing lock files (use in CI — fails if lock is stale)
./gradlew dependencies --verify-lock-files
# Lock files location: gradle/dependency-locks/*.lockfile
# Commit the lockfiles directory to source control
3 🔍 Pod Install & Xcode Deep Dive
Pod Install & Xcode Deep Dive
CocoaPods is the dominant dependency manager for iOS. Understanding exactly what pod install does — and what Xcode does with the result — is essential for diagnosing build failures. This tab covers the internals and every common failure mode.
CocoaPods Resolution & Integration Flow — Animated
What pod install Does — Step By Step
1
Parse Podfile
Reads platform, target blocks, and pod declarations. Evaluates Ruby DSL blocks (pre/post install hooks).
2
Resolve Dependency Graph (Molinillo)
Conflict-driven dependency resolver with backtracking. Tries to satisfy all version constraints transitively. Fails if two pods require incompatible versions of the same dependency.
3
Check Podfile.lock
If lock file exists and resolution matches, no download happens — existing Pods/ is used. --deployment flag enforces this strictly.
4
Download Pod Sources
Clone git repos, download from CDN (cdn.cocoapods.org), or copy from local path. Stored in the global cache at ~/Library/Caches/CocoaPods/.
5
Generate Xcode Integration Files
Writes Pods.xcodeproj, Pods.xcworkspace, per-target .xcconfig files, and injects Copy Pods Resources and Embed Pods Frameworks build phases into your project.
Xcode Build Phases — Execution Order
Build Phase Sequence
Swift Package Manager (SPM)
SPM is now the default dependency manager for new Xcode projects and is integrated directly into Xcode (no separate install needed).
Key Differences vs CocoaPods
- Dependencies declared in
Package.swiftor via Xcode’s “Add Package Dependencies” UI - No
Podfile.lockequivalent — Xcode generatesPackage.resolved(commit this file) - SPM packages are resolved at the workspace level, not the app target level
- No
xcworkspacegeneration — packages are resolved inside the.xcodeprojitself - Offline resolution:
xcodebuild -resolvePackageDependenciespins versions before building - Mixing CocoaPods and SPM: your project will have both a
.xcworkspace(from CocoaPods) and inline SPM dependencies — always open the.xcworkspace, never the.xcodeproj
# Resolve SPM dependencies (always run before xcodebuild in CI)
xcodebuild -resolvePackageDependencies \
-project MyApp.xcodeproj \
-scheme MyApp
# Then build as normal
xcodebuild clean build \
-project MyApp.xcodeproj \
-scheme MyApp ...
Troubleshooting SPM in CI
error: no such module ‘SomePackage’
SPM resolution failed. Run -resolvePackageDependencies first.
PackageIndex: failed to fetch
Network issue or private registry auth. Use --disable-automatic-package-resolution with a committed Package.resolved.
dSYM — Debug Symbol Files
Every Xcode archive produces a .dSYM bundle alongside the .xcarchive. dSYMs map crash addresses back to your source code — without them, production crash reports show only memory addresses.
# dSYM location after xcodebuild archive
ls build/MyApp.xcarchive/dSYMs/
# → MyApp.app.dSYM/Contents/Resources/DWARF/MyApp
# Verify the dSYM UUID matches your binary
dwarfdump --uuid MyApp.app.dSYM
dwarfdump --uuid MyApp.app/MyApp # must match
# Upload to Firebase Crashlytics
$PODS_ROOT/FirebaseCrashlytics/upload-symbols \
-gsp GoogleService-Info.plist \
-p ios build/MyApp.xcarchive/dSYMs
# Upload to Sentry
sentry-cli upload-dif --org my-org --project my-app \
build/MyApp.xcarchive/dSYMs/
# Upload to Datadog
datadog-ci dsyms upload build/MyApp.xcarchive/dSYMs/
ℹ CI best practice: Archive the entire .dSYM bundle as a build artifact alongside the IPA. Never distribute an IPA without preserving its matching dSYMs — they are identified by UUID and cannot be regenerated.
Build Cache Strategy
iOS — What to Cache
# CocoaPods download cache (saves re-downloading pod sources)
# Cache key: hash of Podfile.lock
~/Library/Caches/CocoaPods/
# DerivedData (incremental builds — 60-80% build time reduction)
# Cache key: hash of source files + Podfile.lock
~/Library/Developer/Xcode/DerivedData/
# Gems (Fastlane, CocoaPods gem)
vendor/bundle/ # if using bundler
Android — What to Cache
# Gradle dependency cache
~/.gradle/caches/
~/.gradle/wrapper/
# Enable Gradle build cache in gradle.properties:
org.gradle.caching=true
org.gradle.daemon=false # always disable daemon on CI
org.gradle.parallel=true
org.gradle.configureondemand=true
Cache Invalidation Rules
- iOS: invalidate DerivedData cache when Xcode version changes, never carry over between major versions
- Android: invalidate Gradle cache when AGP version changes
- Never cache build outputs (IPAs, APKs, AABs) — those are artifacts, not caches
Agent Disk Cleanup (CI Maintenance)
macOS build agents accumulate gigabytes of data within days. Add these to a weekly cron job on every Mac CI agent. A fresh full iOS build generates 1–2 GB; 20 builds/day = 40 GB/day without cleanup. Monitor with: df -h /
#!/bin/bash
# /etc/periodic/weekly/cleanup-build-agent.sh
# Remove stale DerivedData (older than 7 days)
find ~/Library/Developer/Xcode/DerivedData -maxdepth 1 \
-type d -mtime +7 -exec rm -rf {} +
# Remove old simulator runtimes
xcrun simctl delete unavailable
# Remove accumulated .xcarchive bundles (keep last 10)
ls -dt ~/Library/Developer/Xcode/Archives/*/*.xcarchive | \
tail -n +11 | xargs rm -rf
# Remove CocoaPods download cache older than 30 days
find ~/Library/Caches/CocoaPods/Pods -maxdepth 2 \
-type d -mtime +30 -exec rm -rf {} +
# Gradle cache cleanup
find ~/.gradle/caches -name "*.lock" -delete
./gradlew --stop # stop all Gradle daemons
Troubleshooting CocoaPods
Error: Unable to find a specification for X
Specs repo is out of date or pod doesn’t exist at that version.
Fix
pod repo update # refresh CDN
pod install --repo-update
Error: Transitive dependency conflict
Pod A requires FirebaseCore ~> 10.0, Pod B requires ~> 11.0
Fix
# Pin the shared dep in Podfile:
pod 'FirebaseCore', '~> 11.0'
# Then pod install again
Error: The Podfile.lock is out of sync
Someone added a pod without running pod install, or PODS_ROOT is stale.
Fix
pod install # always use --deployment in CI
Xcode: No such file or directory (header not found)
HEADER_SEARCH_PATHS from xcconfig not being inherited.
Fix
# In Build Settings, check that xcconfig is set for
# your target's configuration. Do NOT override
# HEADER_SEARCH_PATHS manually — it breaks CocoaPods.
pod deintegrate && pod install
Xcode: Duplicate symbols at link time
Two pods embed the same framework. Common with Firebase sub-specs.
Fix
# Use the umbrella pod instead of sub-specs:
pod 'Firebase/Core' # NOT individual sub-pods
Build: Module not found after Xcode update
DerivedData cached stale build artifacts after Xcode upgrade.
Fix
# Nuclear clean (always fixes stale DerivedData):
rm -rf ~/Library/Developer/Xcode/DerivedData
xcodebuild clean -workspace App.xcworkspace \
-scheme MyApp
Verbose Xcode Output — Reading Build Logs
By default xcodebuild output is verbose and hard to read. Use xcpretty for human-readable output, or install xcbeautify:
# Pipe through xcpretty (Ruby gem)
xcodebuild ... | xcpretty
xcodebuild ... | xcpretty --report junit
# Or xcbeautify (faster, no Ruby dep)
xcodebuild ... | xcbeautify
# Raw log includes full compiler invocations
# Look for these patterns:
## CompileSwift — swift file compilation
## Ld — linker invocation
## CopySwiftLibs — swift runtime copy
## CodeSign — signing step
# Use Xcode → Reports navigator for GUI log
💡 In Jenkins, save the raw xcodebuild output to a file and archive it as a build artifact. xcodebuild exit codes: 0 = success, 65 = test failures, 70 = build error.
Jenkins Pipeline Example — iOS
// Jenkinsfile — iOS Build Pipeline
pipeline {
agent { label 'mac-agent' }
environment {
KEYCHAIN_PASS = credentials('ci-keychain-pass')
P12_PASS = credentials('ios-dist-cert-p12-pass')
CERT_P12 = credentials('ios-dist-cert-p12-b64')
}
stages {
stage('Checkout') {
steps { checkout scm }
}
stage('Setup Keychain') {
steps {
sh '''
security create-keychain -p "$KEYCHAIN_PASS" ci-build.keychain-db
security set-keychain-settings -t 3600 -l ci-build.keychain-db
echo "$CERT_P12" | base64 --decode > /tmp/cert.p12
security import /tmp/cert.p12 -k ci-build.keychain-db \
-P "$P12_PASS" -T /usr/bin/codesign
security set-key-partition-list \
-S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASS" ci-build.keychain-db
rm -f /tmp/cert.p12
'''
}
}
stage('Pod Install') {
steps { sh 'pod install --deployment' }
}
stage('Build & Archive') {
steps {
sh '''
xcodebuild clean archive \
-workspace MyApp.xcworkspace \
-scheme MyApp \
-configuration Release \
-archivePath build/MyApp.xcarchive \
OTHER_CODE_SIGN_FLAGS="--keychain ci-build.keychain-db" \
COMPILER_INDEX_STORE_ENABLE=NO | xcpretty
'''
}
}
stage('Export IPA') {
steps {
sh 'xcodebuild -exportArchive -archivePath build/MyApp.xcarchive -exportPath build/export -exportOptionsPlist ExportOptions.plist'
}
}
}
post {
always {
sh 'security delete-keychain ci-build.keychain-db || true'
archiveArtifacts artifacts: 'build/export/*.ipa, build/MyApp.xcarchive/dSYMs/**', allowEmptyArchive: true
}
}
}
4 🔐 Certs, Profiles & Signing
Certificates, Provisioning Profiles & Code Signing
iOS code signing is a three-party system: Apple (certificate authority), you (the developer with a private key), and the device (which verifies the signature at launch). Every installed app must pass this chain of trust.
Apple Certificate Trust Chain — Animated
Provisioning Profile — Anatomy
A provisioning profile is a signed property list issued by Apple. It grants specific capabilities to an app binary, for specific certificates, on specific devices. It expires and must be renewed.
What Is Inside a .mobileprovision
Profile Types — When to Use Each
| Type | Signed By | Distribution | Devices |
|---|---|---|---|
| Development | Dev Cert | Debug on device via Xcode | Registered UDIDs only |
| Ad Hoc | Dist Cert | Direct install / TestFlight alt | Up to 100 registered devices per device type per account; resets once annually |
| App Store | Dist Cert | Submission to App Store | No device limit (managed by App Store) |
| Enterprise | Dist Cert (Enterprise account) | Internal MDM / web install | All devices (no UDID list) |
⚠ Enterprise distribution is for employees only. Distributing an Enterprise-signed app to the public violates Apple’s Developer Enterprise Program License and can result in certificate revocation.
Common Signing Errors
No signing certificate found
The cert isn’t in the build machine’s keychain. Import the .p12 export including the private key.
Profile doesn’t include the signing certificate
The profile was generated for a different certificate. Regenerate it on developer.apple.com with the correct cert, then re-download.
The executable was signed with invalid entitlements
The app’s Entitlements.plist requests a capability not granted by the profile (e.g. Push enabled in code but not in profile). Regenerate the App ID with the capability, then regenerate the profile.
Code Signing Flow — End to End
How Your App Gets Signed & Verified — Animated
Verification Steps on Device
- Signature valid (public key in cert matches hash)
- Certificate issued by trusted Apple CA
- Profile not expired
- Device UDID in profile (Dev/AdHoc) or enterprise entitlement
- App ID matches bundle identifier
- Entitlements match what profile grants
CI / Jenkins Setup — Dedicated Keychain
⚠ Never unlock the login keychain on a shared Mac agent — it exposes every private key on the machine to concurrent builds. Always create a dedicated per-pipeline keychain and delete it after the build.
# Create a dedicated per-pipeline keychain (never use the login keychain on shared agents)
security create-keychain -p "$KEYCHAIN_PASS" ci-build.keychain-db
security set-keychain-settings -t 3600 -l ci-build.keychain-db
security list-keychains -d user -s ci-build.keychain-db $(security list-keychains -d user | tr -d '"')
# Import certificate — inject P12 as base64 env var in CI
echo "$CERT_P12_BASE64" | base64 --decode > /tmp/cert.p12
security import /tmp/cert.p12 -k ci-build.keychain-db -P "$P12_PASS" \
-T /usr/bin/codesign -T /usr/bin/security
# CRITICAL: without this call, codesign silently fails with
# "User interaction is not allowed" on headless agents (macOS 12+)
security set-key-partition-list \
-S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASS" ci-build.keychain-db
# Always clean up after build
security delete-keychain ci-build.keychain-db
- Copy
.mobileprovisionto~/Library/MobileDevice/Provisioning Profiles/ - Set
CODE_SIGN_IDENTITY+PROVISIONING_PROFILE_SPECIFIER - Or use Fastlane match for centralized cert management
Fastlane Match
Stores all certs and profiles encrypted in a git repo or S3 bucket. All CI agents run match appstore to sync — always the latest valid cert+profile pair.
fastlane match appstore
fastlane match enterprise
fastlane match adhoc
CI Keychain Lifecycle — Headless macOS Signing
On a headless CI agent (no logged-in user session), macOS has no accessible login keychain for codesign. You must create a dedicated keychain, populate it with the certificate and private key, grant programmatic access without any UI prompt, use it for signing, and destroy it. Every build gets its own isolated keychain that exists only for the duration of that build.
Login Keychain (Dangerous) vs Dedicated Per-Build Keychain (Safe)
CI Keychain 7-Step Lifecycle — Animated Flow
⑤ The single most common headless CI signing failure: omitting security set-key-partition-list. Without it, codesign silently fails with “User interaction is not allowed” on every macOS 12+ agent. This call grants apple-tool: and codesign: partition access so the private key can be used without triggering a UI passphrase dialog.
⑦ Always delete the keychain in post { always { } }: if the build crashes mid-sign, a leaked keychain stays on disk unlocked until it times out (3600s). A security delete-keychain in the post block removes it immediately regardless of build outcome.
Inspecting & Verifying Signatures
# Inspect a signed app binary
codesign -dv --verbose=4 MyApp.app
# Deep verification of the app bundle
codesign --verify --deep --strict MyApp.app
echo $? # 0 = valid, non-zero = invalid
# Check which certificate signed the binary
codesign -dv MyApp.app 2>&1 | grep "Authority="
# Verify provisioning profile is embedded
cat MyApp.app/embedded.mobileprovision | \
security cms -D | grep -A1 -E "(Name|ExpirationDate|TeamName)"
# Check entitlements baked into the binary
codesign -d --entitlements - MyApp.app
# Common output meanings
# "valid on disk" — file not tampered
# "satisfies its Designated Requirement" — cert chain valid
# "sealed resource is missing or invalid" — file added/changed after signing
Expiry Monitoring
Why it matters: Distribution certificates expire in 1 year. Provisioning profiles expire in 1 year (paid account) or 7 days (free). A Friday cert expiry with no alert blocks releases until Monday.
# Check certificate expiry (run on CI agent)
security find-certificate -a -p | \
openssl x509 -noout -dates 2>/dev/null | \
grep notAfter
# Check a provisioning profile expiry
security cms -D -i ~/Library/MobileDevice/Provisioning\ Profiles/*.mobileprovision \
| grep -A1 ExpirationDate
# Days remaining for a cert (add to a cron job)
EXPIRY=$(security find-certificate -c "iPhone Distribution" -p | \
openssl x509 -noout -enddate | cut -d= -f2)
DAYS=$(( ($(date -jf "%b %d %T %Y %Z" "$EXPIRY" +%s) - $(date +%s)) / 86400 ))
echo "Certificate expires in $DAYS days"
[ $DAYS -lt 30 ] && echo "WARNING: cert expires soon" && exit 1
⚠ Recommended: Add a scheduled Jenkins/CI job that runs this check daily and alerts at 30 days and 7 days before expiry. Treat a cert expiry like an on-call alert — it will block your release pipeline without warning.
Document Notes & Known Limitations
Reference checklist for editors — all findings addressed in this document.
Deprecated/Removed APIs
altoolremoved Xcode 16 → replaced with App Store Connect API / Fastlane deliver- Bitcode deprecated Xcode 14, removed Xcode 15 → all Bitcode keys silently ignored in new builds
pod repo updatefor CDN refresh is incorrect since CocoaPods 1.8 (CDN is lazy-fetched on demand)jarsignerdeprecated for APK signing → useapksignerfor APK v2+ signing schemeR.java— AGP 8.0+ uses non-transitive R classes by default (android.nonTransitiveRClass=true)
Technical Clarifications
- Molinillo is a conflict-driven backtracking resolver, not a SAT solver
- D8 = debug DEX compiler for all builds; R8 = release-only optimizer that subsumes D8 (shrink + obfuscate)
- WWDR CA: G6 is current (G3 expired Dec 2023)
- Ad Hoc device limit: 100 per device type per account, reset window once per year (not freely cycled)
- Swift compilation: whole-module compilation, not per-file per core
- ART replaced Dalvik in Android 5.0; .dex format is ART-compatible, not Dalvik
- Apple Distribution cert validity: 1 year; Provisioning profiles: 1 year (paid), 7 days (free)
- App Store AND Enterprise profiles have empty device lists; only Dev/AdHoc have UDID lists
CI/CD Operational Gaps (addressed in this document)
- Dedicated per-pipeline keychain required (never login keychain on shared agents)
security set-key-partition-listis mandatory for headless codesign on macOS 12+- Android keystore must never be committed to source control — use base64 env var injection
- Cert/profile expiry monitoring is required for production pipelines
- Gradle
--no-daemonmust be used in CI - DerivedData/Gradle cache cleanup must be scheduled as cron jobs
- dSYM upload to crash reporting platform must be part of CI pipeline
Content Added Based on Review
- SPM (Swift Package Manager) section added (Tab 3)
- dSYM workflow section added (Tab 3)
codesign --verifyinspection commands added (Tab 4)bundletoolusage for AAB local testing added (Tab 2)- Build cache strategy (DerivedData, CocoaPods, Gradle) added (Tab 3)
- Disk cleanup cron job guidance added (Tab 3)
- Gradle dependency locking (
gradle.lockfile) added (Tab 2) - Jenkins pipeline-as-code examples added — iOS (Tab 3) and Android (Tab 2)
- Cert/profile expiry monitoring commands added (Tab 4)
- ARIA accessibility (tab roles, SVG titles, prefers-reduced-motion) added throughout
- Copy-to-clipboard on all code blocks added
Enjoyed this post?
Get the next one in your inbox — only when I ship something worth reading.
Newsletter form not configured.
Or follow on Substack for the newsletter.
Comments via GitHub Discussions
Comments not configured. Set GISCUS env vars to enable.