
Raw xcodebuild -exportArchive commands spread flags across pipeline YAML, hide export options in a plist, and bury errors in multi-thousand-line logs. Fastlane's gym action (an alias for build_app) wraps the whole archive-and-export sequence in one readable config. This guide gives you a portable Gymfile, a Fastfile lane that asserts the IPA path, the match wiring for signing, and a clear recommendation on which hosted Mac to run it on.
for a single-app team doing Gym builds a few times a day, start with a Figment M-series Mac from $95/mo (exact chip, RAM, and disk confirmed in the quote because physical hardware is request-based). If you want the runner, backups, monitoring, and the initial CI pipeline wired for you, the Managed Build Mac plan at $249/mo bundles one M-series Mac with CI setup and 30 minutes of monthly support. The $29/mo virtual Intel macOS VM can run Xcode 26 or earlier and sign IPAs for supported Intel targets, but if your release target needs Xcode 27 or an iOS Simulator slice that is Apple‑silicon‑only, pick the M-series Mac. See /mac-hosting for plans and /mac-hosting/start to open a configuration request.
Gym runs both steps in one pass: xcodebuild archive then xcodebuild -exportArchive. It applies xcpretty formatting so errors are readable, and it writes the raw xcodebuild log under ~/Library/Logs/gym/ for post-mortems — both confirmed in the gym docs. A Gymfile at the project root keeps your pipeline YAML short; no reviewer needs to parse a 20-flag xcodebuild invocation to understand what the build step does.
Install fastlane once per runner. On a hosted Mac, bundle it with your project so CI uses a pinned version:
# Project-local, reproducible
bundle init
bundle add fastlane
bundle exec fastlane --version
The Gymfile is a Ruby DSL file at fastlane/Gymfile. Every field has a default, so you only specify what differs. The fields you will almost always set:
# fastlane/Gymfile
# Required: which scheme to build. Use the exact scheme from your project.
scheme(ENV.fetch("APP_SCHEME"))
# Use workspace if you have CocoaPods or multi-project setups
workspace(ENV.fetch("APP_WORKSPACE"))
# Build configuration (default: Release)
configuration("Release")
# Export method: "app-store", "ad-hoc", "development", "enterprise"
export_method("app-store")
# Where to write the IPA
output_directory(ENV["RUNNER_TEMP"] || "./build")
# Explicit IPA filename (default: scheme name)
output_name(ENV.fetch("APP_SCHEME"))
# Extra xcodebuild flags passed verbatim
xcargs("ONLY_ACTIVE_ARCH=NO")
# Keep xcpretty enabled for readable output
xcpretty_args("--color --simple")
Driving scheme and workspace from environment variables keeps this Gymfile reusable across projects — pick values that match your own Xcode project rather than hard-coding a placeholder. xcargs appends KEY=VALUE build settings directly to xcodebuild; use it when there is no dedicated Gymfile key.
The Fastfile lane orchestrates signing (match), the build (gym), and an explicit check that the IPA landed where downstream steps expect it.
# fastlane/Fastfile
default_platform(:ios)
platform :ios do
desc "Build a signed App Store IPA and assert the output file exists"
lane :build_release do
match(
type: "app-store",
readonly: is_ci,
app_identifier: ENV.fetch("APP_BUNDLE_ID")
)
gym
ipa_path = lane_context[SharedValues::IPA_OUTPUT_PATH]
UI.user_error!("IPA_OUTPUT_PATH not set by gym") if ipa_path.nil?
UI.user_error!("IPA not found at #{ipa_path}") unless File.exist?(ipa_path)
UI.success("IPA produced at: #{ipa_path}")
end
end
is_ci returns true when the CI environment variable is set, which GitHub Actions, Buildkite, and most CI platforms do automatically. Readonly match fetches existing certificates and profiles without generating new ones — the right default in CI, per the match docs.
The path assertion catches the case where gym exits zero but downstream steps (TestFlight upload, artifact upload) look at the wrong path. SharedValues::IPA_OUTPUT_PATH is the documented lane-context key for the generated IPA.
Match stores encrypted certificates and provisioning profiles in a Git repository, S3, or Google Cloud Storage. For Git, set the passphrase through the documented MATCH_PASSWORD environment variable so the decrypt step runs without a prompt. Put the repository URL in a Matchfile:
# fastlane/Matchfile
git_url("git@github.com:your-org/certificates.git")
storage_mode("git")
type("appstore")
In CI, provide only the secret:
MATCH_PASSWORD=<your-match-passphrase>
Keep the passphrase in your CI secret store (GitHub Actions encrypted secrets, Buildkite agent-level secrets, etc.) — not in the Fastfile or any file in source control. Match installs the profile and sets PROVISIONING_PROFILE_SPECIFIER, which gym picks up automatically.
Signing material moving to a new runner should transfer as an encrypted .p12 for the signing identity (certificate + private key) plus the .mobileprovision files on disk. Provisioning profiles are files, not Keychain items; copy them from ~/Library/MobileDevice/Provisioning Profiles/ rather than exporting from Keychain.
The Gymfile has no CI-platform-specific syntax. The same file runs on GitHub Actions self-hosted runners, Buildkite agents, and a Figment Mac without edits. What differs is how the output directory and secrets are wired.
GitHub Actions:
# .github/workflows/build.yml
jobs:
build:
runs-on: [self-hosted, macOS]
env:
RUNNER_TEMP: ${{ runner.temp }}
APP_SCHEME: MyApp
APP_WORKSPACE: MyApp.xcworkspace
APP_BUNDLE_ID: com.example.MyApp
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
steps:
- uses: actions/checkout@v4
- run: bundle install
- run: bundle exec fastlane build_release
- uses: actions/upload-artifact@v4
with:
name: ipa-${{ github.sha }}
path: ${{ runner.temp }}/MyApp.ipa
Buildkite:
# .buildkite/pipeline.yml
steps:
- label: "Build release IPA"
command: "bundle install && bundle exec fastlane build_release"
env:
RUNNER_TEMP: "build"
APP_SCHEME: "MyApp"
APP_WORKSPACE: "MyApp.xcworkspace"
APP_BUNDLE_ID: "com.example.MyApp"
agents:
queue: "mac-runners"
artifact_paths: "build/MyApp.ipa"
The Gymfile and Fastfile are identical in both cases; only the ambient environment and the artifact-upload step differ.
When gym fails, read the raw xcodebuild log that gym wrote, not the xcpretty summary:
ls ~/Library/Logs/gym/
grep -nE "error:|note: Using|CodeSign" ~/Library/Logs/gym/MyApp-MyApp.log | head -40
Common failures and where to look:
| Symptom | Where to look in the raw log |
|---|---|
| Signing identity not found | error: near CodeSign lines |
| Provisioning profile mismatch | embedded.mobileprovision lines |
| Archive missing device slice | ONLY_ACTIVE_ARCH or arm64 lines |
| Missing scheme | does not contain a scheme named |
| xcpretty hid the error | re-run with gym(disable_xcpretty: true) |
For a one-off diagnostic run, pass verbose through to xcodebuild from the lane:
gym(verbose: true)
Leave it off by default; the output is large and slows log ingestion.
A quick worksheet for a team deciding between Figment plans for this exact task:
If you are comparing against other hosts for an equivalent M4 class, check their current pages directly: CloudMac publishes a $99.99/month M4/16 GB/256 GB offer and a $999/year annual on cloudmac.org/pricing and cloudmac.org (effective $83.25/month if you prepay the year, with the 256 GB tier marked Reserve now and larger tiers Fully reserved at the time of writing). Figment's $95 entry is an unspecified M-series — a lower starting price, not a like-for-like M4/16 GB match — and the Managed Build and Managed Team plans are the differentiator when you want the CI pipeline delivered, not just the Mac. Open a quote at /mac-hosting/start with your Xcode version, scheme list, and concurrency target, and we will size the plan to it.
Related guides: