
Answer up front. Run Jenkins on your existing Linux controller and attach a Figment Mac as an agent that executes every iOS-specific step. For a straightforward team setup, pick the M-series Mac from $95/month — the exact chip, RAM, and disk are confirmed in your configuration quote; $95 is the entry, not a specific M4/16 GB claim. If you want Xcode, CocoaPods/Bundler, the keychain, and the Jenkins agent configured for you, the Managed Build Mac at $249/month includes CI setup, backups, monitoring, and 30 minutes of monthly support. The $29/month virtual Intel macOS VM (2 vCPU, 8 GiB RAM, 80 GiB disk; Linux/KVM, no GPU, no audio, not Apple silicon) can run supported Intel Xcode 26 or earlier toolchains for cross-compile and signing of supported targets, but it does not execute arm64 native builds and does not currently expose inbound public SSH — attach it to Jenkins as an outbound JNLP agent launched from its browser-console terminal. Physical plans are quote-based; start at /mac-hosting/start.
Primary references I lean on in this guide: Jenkins' declarative pipeline syntax and managing nodes.
Jenkins supports two directions for agent connections. The right choice depends on which Figment product you use and which side of the connection can initiate it.
| Figment plan | Connection direction | Reason |
|---|---|---|
| M-series Mac (from $95/mo) | Inbound from the Jenkins controller over SSH, if your quote enables SSH | Physical M-series can expose SSH per your configuration quote. |
| Managed Build Mac ($249/mo) | Inbound SSH, agent already configured | Installed and auto-reconnecting on your behalf. |
| Virtual Intel macOS ($29/mo) | Outbound JNLP/WebSocket, launched from the VM's browser-console terminal | Direct public SSH to the $29 VM is not currently implemented; the Mac initiates the agent connection to Jenkins. |
Both paths produce the same Jenkinsfile; only the java -jar agent.jar launch versus the SSH launch differs on the controller side.
Run this on the Mac first — it fails fast if a build dependency is missing, instead of discovering it mid-pipeline. Save as preflight.sh and chmod +x:
#!/usr/bin/env bash
set -euo pipefail
echo "Running as: $(whoami) on $(sw_vers -productName) $(sw_vers -productVersion), arch $(uname -m)"
for tool in java xcodebuild xcrun git brew bundle pod; do
if command -v "$tool" >/dev/null 2>&1; then
printf ' OK %-12s -> %s\n' "$tool" "$(command -v "$tool")"
else
printf ' MISS %-12s (install before continuing)\n' "$tool"
fi
done
xcodebuild -version
xcrun simctl list runtimes | head -5
security list-keychains -d user
If xcodebuild is missing, open Xcode once from the Applications folder and run sudo xcodebuild -license accept. If brew is missing, install Homebrew under the ci user — not root. If uname -m returns x86_64 on the $29 VM, pin your pipeline to an Xcode release that still supports Intel; consult the exact row in Apple's Xcode release notes.
Install everything as the ci user that Jenkins will actually use, not under your own login:
# 1) Java 21 for the Jenkins agent
brew install openjdk@21
# 2) CocoaPods via Homebrew (avoids system-Ruby permission issues)
brew install cocoapods
# 3) Bundler for per-project Fastlane / xcpretty pins
gem install --user-install bundler
Confirm the PATH the Jenkins agent will actually see:
sudo -u ci -i bash -lc 'which xcodebuild pod bundle java'
Add the correct Homebrew prefix to ~ci/.zprofile: /opt/homebrew on Apple silicon, /usr/local on Intel. Append; do not overwrite an existing PATH.
Replace MyApp with your project's actual scheme and workspace before the first run. The pipeline fails fast if they do not exist — that is intentional, so a typo cannot be reported as green. This code has not been executed against a specific project in this article; treat it as a template to read, adapt, and dry-run against your own repository.
// Jenkinsfile — iOS CI pipeline on a Figment Mac node
pipeline {
agent { label 'mac && ios' }
options {
timeout(time: 60, unit: 'MINUTES')
buildDiscarder(logRotator(numToKeepStr: '30'))
disableConcurrentBuilds()
}
parameters {
string(name: 'SCHEME', defaultValue: 'MyApp', description: 'Xcode scheme in your project')
string(name: 'WORKSPACE_FILE', defaultValue: 'MyApp.xcworkspace', description: 'Workspace file')
string(name: 'SIM_DEVICE', defaultValue: 'iPhone 16', description: 'Simulator for the test stage')
}
environment {
ARCHIVE_PATH = 'build/MyApp.xcarchive'
EXPORT_DIR = 'build/export'
// Secret text binding: a single variable, no _USR / _PSW suffix.
// _USR / _PSW are only added for Username-with-password credentials.
KEYCHAIN_PW = credentials('ci-keychain-password')
}
stages {
stage('Checkout') {
steps {
checkout scm
sh 'git submodule update --init --recursive'
}
}
stage('Install Dependencies') {
steps {
sh '''
set -euo pipefail
bundle config set --local path vendor/bundle
bundle install
bundle exec pod install
'''
}
}
stage('Build & Archive') {
steps {
sh '''
set -euo pipefail
security unlock-keychain -p "$KEYCHAIN_PW" \
"$HOME/Library/Keychains/CI.keychain-db"
xcodebuild archive \
-workspace "$WORKSPACE_FILE" \
-scheme "$SCHEME" \
-configuration Release \
-destination "generic/platform=iOS" \
-archivePath "$ARCHIVE_PATH"
'''
}
}
stage('Test') {
steps {
sh '''
set -euo pipefail
set -o pipefail
xcodebuild test \
-workspace "$WORKSPACE_FILE" \
-scheme "$SCHEME" \
-destination "platform=iOS Simulator,name=${SIM_DEVICE},OS=latest" \
-resultBundlePath build/TestResults.xcresult \
| bundle exec xcpretty --report junit --output build/test-results.xml
'''
junit 'build/test-results.xml'
}
}
stage('Export IPA') {
steps {
sh '''
set -euo pipefail
xcodebuild -exportArchive \
-archivePath "$ARCHIVE_PATH" \
-exportOptionsPlist ExportOptions-AppStore.plist \
-exportPath "$EXPORT_DIR"
'''
archiveArtifacts artifacts: 'build/export/**/*.ipa', fingerprint: true
}
}
}
post {
always {
// Best-effort cleanup only. A hard machine failure or force-termination
// can prevent this block from running — do not rely on it for secret hygiene.
sh '''
security lock-keychain "$HOME/Library/Keychains/CI.keychain-db" || true
rm -rf "$HOME/Library/Developer/Xcode/DerivedData/ModuleCache.noindex" || true
'''
}
failure {
echo "Build ${env.BUILD_NUMBER} failed on ${env.BRANCH_NAME ?: 'detached'}"
}
}
}
Why these specific choices, each tied to a real failure mode:
set -euo pipefail plus set -o pipefail in the test stage make the shell exit on the first failed command, including the xcodebuild | xcpretty pipe. A failed test cannot be silently rewritten as a success.disableConcurrentBuilds() prevents two branches racing for the same keychain and DerivedData directory on one Mac.credentials('ci-keychain-password') binding for Secret text produces a single variable ($KEYCHAIN_PW). The _USR/_PSW pair is reserved for Username-with-password credentials — do not append _PSW to a Secret text variable.ExportOptions-AppStore.plist and the project's Signing & Capabilities panel, not on the xcodebuild command line, which keeps secrets out of ps aux output.Store the CI keychain password as a Jenkins Secret text credential with ID ci-keychain-password. The credentials() binding exposes it only inside the shell step and masks it in the console log. A few rules that keep the Mac agent out of trouble:
CI.keychain-db needs to be on the list, read the current list with security list-keychains -d user, append your entry, and restore the original list in a trap on exit. Overwriting the list silently breaks every other tool that relies on login.keychain..p12 (private key included) from a trusted Mac and import with security import cert.p12 -k "$HOME/Library/Keychains/CI.keychain-db" -P "$P12_PW" -T /usr/bin/codesign. The .p12 must include the private key — a certificate-only export cannot sign..mobileprovision to ~/Library/MobileDevice/Provisioning Profiles/.umask 077 before writing any secret file, not after.post { always {} } is best effort. Treat long-lived secrets on disk as if they will survive a crashed build and rotate them accordingly.For the end-to-end distribution flow, Apple's Distributing your app for beta testing and releases article documents the current archive → export → upload sequence that the pipeline above automates.
Trigger builds from your Git host. For GitHub, install the GitHub plugin and declare:
triggers { githubPush() }
Set the repository webhook payload URL to https://your-jenkins-host/github-webhook/ and choose push or pull-request events. For GitLab, install the GitLab plugin and use https://your-jenkins-host/project/<job-name>.
archiveArtifacts stores the IPA on the Jenkins controller. The buildDiscarder(numToKeepStr: '30') option keeps roughly the last 30 builds so neither the Mac's disk nor the controller's artifact store fills up with stale exports.
After a Mac reboot the agent does not come back on its own:
java -jar agent.jar -url <controller> -secret <value> -name <node> with KeepAlive=true and RunAtLoad=true, logging to ~/jenkins/agent.log. The exact command string and secret appear on the node's Status page in Jenkins. Launch the agent once from the VM's browser-console terminal; the LaunchAgent handles subsequent reboots.The concrete work Figment can take off a Jenkins team's plate is the setup you otherwise do by hand: Xcode installed and licensed, CocoaPods and Bundler on the CI user's PATH, a CI keychain pre-provisioned, the Jenkins agent set to auto-reconnect, and backups configured. That is the $249/month Managed Build Mac (one M-series Mac, CI setup, backups, monitoring, 30 minutes monthly support). For a two-runner team, Managed Mac Team at $999/month adds a second M-series Mac, two CI runners, team setup, and one hour of monthly support.
If your team already runs its own tooling, the M-series Mac from $95/month is the entry; the $29/month virtual Intel macOS VM is useful when you specifically need an Intel-compatible toolchain and outbound-only connectivity from the Mac. Exact chip, RAM, disk, taxes, fees, and term are whatever the configuration quote states — not what the entry price implies. No free trial, no uptime SLA, and no benchmark numbers are claimed here; agree an evaluation or managed-setup scope in writing before purchase.
Request a quote or managed setup at /mac-hosting/start, or review plans at /mac-hosting.
Related guides: