
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.
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:
.p8 private key file.sudo xcodebuild -license accept).Install Fastlane via Bundler in your project directory:
echo 'gem "fastlane"' >> Gemfile
bundle install
bundle exec fastlane --version
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 name | Value |
|---|---|
ASC_KEY_ID | The 10-character key identifier |
ASC_ISSUER_ID | The UUID on the API keys page |
ASC_KEY_CONTENT | Contents 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/.
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.
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:
| Parameter | Purpose |
|---|---|
skip_waiting_for_build_processing | Return immediately after upload; don't block the runner for Apple's processing queue |
groups | Internal tester group names to distribute to after processing |
changelog | "What to Test" text visible to testers |
distribute_external | External distribution requires Apple review; keep false for internal groups |
wait_processing_timeout_duration | Override 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.
beta LanePlace 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.
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.
Treat this as a decision table, not a quote. Confirm hardware and availability in the configuration request on /mac-hosting/start.
| Need | Plan | Entry price | What you still do |
|---|---|---|---|
| Hobby/side project, one beta per week, you already run Fastlane | M-series Mac | $95/month | Install Xcode, set up keychain and Fastlane yourself |
| Team of 1–3, want CI, backups, monitoring, light support | Managed Build Mac | $249/month | Hand over bundle ID, signing assets, and the Fastfile; Figment wires the runner |
| Two Macs, two runners, one hour monthly support | Managed Mac Team | $999/month | Split 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.
"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.