Fastlane for a Multiplatform App: From Painful Setup to One-Command Deploys

ShowShark ships to five Apple platforms: iOS, tvOS, macOS (App Store), macOS (direct download via Developer ID), and visionOS. Each platform has its own archive format, signing requirements, distribution channel, and post-upload steps. Doing this by hand means five trips through the Xcode archive organizer, five manual uploads, one notarization submission, one appcast generation, and one SCP to the web server. Miss a step and you ship an unsigned build, or a stale CDN serves last week's download, or TestFlight never gets the tvOS binary because you forgot it exists.

Fastlane automates all of it. One command builds, signs, notarizes, uploads, generates the appcast, deploys, purges the CDN cache, and sends a push notification to my phone when it finishes. Getting to that point was one of the more frustrating experiences in this project.

This post covers the journey: what Fastlane is, why the initial setup is painful, how the Fastfile evolved from "does not work" to a reliable multiplatform deployment pipeline, and the specific hard-won solutions for App Store signing, Developer ID notarization, and direct-download deployment.

What Fastlane Actually Is

Fastlane is a Ruby-based automation tool for building and releasing mobile apps. It wraps Apple's command-line tools (xcodebuild, notarytool, altool) and App Store Connect APIs into a higher-level interface. You write "lanes" in a Fastfile, each lane being a sequence of actions, and run them from the terminal.

In theory, this sounds simple. In practice, Fastlane sits at the intersection of Apple's frequently changing toolchain, Ruby's dependency ecosystem, Xcode's project format, and App Store Connect's authentication mechanisms. Each of these is individually manageable. Together, they produce a setup experience that can charitably be described as character-building.

The First Commit

The git history tells the story honestly. The first Fastlane commit in ShowShark's repository is:

5d0259e Fastlane stuff, which so far does not work. Arrrrgh.

Followed shortly by:

4e02f21 - Stubs in fastlane stuff, which isn't configured yet.

Then, after some time:

0bcf6e5 Fastlane deployment stuff.

These commit messages reflect the reality of Fastlane setup. There is no single configuration file you fill in and run. There is a gradient from "installed the gem" to "it actually works," and the gradient is populated with authentication errors, provisioning profile mismatches, and Xcode version incompatibilities that each take an hour to diagnose and five minutes to fix.

The current Fastfile is 1,115 lines of Ruby. It took approximately 20 commits to get from "does not work" to reliable. Here is what it does today.

Architecture: Two Distribution Channels

ShowShark has two fundamentally different distribution paths, and understanding this split is essential to understanding why the Fastfile is structured the way it is.

  ┌─────────────────────────────────────────────────────────────────────┐
  │                    ShowShark Deployment Pipeline                    │
  │                                                                     │
  │              ┌────────────────────────────────────────┐             │
  │              │       App Store Connect Path           │             │
  │              │    (client_ios, client_tvos,           │             │
  │              │     client_macos, client_visionos)     │             │
  │              │                                        │             │
  │              │  Archive ──▶ Export (app-store-connect)│             │
  │              │     │                                  │             │
  │              │  Signing: Apple Distribution cert      │             │
  │              │           + provisioning profile       │             │
  │              │     │                                  │             │
  │              │  Upload to TestFlight (.ipa or .pkg)   │             │
  │              │     │                                  │             │
  │              │  Apple handles distribution,           │             │
  │              │  notarization, and updates             │             │
  │              └────────────────────────────────────────┘             │
  │                                                                     │
  │              ┌────────────────────────────────────────┐             │
  │              │       Developer ID Path                │             │
  │              │    (server_macos)                      │             │
  │              │                                        │             │
  │              │  Archive ──▶ Export (developer-id)     │             │
  │              │     │                                  │             │
  │              │  Signing: Developer ID Application     │             │
  │              │           cert (no profile needed)     │             │
  │              │     │                                  │             │
  │              │  Notarize with notarytool              │             │
  │              │     │                                  │             │
  │              │  Staple ticket to .app                 │             │
  │              │     │                                  │             │
  │              │  ZIP, SCP to web server                │             │
  │              │     │                                  │             │
  │              │  Generate signed appcast               │             │
  │              │     │                                  │             │
  │              │  We handle everything ourselves        │             │
  │              └────────────────────────────────────────┘             │
  │                                                                     │
  └─────────────────────────────────────────────────────────────────────┘

The App Store path is simpler in some ways (Apple handles distribution and updates) but more complex in others (provisioning profiles, App Store Connect API keys, platform-specific export formats). The Developer ID path gives you full control but requires managing notarization, hosting, update feeds, and CDN invalidation yourself.

The Fastfile, Section by Section

Environment and Authentication

The Fastfile begins with configuration that took the longest to get right: authentication. There are three separate authentication contexts, each with multiple credential strategies.

# App Store Connect API key (for client uploads to TestFlight)
APP_STORE_CONNECT_KEY_ID=XXXXXXXXXX
APP_STORE_CONNECT_ISSUER_ID=00000000-0000-0000-0000-000000000000
APP_STORE_CONNECT_KEY_FILEPATH=/path/to/AuthKey.p8

# Notarization credentials (for Developer ID server builds)
NOTARYTOOL_KEYCHAIN_PROFILE=showshark-notary

# Cloudflare API (for CDN cache purge after server deploy)
CLOUDFLARE_ZONE_ID=your_zone_id
CLOUDFLARE_API_TOKEN=your_api_token

All credentials live in fastlane/.env, which is gitignored. The .env.example file documents every variable. The Fastfile reads them through a helper that returns empty strings for missing values rather than crashing, so optional integrations (Cloudflare, Pushover, Sentry) degrade gracefully:

def env_value(name)
  ENV[name].to_s.strip
end

The xcodebuild_authentication_args helper is worth examining because it solves a subtle problem. Fastlane's built-in build_app action uses its own project parsing logic (the xcodeproj gem), which breaks on newer Xcode project attributes. The workaround is to call xcodebuild directly, but then you need to pass App Store Connect authentication yourself:

def xcodebuild_authentication_args
  key_id = env_value("APP_STORE_CONNECT_KEY_ID")
  issuer_id = env_value("APP_STORE_CONNECT_ISSUER_ID")
  return [] if key_id.empty? || issuer_id.empty?

  key_path =
    if !key_content.empty?
      # Key is inline (base64 or raw) -- write to temp file for xcodebuild
      temp_key = Tempfile.new(["showshark-asc-key", ".p8"])
      temp_key.binmode
      payload = base64? ? Base64.decode64(key_content) : key_content
      temp_key.write(payload)
      temp_key.flush
      (@xcodebuild_auth_temp_keys ||= []) << temp_key
      temp_key.path
    elsif !key_filepath.empty?
      ensure_file_exists!(key_filepath, "APP_STORE_CONNECT_KEY_FILEPATH")
    else
      return []
    end

  ["-authenticationKeyPath", key_path,
   "-authenticationKeyID", key_id,
   "-authenticationKeyIssuerID", issuer_id]
end

The temp file array (@xcodebuild_auth_temp_keys) prevents Ruby's garbage collector from deleting the temp file while xcodebuild is still reading it. This is the kind of thing that works on your machine, fails in CI, and takes three hours to figure out.

Why Raw xcodebuild Instead of build_app

This deserves its own section because it was the turning point from "Fastlane mostly works" to "Fastlane actually works."

Fastlane's build_app (formerly gym) action is the standard way to archive and export. It wraps xcodebuild and adds conveniences like automatic scheme detection and code signing configuration. It also depends on the xcodeproj Ruby gem to parse your project file.

The problem: the xcodeproj gem cannot keep up with every new Xcode project format change. When Apple adds a new build setting or project attribute, xcodeproj may fail to parse the project entirely, or worse, parse it incorrectly and produce a corrupted export. This is the commit that fixed it:

b00489e Refactor Fastfile: add visionOS, deduplicate client lanes,
        integrate server deploy

- Replace fastlane build_app with raw xcodebuild calls to bypass
  xcodeproj gem incompatibility with newer Xcode project attributes

The replacement is straightforward but requires generating the ExportOptions plist yourself:

def write_export_options_plist!(method:, bundle_id: nil, profile_env_name: nil)
  profile_name = profile_env_name ? env_value(profile_env_name) : ""
  automatic = profile_name.empty?

  lines = ['<?xml version="1.0" encoding="UTF-8"?>']
  lines << '<!DOCTYPE plist PUBLIC "-//Apple...'
  lines << '<plist version="1.0">'
  lines << '<dict>'
  lines << "  <key>method</key>"
  lines << "  <string>#{method}</string>"
  # ... teamID, signingStyle, provisioningProfiles ...
  lines << '</dict>'
  lines << '</plist>'

  plist_path = File.join(Dir.tmpdir, "ShowShark-ExportOptions.plist")
  File.write(plist_path, lines.join("\n") + "\n")
  plist_path
end

The method parameter is the critical fork: "app-store-connect" for clients going to TestFlight, "developer-id" for the server going to direct download. The plist is written to a temp file, passed to xcodebuild -exportArchive, and cleaned up in an ensure block.

Signing Preflight: Catching Problems Before the Build

The most frustrating Fastlane failure mode is: build succeeds, export succeeds, upload fails because the provisioning profile does not match the signing certificate. This happens an hour into the process, after you have already built four platforms.

The preflight checks solve this by validating the signing chain before any building starts:

  ┌────────────────────────────────────────────────────────────────────┐
  │                    Signing Preflight Flow                          │
  │                                                                    │
  │  1. Scan ~/Library/Developer/Xcode/UserData/Provisioning Profiles  │
  │     and ~/Library/MobileDevice/Provisioning Profiles               │
  │                                                                    │
  │  2. For each .mobileprovision / .provisionprofile:                 │
  │     - Extract embedded plist via openssl smime                     │
  │     - Read Name, UUID, TeamIdentifier, Platform                    │
  │     - Read DeveloperCertificates (embedded cert chain)             │
  │     - Filter: team matches? bundle ID matches? platform matches?   │
  │                                                                    │
  │  3. Select best profile (explicit name > App Store name > newest)  │
  │                                                                    │
  │  4. Extract certificate SHA-1 hashes from the profile              │
  │                                                                    │
  │  5. Query Keychain: security find-identity -p codesigning          │
  │     - Get installed Apple Distribution cert hashes                 │
  │                                                                    │
  │  6. Match: does any profile cert hash appear in the Keychain?      │
  │     - Yes: preflight passes                                        │
  │     - No: fail with diagnostic message listing both sides          │
  │                                                                    │
  │  7. macOS only: check for Mac Installer Distribution cert          │
  │     (needed for .pkg generation for App Store upload)              │
  │                                                                    │
  └────────────────────────────────────────────────────────────────────┘

The diagnostic output on failure is deliberately verbose. When you see "Profile cert hashes: ABC123, DEF456" and "Installed Apple Distribution hashes: 789GHI," you know exactly what to fix: either download a new profile that includes your current certificate, or install the certificate that the profile expects. Compare this to Xcode's error message, which is typically something like "No signing certificate found. Try again." Extremely helpful.

This preflight runs as embedded bash inside the Ruby Fastfile. It is about 100 lines of shell that parse provisioning profiles using openssl smime, extract plist values with plutil, and cross-reference against the Keychain with security find-identity. Writing it was unpleasant. Debugging signing failures without it was worse.

The Client Build: Four Platforms, One Function

The four client lanes (client_ios, client_tvos, client_macos, client_visionos) are each a single line:

lane :client_ios do
  build_and_upload_client(:ios)
end

All the logic lives in build_and_upload_client, driven by a configuration table:

CLIENT_PLATFORMS = {
  ios: {
    display_name: "iOS",
    destination: "generic/platform=iOS",
    profile_env: "IOS_APP_STORE_PROFILE_NAME",
    upload_platform: "ios",
    uses_pkg: false,
    preflight: :ios
  },
  tvos: {
    display_name: "tvOS",
    destination: "generic/platform=tvOS",
    upload_platform: "appletvos",
    uses_pkg: false,
    preflight: nil
  },
  macos: {
    display_name: "macOS",
    destination: "generic/platform=macOS",
    upload_platform: "osx",
    uses_pkg: true,           # macOS App Store requires .pkg, not .ipa
    preflight: :macos
  },
  visionos: {
    display_name: "visionOS",
    destination: "generic/platform=xrOS",
    upload_platform: "xros",
    uses_pkg: false,
    preflight: nil
  }
}

This table captures every platform difference that matters: the xcodebuild destination string, the upload platform identifier for App Store Connect, whether the export produces an IPA or a PKG, which preflight check to run (if any), and the environment variable for an optional explicit provisioning profile.

Before this refactor, the Fastfile had four nearly identical 30-line blocks. Each time something changed (like adding retry logic to uploads), all four needed updating. The configuration table eliminated approximately 90 lines of duplication.

The shared build function follows a consistent flow:

def build_and_upload_client(platform_key)
  config = CLIENT_PLATFORMS[platform_key]

  # 1. Run signing preflight (if platform requires it)
  case config[:preflight]
  when :ios then preflight_client_ios_signing!
  when :macos then preflight_client_macos_signing!
  end

  # 2. Archive
  xcodebuild_archive!(
    project: PROJECT_PATH,
    scheme: CLIENT_SCHEME,
    destination: config[:destination],
    archive_path: archive_path,
    use_api_auth: true
  )

  # 3. Export (generates .ipa or .pkg depending on platform)
  xcodebuild_export!(
    archive_path: archive_path,
    export_path: export_dir,
    plist_path: plist_path,
    use_api_auth: true
  )

  # 4. Upload dSYMs to Sentry for crash symbolication
  sentry_upload_dsyms!(archive_path: archive_path,
                       sentry_project: SENTRY_CLIENT_PROJECT)

  # 5. Upload to TestFlight (with retry)
  max_upload_attempts = 3
  max_upload_attempts.times do |attempt|
    begin
      upload_to_testflight(upload_options)
      break
    rescue => e
      remaining = max_upload_attempts - attempt - 1
      if remaining > 0
        delay = 30 * (attempt + 1)
        sleep(delay)
      else
        raise
      end
    end
  end
end

The retry loop around upload_to_testflight was added after a few too many failures caused by transient App Store Connect errors. Apple's upload API occasionally returns 500s or connection resets, especially under heavy load. Three attempts with increasing delays (30s, 60s, 90s) handles this reliably without masking real errors.

The Server Build: Notarization and Deployment

The server path is more complex because we manage everything Apple would normally handle. The server_all lane runs four sub-lanes in sequence:

lane :server_all do
  ensure_deploy_host_reachable!   # fail fast if VPN is down
  server_macos                    # archive, export, notarize, staple
  server_deploy                   # zip, scp, symlink
  appcast_update                  # EdDSA sign, generate XML
  appcast_deploy                  # scp, Cloudflare purge
end

The ensure_deploy_host_reachable! check at the top is important. The web server is behind a VPN, and if the VPN is not connected, the SCP in server_deploy will hang for 30 seconds before timing out. Checking SSH connectivity upfront (with an 8-second timeout) saves time:

def ensure_deploy_host_reachable!
  sh([
    "ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8",
    DEPLOY_HOST, "exit 0"
  ].shelljoin)
rescue StandardError
  UI.user_error!("Cannot connect to the web server. Is your VPN connected?")
end

Notarization

Apple requires all Developer ID-signed apps to be notarized. This means uploading the app to Apple, waiting for them to scan it, and then "stapling" the approval ticket to the app so Gatekeeper does not complain on first launch.

The notarize_with_notarytool! function supports three credential strategies because Apple has changed their recommended approach twice:

  1. Keychain profile (current recommendation): xcrun notarytool store-credentials saves credentials to the Keychain once; subsequent calls use --keychain-profile
  2. App Store Connect API key: .p8 file + key ID, same credentials as TestFlight upload
  3. Apple ID + app-specific password: The oldest approach, still works

The function tries them in order of preference and fails with a clear message if none are configured.

Stapling is where the retry logic lives. After notarytool submit --wait returns success, the app is notarized, but Apple's CDN may not have propagated the ticket yet. xcrun stapler staple queries Apple's servers for the ticket and embeds it in the .app; if the ticket has not propagated, it fails. In my experience, the first attempt fails about 20% of the time:

max_attempts = 5
max_attempts.times do |attempt|
  begin
    sh("xcrun stapler staple #{target}")
    break
  rescue StandardError => e
    remaining = max_attempts - attempt - 1
    if remaining > 0
      delay = 15 * (attempt + 1)  # 15s, 30s, 45s, 60s
      sleep(delay)
    else
      UI.user_error!("Stapling failed after #{max_attempts} attempts")
    end
  end
end

If notarization itself fails (returns status "Invalid"), the function fetches the notarization log via notarytool log and prints it before failing. This is critical for diagnosis; without the log, a notarization rejection gives you no information about what Apple objected to.

Server Deployment

After notarization, the server needs to reach the web server. The server_deploy lane reads version numbers from the notarized app, creates a versioned ZIP, uploads it via SCP, and updates a symlink:

# Create versioned filename: ShowShark-Server-20260323-55.zip
zip_name = "ShowShark-Server-#{version_slug}-#{bundle_version}.zip"

# Upload to web server
sh("scp #{zip_path} #{DEPLOY_HOST}:#{DEPLOY_DIR}/")

# Atomically update the "latest" symlink
sh("ssh #{DEPLOY_HOST} 'cd #{DEPLOY_DIR} && " \
   "rm -f ShowShark-Server-latest.zip && " \
   "ln -s #{zip_name} ShowShark-Server-latest.zip'")

The symlink pattern means every version is preserved on the server (useful for rollbacks) while the appcast always points to a stable URL. The remove-then-symlink is not perfectly atomic; ln -sf would be cleaner, but the two-step version is explicit about what it does.

Appcast Generation and CDN Purge

The appcast XML is what Sparkle clients poll to discover updates. It is generated by Scripts/generate-appcast.sh, which signs the ZIP with Sparkle's sign_update tool (EdDSA, private key in the Keychain) and produces a minimal RSS document with the signature, file size, and version numbers. Part 11 covers this in detail.

The CDN cache purge deserves emphasis because skipping it is a mistake you make exactly once. The appcast and the download ZIP are both served through Cloudflare. After uploading a new version, the old version remains cached at Cloudflare's edge nodes. Without an explicit purge, users check for updates and are told they are already running the latest version because Cloudflare is still serving the old appcast. The purge is surgical; it targets only the two relevant URLs:

purge_cloudflare_cache_by_url!(latest_download_url)  # in server_deploy
purge_cloudflare_cache_by_url!(APPCAST_URL)           # in appcast_deploy

Notifications and Completion Hooks

The Fastfile ends with two hooks that fire on every lane:

after_all do |lane|
  send_pushover(title: "ShowShark", message: "#{lane} completed successfully")
end

error do |lane, exception|
  send_pushover(
    title: "ShowShark FAILED",
    message: "#{lane}: #{exception.message}"[0, 512],
    priority: 1
  )
end

send_pushover is a lightweight wrapper around Pushover's HTTP API. Success notifications use the default sound; failure notifications use the siren sound and priority 1 (bypasses Do Not Disturb). The entire integration is 15 lines of Ruby.

This matters because fastlane all takes about 20 minutes. I start it and switch to something else. The push notification tells me whether I need to go back and fix something or if I can move on with my day.

Sentry Debug Symbols

After each archive, the Fastfile uploads dSYMs to Sentry:

def sentry_upload_dsyms!(archive_path:, sentry_project:)
  auth_token = env_value("SENTRY_AUTH_TOKEN")
  if auth_token.empty?
    UI.important("Skipping Sentry dSYM upload (SENTRY_AUTH_TOKEN not set)")
    return
  end

  dsyms_path = File.join(archive_path, "dSYMs")
  sh("sentry-cli debug-files upload " \
     "--auth-token #{auth_token} " \
     "--org #{SENTRY_ORG} --project #{sentry_project} #{dsyms_path}")
end

Without debug symbols, crash reports from production are hex addresses. With them, you get file names, line numbers, and function names. The upload is quick (a few seconds) and happens in parallel with the export step conceptually, though in practice it runs sequentially after export.

The Full Pipeline

Here is what happens when I run fastlane all:

  fastlane all
       │
       ├── client_all
       │     │
       │     ├── preflight_client_macos_signing!
       │     ├── bump_build_number
       │     │
       │     ├── client_ios
       │     │     ├── preflight_client_ios_signing!
       │     │     ├── xcodebuild archive (iOS)
       │     │     ├── xcodebuild -exportArchive (.ipa)
       │     │     ├── sentry-cli upload dSYMs
       │     │     └── upload_to_testflight (with retry)
       │     │
       │     ├── client_tvos
       │     │     ├── xcodebuild archive (tvOS)
       │     │     ├── xcodebuild -exportArchive (.ipa)
       │     │     ├── sentry-cli upload dSYMs
       │     │     └── upload_to_testflight (with retry)
       │     │
       │     ├── client_macos
       │     │     ├── preflight_client_macos_signing!
       │     │     ├── xcodebuild archive (macOS)
       │     │     ├── xcodebuild -exportArchive (.pkg)
       │     │     ├── sentry-cli upload dSYMs
       │     │     └── upload_to_testflight (with retry)
       │     │
       │     └── client_visionos
       │           ├── xcodebuild archive (visionOS)
       │           ├── xcodebuild -exportArchive (.ipa)
       │           ├── sentry-cli upload dSYMs
       │           └── upload_to_testflight (with retry)
       │
       └── server_all
             │
             ├── ensure_deploy_host_reachable! (VPN check)
             │
             ├── server_macos
             │     ├── xcodebuild archive (Developer ID)
             │     ├── xcodebuild -exportArchive (.app)
             │     ├── ditto (zip for notarization)
             │     ├── notarytool submit --wait
             │     ├── stapler staple (with retry)
             │     └── sentry-cli upload dSYMs
             │
             ├── server_deploy
             │     ├── ditto (versioned distribution zip)
             │     ├── scp to web server
             │     ├── update "latest" symlink
             │     └── purge Cloudflare cache (ZIP URL)
             │
             ├── appcast_update
             │     ├── sign_update (EdDSA)
             │     └── generate appcast.xml
             │
             └── appcast_deploy
                   ├── scp appcast.xml to web server
                   └── purge Cloudflare cache (appcast URL)

  Completion hook: Pushover notification (success or failure)

Five platforms. Two distribution channels. Three authentication contexts. One command. About 30 minutes end to end, most of which is Apple processing things on their servers.

Lessons Learned the Hard Way

Do not use build_app / gym if your project uses newer Xcode features. The xcodeproj gem that build_app depends on may not parse your project correctly. Raw xcodebuild with a generated ExportOptions plist is more verbose but does not break when Xcode's project format evolves. The five extra lines of code save hours of debugging corrupted exports.

Validate signing before building. The preflight checks add about three seconds to the pipeline. A signing failure after a 90-second archive build wastes those 90 seconds plus however long you spend figuring out what went wrong. Checking provisioning profile and certificate compatibility upfront is a trivial time investment that catches the most common failure mode.

Treat notarization stapling as eventually consistent. Apple's notarization service returns "approved" before the approval ticket has propagated to all their CDN nodes. Stapling queries those nodes. A retry loop with backoff is required, not optional.

Read notarization logs on failure. When notarytool submit returns "Invalid," the default behavior is to tell you nothing useful. Fetching the log via notarytool log <submission-id> gives you the specific reason: unsigned framework, restricted entitlement, whatever it is. The Fastfile does this automatically so you never have to remember to do it manually.

Purge your CDN. If your download URL or update feed passes through any caching layer, invalidate the cache after every deploy. This is not optional. Stale caches are invisible until someone reports they cannot get the latest version, and then you spend an hour wondering what you did wrong before realizing the problem is not your server.

Keep credentials in .env, not in the Fastfile. This sounds obvious, but it is easy to hardcode a path or API key "temporarily" and forget about it. The .env.example file documents every variable with its purpose; the real .env is gitignored. Rebuilding credentials from scratch should require reading one file, not reverse-engineering the Fastfile.

Make integrations optional. Sentry, Cloudflare, and Pushover are all gated on their respective environment variables being set. If SENTRY_AUTH_TOKEN is empty, dSYM upload is silently skipped. This means the core build-and-deploy pipeline works on a fresh machine with only the essential credentials (App Store Connect key + notarization profile), and integrations are added incrementally as you set up each service.

Ruby version compatibility is a real problem. Ruby 4.0 removed the xml_attr_content encoding that Fastlane's XML handling depends on. The fix is a four-line wrapper script that forces Ruby 3.3:

#!/bin/bash
export PATH="/usr/local/opt/[email protected]/bin:$PATH"
exec fastlane "$@"

This is the kind of thing that breaks with no warning after a system update and takes an hour to diagnose. The wrapper script is cheap insurance.

Send yourself notifications. A 30-minute build is long enough to context-switch into something else, and long enough to forget you started it. A push notification on success or failure keeps you from discovering at the end of the day that the tvOS upload failed four hours ago and you never noticed.

Was It Worth It

The Fastfile took a couple days of cumulative effort across 20 commits, from the first "does not work" commit to the current 1,115-line pipeline. Each of those 20 commits fixed something that had gone wrong: a signing mismatch, a notarization failure, a missing retry, a Ruby compatibility break.

Since reaching stability, I have run fastlane all or its sub-lanes for every single release. Each run saves approximately 45 minutes of manual work across five platforms, plus the cognitive overhead of remembering every step and doing them in the right order. Over the course of 55 build numbers, that is a substantial amount of time not spent clicking through Xcode's archive organizer.

The honest answer is that Fastlane is not pleasant to set up. Apple's toolchain has too many authentication mechanisms, too many signing configurations, and too many undocumented behaviors for any wrapper to make it easy. But once you get through the setup, the payoff is real. One command, five platforms, two distribution channels, and a push notification when it is done. I would set it up again.