iosandroidcimobile

iOS & Android Build Flows — Deep Dive Guide

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

Checkout git clone fetch submodules 🧩 pod install CocoaPods / SPM resolve deps Compile Swift / Obj-C xcodebuild build Test XCTest / XCUITest Simulator / Device 📁 Archive xcodebuild archive .xcarchive 📲 Export IPA ExportArchive sign + package 🚀 Distribute App Store / TF Enterprise / AdHoc SCM Deps Build Test Package Sign Deploy

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

VariableUsed ForExample
CODE_SIGN_IDENTITYSelects which certificate to sign withiPhone Distribution: Acme Corp
PROVISIONING_PROFILE_SPECIFIERProfile name or UUID to embedMyApp_Enterprise
DEVELOPMENT_TEAMApple Team ID for automatic signingA1B2C3D4E5
CODE_SIGNING_REQUIREDDisable signing for compile-only CINO
ONLY_ACTIVE_ARCHBuild all architectures (not just host)NO
COMPILER_INDEX_STORE_ENABLESkip 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

Checkout git clone fetch refs 🔄 Gradle Sync resolve deps download AARs Compile javac / kotlinc .class files 🎨 Resources AAPT2 compile R.java / .flat 🔧 D8 / R8 DEX compile shrink + obfuscate 📦 Package APK / AAB zipalign 🔐 Sign & Deploy apksigner Play / Firebase SCM Deps JVM Compile Assets ART/DEX Bundle Sign & Push

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

Podfile platform :ios, '16.0' Molinillo Resolver conflict-driven dep resolver resolves transitive deps Podfile.lock pinned versions Alamofire git clone Firebase CDN download RxSwift local cache Pods/ directory App.xcworkspace references app project + Pods project Pods.xcconfig HEADER_SEARCH_PATHS, LD_FLAGS… Pods.xcodeproj separate project, builds all pod targets xcodebuild -workspace .xcworkspace INPUT DOWNLOAD GENERATED CONSUMES

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

Run Script — Pre-Compile Hooks SwiftLint, code generation, version bump Compile Sources Swift/ObjC → .o object files Link Binary With Libraries ld links .o → executable + frameworks Copy Bundle Resources assets, storyboards, xcassets → .app Embed Pods Frameworks copy dynamic pod frameworks → .app Code Sign embed profile, sign binary + frameworks

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.swift or via Xcode’s “Add Package Dependencies” UI
  • No Podfile.lock equivalent — Xcode generates Package.resolved (commit this file)
  • SPM packages are resolved at the workspace level, not the app target level
  • No xcworkspace generation — packages are resolved inside the .xcodeproj itself
  • Offline resolution: xcodebuild -resolvePackageDependencies pins 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

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

Apple Root CA Self-signed · Pre-installed on every Apple device Apple Worldwide Dev Relations CA Signs all developer certificates · G6 intermediate (current; G3 expired Dec 2023) Development Certificate iPhone Developer: Your Name used for debug builds on device Distribution Certificate iPhone Distribution: Acme Corp App Store · Enterprise · Ad Hoc 🔑 Private Key stored in Keychain cert + key = signing identity (.p12 export) 🔑 Private Key stored in Keychain must be on the build machine Certificate embedded in Provisioning Profile → signs your app binary

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

Provisioning Profile (.mobileprovision) DER-encoded CMS envelope signed by Apple WWDR CA Certificate(s) public key of signing identity App ID com.acme.myapp (or wildcard *) Devices (UDIDs) empty = App Store or Enterprise; list = Dev or Ad Hoc only Entitlements Push, iCloud, Sign in with Apple… Expiry Date 1 year (dev) · 1 year (dist) Distribution Type dev · ad-hoc · app-store · enterprise Entire payload signed by Apple WWDR CA → tamper-proof

Profile Types — When to Use Each

TypeSigned ByDistributionDevices
DevelopmentDev CertDebug on device via XcodeRegistered UDIDs only
Ad HocDist CertDirect install / TestFlight altUp to 100 registered devices per device type per account; resets once annually
App StoreDist CertSubmission to App StoreNo device limit (managed by App Store)
EnterpriseDist Cert (Enterprise account)Internal MDM / web installAll 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

App Binary compiled .app unsigned codesign private key signs hash embeds profile Certificate + private key Signed .app signature + profile .ipa package Payload/App.app ZIP container iOS Device install IPA verify on install ✓ Verified sig valid · profile trusted UDID/cert match · app launches

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 .mobileprovision to ~/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)

⚠ Login Keychain — Never on Shared CI Agents Build A Build B Build C login.keychain-db ALL team private keys exposed to every concurrent build Zero isolation Concurrent builds share all signing keys simultaneously ✓ Dedicated Per-Build Keychain Build A ci-a.keychain deleted ✕ Build B ci-b.keychain deleted ✕ Build C ci-c.keychain deleted ✕ Isolated · temporary · auditable Each build sees only its own keys

CI Keychain 7-Step Lifecycle — Animated Flow

① Create & Setup Phase ② Security Phase ③ Sign ④ Cleanup ① Create Keychain File create-keychain -p $KEYCHAIN_PASS ② Configure Auto-Lock 3600s set-keychain-settings -t 3600 -l ③ Register Search List list-keychains -d user -s ④ Import Certificate + Key base64 decode security import ⑤ Authorize set-key-partition-list -S apple-tool:,apple: ⚠ CRITICAL no UI prompt ⑥ Sign xcodebuild codesign reads from keychain ⑦ Delete Keychain delete-keychain post { always } Inject $CERT_P12_BASE64 from CI secret store Without this → codesign fails "User interaction not allowed" on macOS 12+ headless agents Always run in post/finally — even on build failure

⑤ 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

  • altool removed 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 update for CDN refresh is incorrect since CocoaPods 1.8 (CDN is lazy-fetched on demand)
  • jarsigner deprecated for APK signing → use apksigner for APK v2+ signing scheme
  • R.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-list is 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-daemon must 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 --verify inspection commands added (Tab 4)
  • bundletool usage 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.