Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Automate TestFlight Uploads from a CI Mac

Run this Fastlane TestFlight pipeline on a Figment M-series Mac (entry price $95/month; physical plans are request-based, so the exact chip, RAM, and disk are confirmed in the quote) or on the Managed Build Mac at $249/month, which includes CI setup, backups, monitoring, and 30 minutes of monthly support on one M-series Mac. Current prices and inclusions are on the Figment Mac hosting page. The $29/month virtual Intel macOS plan (2 vCPU, 8 GiB RAM, 80 GiB disk, Linux/KVM, no GPU or audio) can run Fastlane and xcodebuild with supported Intel Mac toolchains such as Xcode 26 or earlier per Apple's porting guide; it is a real macOS environment. Choose M-series when you need ARM-native execution or Apple-silicon-only tooling.

Shipping a weekly TestFlight beta by hand means opening Xcode, waiting for an archive, signing in Transporter, and babysitting an upload. Automating it with Fastlane turns the whole sequence into a single command on a schedule. This guide builds a beta lane that bumps the build number, archives, and uploads—fully headless. Primary references used throughout: the Fastlane upload_to_testflight documentation and the App Store Connect API documentation.

Prerequisites: App Store Connect API Key and Fastlane

Fastlane supports two authentication methods. The App Store Connect API key is the right choice for CI: it uses a short-lived JWT, never triggers a two-factor challenge, and works without an Apple ID session.

You need:

  • App Store Connect API key — requires Admin or App Manager role in App Store Connect. The key is a Key ID, an Issuer ID, and a .p8 private key file.
  • Fastlane installed on the CI Mac via Bundler (pin the version per project).
  • Xcode installed and licensed on the Mac (sudo xcodebuild -license accept).

Install Fastlane via Bundler in your project directory:

echo 'gem "fastlane"' >> Gemfile
bundle install
bundle exec fastlane --version

Generating and Storing the App Store Connect API Key as a CI Secret

In App Store Connect, go to Users and Access → Integrations → App Store Connect API, click +, name the key ci-upload, assign the App Manager role, and download the .p8 once (Apple only offers one download). Note the Key ID and Issuer ID shown on the same page.

Store three values as CI secrets:

Secret nameValue
ASC_KEY_IDThe 10-character key identifier
ASC_ISSUER_IDThe UUID on the API keys page
ASC_KEY_CONTENTContents of the .p8 file, base64-encoded

Never commit the .p8 to the repo. In GitHub Actions, add each value under Settings → Secrets and variables → Actions. Do not dump the shell environment to disk at any point—keys would land in a file. Only the specific .p8 content needs to be materialised, and only at runtime:

echo "$ASC_KEY_CONTENT" | base64 --decode > "$RUNNER_TEMP/asc_key.p8"
chmod 600 "$RUNNER_TEMP/asc_key.p8"

Signing certificates travel separately: export the distribution identity from your signing Mac as an encrypted .p12 (private key included) and import it into a dedicated CI keychain on first provisioning. Provisioning profiles are plain files; copy them into ~/Library/MobileDevice/Provisioning Profiles/.

Auto-Incrementing the Build Number

Duplicate build numbers cause upload failures. Query the current highest TestFlight build directly from App Store Connect and increment:

current = latest_testflight_build_number(
  api_key:        api_key,
  app_identifier: ENV.fetch("BUNDLE_ID"),
  version:        get_version_number(xcodeproj: ENV.fetch("XCODEPROJ"))
)
increment_build_number(build_number: current + 1, xcodeproj: ENV.fetch("XCODEPROJ"))

Scoping by version: keeps parallel release trains from stepping on each other.

Archiving with gym and Uploading with pilot

gym (alias build_app) drives xcodebuild archive and xcodebuild -exportArchive. A minimum App Store export call:

gym(
  workspace:        ENV.fetch("WORKSPACE"),
  scheme:           ENV.fetch("SCHEME"),
  configuration:    "Release",
  export_method:    "app-store",
  output_name:      "MyApp",
  output_directory: "build"
)

Use workspace: for CocoaPods projects; use xcodeproj: only for standalone projects. If you use manual signing, pass export_options with the profile name.

pilot (alias upload_to_testflight) submits the IPA. Useful parameters per the Fastlane docs:

ParameterPurpose
skip_waiting_for_build_processingReturn immediately after upload; don't block the runner for Apple's processing queue
groupsInternal tester group names to distribute to after processing
changelog"What to Test" text visible to testers
distribute_externalExternal distribution requires Apple review; keep false for internal groups
wait_processing_timeout_durationOverride default processing-wait timeout when skip_waiting_for_build_processing is false

Note Fastlane's own guidance: if skip_waiting_for_build_processing is true, distribute_external has no effect in the same run—processing must finish first.

The Complete beta Lane

Place this in fastlane/Fastfile. Replace the project-specific env names with your own:

default_platform(:ios)

BUNDLE_ID = ENV.fetch("BUNDLE_ID")
WORKSPACE = ENV.fetch("WORKSPACE")   # e.g. "MyApp.xcworkspace"
XCODEPROJ = ENV.fetch("XCODEPROJ")   # e.g. "MyApp.xcodeproj"
SCHEME    = ENV.fetch("SCHEME")      # your real scheme name

platform :ios do
  lane :beta do
    api_key = app_store_connect_api_key(
      key_id:       ENV.fetch("ASC_KEY_ID"),
      issuer_id:    ENV.fetch("ASC_ISSUER_ID"),
      key_filepath: ENV.fetch("ASC_KEY_PATH"),
      duration:     1200,
      in_house:     false
    )

    current_build = latest_testflight_build_number(
      api_key:        api_key,
      app_identifier: BUNDLE_ID,
      version:        get_version_number(xcodeproj: XCODEPROJ)
    )
    increment_build_number(build_number: current_build + 1, xcodeproj: XCODEPROJ)

    unlock_keychain(
      path:     "~/Library/Keychains/CI.keychain-db",
      password: ENV.fetch("KEYCHAIN_PASSWORD")
    )

    gym(
      workspace:        WORKSPACE,
      scheme:           SCHEME,
      configuration:    "Release",
      export_method:    "app-store",
      output_name:      "MyApp",
      output_directory: "build"
    )

    changelog = changelog_from_git_commits(
      commits_count:          10,
      pretty:                 "- %s",
      merge_commit_filtering: "exclude_merges"
    )

    upload_to_testflight(
      api_key:                           api_key,
      app_identifier:                    BUNDLE_ID,
      ipa:                               "build/MyApp.ipa",
      skip_waiting_for_build_processing: true,
      distribute_external:               false,
      groups:                            ["Internal Beta"],
      changelog:                         changelog
    )

    UI.success "Beta uploaded — build #{current_build + 1}"
  end
end

This is an unexecuted example; run it against your real project and scheme before scheduling. If any step fails, Fastlane halts and the lane exits non-zero—do not wrap it in || true, and never treat a failed archive as a successful run.

Nightly Trigger via GitHub Actions

A self-hosted runner on your Figment Mac routes jobs to the environment where Xcode, Fastlane, and the signing keychain already exist:

name: Nightly TestFlight Beta
on:
  schedule:
    - cron: '0 2 * * *'     # 02:00 UTC nightly
  workflow_dispatch:

concurrency:
  group: testflight-upload
  cancel-in-progress: false

jobs:
  beta:
    runs-on: [self-hosted, macOS]
    timeout-minutes: 45
    steps:
      - uses: actions/checkout@v4
      - name: Materialise API key
        run: |
          echo "$ASC_KEY_CONTENT" | base64 --decode > "$RUNNER_TEMP/asc_key.p8"
          chmod 600 "$RUNNER_TEMP/asc_key.p8"
        env:
          ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }}
      - name: Install gems
        run: bundle install
      - name: Upload to TestFlight
        run: bundle exec fastlane beta
        env:
          ASC_KEY_ID:        ${{ secrets.ASC_KEY_ID }}
          ASC_ISSUER_ID:     ${{ secrets.ASC_ISSUER_ID }}
          ASC_KEY_PATH:      ${{ runner.temp }}/asc_key.p8
          BUNDLE_ID:         ${{ secrets.BUNDLE_ID }}
          WORKSPACE:         MyApp.xcworkspace
          XCODEPROJ:         MyApp.xcodeproj
          SCHEME:            MyApp
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}

The concurrency block serialises uploads so two runs cannot both query latest_testflight_build_number at the same moment. Firewall allowlists, if you use them, belong on your own egress—there is no general Apple-published allowlist for App Store Connect ingress traffic to add.

Cost Worksheet: Which Figment Plan Fits Weekly Betas?

Treat this as a decision table, not a quote. Confirm hardware and availability in the configuration request on /mac-hosting/start.

NeedPlanEntry priceWhat you still do
Hobby/side project, one beta per week, you already run FastlaneM-series Mac$95/monthInstall Xcode, set up keychain and Fastlane yourself
Team of 1–3, want CI, backups, monitoring, light supportManaged Build Mac$249/monthHand over bundle ID, signing assets, and the Fastfile; Figment wires the runner
Two Macs, two runners, one hour monthly supportManaged Mac Team$999/monthSplit nightly and on-demand builds across runners

Twelve months of the entry M-series is $1,140; the Managed Build Mac adds $154/month over self-serve for CI setup, backups, monitoring, and 30 minutes of monthly support. If you expect to spend more than about 30 minutes a month fighting Xcode or signing drift, the managed tier pays for itself.

Troubleshooting

"No .p8 key file found." ASC_KEY_PATH points at a file that never materialised in this shell. Add ls -l "$ASC_KEY_PATH" before the Fastlane step.

Duplicate build number on upload. Two jobs raced. The concurrency block above fixes it; for Buildkite, use a concurrency group with limit 1.

"The request timed out." Large IPAs can exceed the default wait. Either keep skip_waiting_for_build_processing: true and check processing status in a later job, or raise wait_processing_timeout_duration—see the upload_to_testflight parameter reference.

"You don't have permission to perform this action." The API key's role is below App Manager. Reissue it with the correct role in App Store Connect.


Related guides

Ready to ship betas without opening Xcode? Start with a Figment Mac.


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms