Post

iOS Build with Xcode, xcodebuild and Swift Package Manager

How Swift and iOS apps are built, signed and archived with xcodebuild and Swift Package Manager, which files are involved, how to lint, scan and test, and a GitHub Actions pipeline.

iOS Build with Xcode, xcodebuild and Swift Package Manager

Swift projects are built either with Swift Package Manager (packages, server-side Swift, libraries) or with Xcode / xcodebuild (iOS, macOS, watchOS and visionOS apps). App builds add steps other languages do not have: code signing with certificates and provisioning profiles, archiving, and exporting an .ipa for TestFlight or the App Store. fastlane automates most of it.

This post is part of Build Ecosystems by Programming Language. The Android side is covered in Android Gradle Build.

At a glance

CategoryCommon tools
Build toolsXcode, xcodebuild, Swift Package Manager (swift build), Tuist / XcodeGen (project generation), fastlane
Package managersSwift Package Manager, CocoaPods (maintenance mode), Carthage
Build filesPackage.swift, Package.resolved, *.xcodeproj, *.xcworkspace, *.xcconfig, Info.plist, *.entitlements, ExportOptions.plist, Podfile, Fastfile, Appfile, Matchfile
Lint and formatSwiftLint, SwiftFormat
SAST (open source)MobSF / mobsfscan, Semgrep, CodeQL (Swift)
SAST (commercial)NowSecure, Checkmarx One, Veracode, Snyk Code, SonarQube Server
SCASnyk, Mend, OSV-Scanner (Package.resolved), GitHub dependency graph
FrameworksSwiftUI, UIKit, Combine, SwiftData; server-side: Vapor
Test and coverageXCTest, Swift Testing, XCUITest; Xcode code coverage (xccov), Slather
Build output.app, .xcarchive, .ipa; .xcframework for libraries
CI cacheSwiftPM .build/ and SourcePackages, DerivedData, Pods/

How the build works

In CI, each stage of the generic build pipeline maps to a Xcode command, plugin or scanner. Press play, or step through it:

  1. Source + Package.swift: A commit triggers CI. Package.swift and Package.resolved pin the Swift packages, and the Xcode project or workspace defines the app.
  2. Resolve packages: xcodebuild -resolvePackageDependencies fetches the exact versions in Package.resolved; CI caches SourcePackages and DerivedData.
  3. Lint and format: swiftlint --strict fails on any lint warning; SwiftFormat checks formatting.
  4. Compile: xcodebuild builds the app for the scheme; swift build -c release builds plain Swift packages.
  5. Test and coverage: xcodebuild test runs XCTest and Swift Testing on an iOS Simulator with code coverage on; xcrun xccov reports it.
  6. Security scans in parallel: mobsfscan applies MobSF static rules to our code, OSV-Scanner checks Package.resolved, and Gitleaks scans for secrets.
  7. Quality gate: Tests, coverage and every scanner are required checks, so a single failure stops the pipeline here.
  8. Fail the build (fail): A failed check stops the pipeline at the gate, so nothing is packaged, signed or published.
  9. Archive (pass): xcodebuild archive builds a Release .xcarchive.
  10. Sign and export (pass): Signing needs a distribution certificate and provisioning profile (managed with fastlane match); -exportArchive then writes the .ipa.
  11. Upload to TestFlight (pass): fastlane beta builds and uploads the .ipa to TestFlight through App Store Connect.
  • Schemes choose which targets to build, test and archive; mark them Shared so they are committed and visible to xcodebuild in CI.
  • Build settings come from the project file and .xcconfig files; keep environment-specific values (bundle ID, API URLs) in .xcconfig rather than editing the project in CI.
  • Signing needs a distribution certificate and a provisioning profile on the build machine. fastlane match stores them encrypted in a git repo or cloud bucket and installs them in CI.
  • macOS runners are required for iOS builds; they are slower and more expensive than Linux, so cache aggressively and run lint/SAST on Linux where possible.

Build files

FilePurposeCommit?
Package.swiftSwiftPM manifest: products, targets, dependenciesYes
Package.resolvedLocked package versionsYes
*.xcodeproj, *.xcworkspaceXcode project / workspace (or generated by Tuist/XcodeGen)Yes (or the generator spec)
xcshareddata/xcschemes/*.xcschemeShared schemes used by CIYes
*.xcconfigBuild settings per configurationYes, without secrets
Info.plist, *.entitlementsApp metadata and capabilitiesYes
ExportOptions.plistExport method (app-store, ad-hoc), team ID, signingYes
Podfile, Podfile.lockCocoaPods dependencies (legacy projects)Yes
fastlane/Fastfile, Appfile, MatchfileAutomation lanes and signing configYes
*.p12, *.mobileprovision, App Store Connect API keysSigning materialNever in plain text
DerivedData/, .build/, build/Build outputsNo

Commands by category

1
2
3
4
5
6
7
8
9
swift build -c release                           # build a Swift package
xcodebuild -resolvePackageDependencies -scheme Demo   # resolve packages
swiftlint --strict                               # lint
mobsfscan --json -o mobsf.json Sources/          # SAST (MobSF static rules)
xcodebuild test -scheme Demo -destination 'platform=iOS Simulator,name=iPhone 16' -enableCodeCoverage YES -resultBundlePath TestResults.xcresult   # tests + coverage
xcrun xccov view --report TestResults.xcresult   # coverage report
xcodebuild archive -scheme Demo -configuration Release -archivePath build/Demo.xcarchive   # archive
xcodebuild -exportArchive -archivePath build/Demo.xcarchive -exportOptionsPlist ExportOptions.plist -exportPath build/   # export .ipa
fastlane beta                                    # build + upload to TestFlight (lane below)

Minimal project

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// swift-tools-version: 6.0
// Package.swift
import PackageDescription

let package = Package(
    name: "Demo",
    platforms: [.iOS(.v17), .macOS(.v14)],
    products: [.library(name: "Demo", targets: ["Demo"])],
    dependencies: [
        .package(url: "https://github.com/apple/swift-log.git", from: "1.6.0")
    ],
    targets: [
        .target(name: "Demo", dependencies: [.product(name: "Logging", package: "swift-log")]),
        .testTarget(name: "DemoTests", dependencies: ["Demo"])
    ]
)
1
2
3
4
5
6
7
8
9
10
11
# fastlane/Fastfile
default_platform(:ios)

platform :ios do
  lane :beta do
    setup_ci
    match(type: "appstore", readonly: true)
    build_app(scheme: "Demo", export_method: "app-store")
    upload_to_testflight(skip_waiting_for_build_processing: true)
  end
end

Frameworks

FrameworkStart a project
SwiftUI appXcode → New Project → App (SwiftUI), or tuist init
Swift package / libraryswift package init --type library
Command-line toolswift package init --type executable
Vapor (server-side)brew install vapor && vapor new api

CI with GitHub Actions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
name: ios-ci
on: [push, pull_request]
permissions:
  contents: read

jobs:
  test:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/cache@v4
        with:
          path: ~/Library/Developer/Xcode/DerivedData/**/SourcePackages
          key: spm-${{ hashFiles('**/Package.resolved') }}

      - run: brew install swiftlint
      - run: swiftlint --strict
      - run: |
          xcodebuild test -scheme Demo \
            -destination 'platform=iOS Simulator,name=iPhone 16' \
            -enableCodeCoverage YES -resultBundlePath TestResults.xcresult

  beta:
    if: github.ref == 'refs/heads/main'
    needs: test
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - run: bundle install && bundle exec fastlane beta
        env:
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
          APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.ASC_API_KEY }}

Simulator names change with each Xcode release on hosted runners. Check the runner image’s installed simulators (xcrun simctl list devices) or select the Xcode version explicitly.

Best practices

  • Prefer Swift Package Manager for new dependencies; CocoaPods is no longer actively developed.
  • Commit Package.resolved and shared schemes so CI resolves and builds the same thing as Xcode.
  • Manage signing with fastlane match (or Xcode Cloud) and an App Store Connect API key; never commit .p12 files or passwords.
  • Generate the project with Tuist or XcodeGen on large teams to avoid .pbxproj merge conflicts.
  • Split jobs: lint and SAST on cheap Linux runners, build/test/archive on macOS.
  • Increment build numbers in CI (agvtool or fastlane increment_build_number) so every upload is unique.
This post is licensed under CC BY 4.0 by the author.