Lab 10.1 — Fastlane Pipeline

Goal: build a match + gym + pilot pipeline that uploads a fresh build to TestFlight with one command. By the end you’ll have a real, reusable Fastfile.

Time: 60–120 minutes (mostly Apple account setup if first time).

Prereqs: Paid Apple Developer account, a private GitHub repo for certs, an App Store Connect API key.

Setup

  1. Open or create a SwiftUI iOS app: FastlaneLab with bundle ID com.yourname.fastlanelab (use your reverse-domain).
  2. In App Store Connect, create the app record (My Apps → +) before automation can talk to it.
  3. Install fastlane:
    gem install fastlane -NV --no-document
    cd FastlaneLab
    fastlane init
    
    When prompted: “Manual setup” (option 4) — we’ll write the Fastfile ourselves.
  4. Create a private empty GitHub repo: yourname/fastlane-lab-certs. Generate a fine-grained PAT scoped only to that repo (Contents: Read & Write).
  5. App Store Connect → Users and Access → Keys → “+ Generate”. Role: App Manager. Download the .p8. Note Key ID + Issuer ID.

Build (cert sync first, then upload lane)

Step 1 — Configure match

Create fastlane/Matchfile:

git_url("https://github.com/yourname/fastlane-lab-certs")
storage_mode("git")
type("development")
app_identifier(["com.yourname.fastlanelab"])
username("you@example.com")  # only used for Apple Developer Portal API
team_id("ABCDE12345")        # your Apple team ID

Run once locally to seed the repo:

# Pick a strong passphrase; record in 1Password as MATCH_PASSWORD
fastlane match development
fastlane match appstore

Watch the certs repo populate with encrypted artifacts.

Step 2 — Configure the App Store Connect API key in fastlane

mkdir -p fastlane
mv ~/Downloads/AuthKey_AAAA1111BB.p8 fastlane/AuthKey.p8

# Build a JSON wrapper fastlane expects
cat > fastlane/AuthKey.json <<EOF
{
  "key_id":   "AAAA1111BB",
  "issuer_id":"69a6de70-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
  "key":      "$(awk '{printf "%s\\n", $0}' fastlane/AuthKey.p8)",
  "duration": 1200,
  "in_house": false
}
EOF

# Make sure these are .gitignored
echo "fastlane/AuthKey.p8" >> .gitignore
echo "fastlane/AuthKey.json" >> .gitignore

Step 3 — Write the Fastfile

fastlane/Fastfile:

fastlane_version "2.220.0"
default_platform :ios

APP_ID    = "com.yourname.fastlanelab"
SCHEME    = "FastlaneLab"
PROJECT   = "FastlaneLab.xcodeproj"
API_KEY   = "fastlane/AuthKey.json"

platform :ios do
  before_all do
    setup_ci if is_ci
  end

  desc "Sync development certs/profiles"
  lane :dev_certs do
    match(type: "development", app_identifier: APP_ID, readonly: is_ci)
  end

  desc "Sync App Store certs/profiles"
  lane :appstore_certs do
    match(type: "appstore", app_identifier: APP_ID, readonly: is_ci)
  end

  desc "Build and upload to TestFlight"
  lane :beta do
    appstore_certs

    increment_build_number(xcodeproj: PROJECT)

    build_app(
      project: PROJECT,
      scheme: SCHEME,
      export_method: "app-store",
      export_options: {
        signingStyle: "manual",
        provisioningProfiles: { APP_ID => "match AppStore #{APP_ID}" }
      },
      output_directory: "build",
      output_name: "FastlaneLab.ipa"
    )

    upload_to_testflight(
      api_key_path: API_KEY,
      ipa: "build/FastlaneLab.ipa",
      skip_waiting_for_build_processing: false,
      distribute_external: false,           # internal only for first run
      changelog: "Initial TestFlight build via Fastlane lab"
    )

    UI.success("Beta #{lane_context[SharedValues::BUILD_NUMBER]} live on TestFlight!")
  end

  error do |lane, exception|
    UI.error("Lane #{lane} failed: #{exception.message}")
  end
end

Step 4 — Run it

bundle exec fastlane beta   # or: fastlane beta

Watch the output:

[12:34:01] ✓ match: 1 profile installed
[12:34:11] ✓ build_app: building...
[12:36:42] ✓ build_app: archived
[12:36:50] ✓ build_app: exported FastlaneLab.ipa
[12:36:55] ↑ upload_to_testflight: uploading...
[12:39:21] ✓ upload_to_testflight: build 2 visible in TestFlight
[12:39:21] 🎉 Beta 2 live on TestFlight!

Confirm: open App Store Connect → TestFlight; your build should be in Processing → Ready to Test.

Stretch

  1. External group — change distribute_external: true, add groups: ["Beta Wide"], submit for beta review automatically.
  2. Changelog from Git — replace the static changelog: string with changelog: changelog_from_git_commits(commits_count: 10).
  3. Slack hook — install fastlane add_plugin slack, append a slack(message: ...) call after upload, store the webhook in .env.secret.
  4. Git auto-bump commit — add commit_version_bump(message: "chore: build [skip ci]") and push_to_git_remote.
  5. CI runner — port this Fastfile to the GitHub Actions workflow from chapter 7. Same lane, just running on macos-15.

Notes

  • If match complains about “Could not create another Distribution certificate” — you’ve hit the 2-cert-per-account limit. Revoke unused ones in the portal.
  • increment_build_number writes to the .xcodeproj. Commit the change or set commit_version_bump to do it automatically.
  • The first TestFlight upload always takes longer than later ones — Apple builds your symbols index. Patience.
  • If TestFlight processing fails, the email from Apple usually explains why. Common: missing ITSAppUsesNonExemptEncryption in Info.plist (set to false for most apps).

Next: Lab 10.2 — Xcode Cloud Workflow