Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Jenkins iOS Build Pipeline on a Remote Mac

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.

Pick the Agent Connection That Matches Your Plan

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 planConnection directionReason
M-series Mac (from $95/mo)Inbound from the Jenkins controller over SSH, if your quote enables SSHPhysical M-series can expose SSH per your configuration quote.
Managed Build Mac ($249/mo)Inbound SSH, agent already configuredInstalled and auto-reconnecting on your behalf.
Virtual Intel macOS ($29/mo)Outbound JNLP/WebSocket, launched from the VM's browser-console terminalDirect 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.

Reader tool: 60-second pre-flight script

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 Tools Under the CI User

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.

Write the Jenkinsfile (Five Declarative Stages)

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.
  • The 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.
  • The code signing identity is chosen through ExportOptions-AppStore.plist and the project's Signing & Capabilities panel, not on the xcodebuild command line, which keeps secrets out of ps aux output.
  • There are no benchmark numbers here; timings depend on your project, scheme, and Mac hardware class.

Keychain, Signing, and Secret Hygiene

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:

  • Do not replace the user's keychain search list. If 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.
  • Signing certificate: export a password-protected .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.
  • Provisioning profiles are files, not Keychain entries. Copy each .mobileprovision to ~/Library/MobileDevice/Provisioning Profiles/.
  • Never dump the whole environment to a plaintext file to move it between machines — environment blocks can carry API keys and signing passwords. Transfer the required names only and re-populate the values from your secret manager on the destination Mac.
  • Allowlists apply to services you control (your Jenkins controller, artifact store, Git host). Apple does not publish a stable IP range for App Store Connect or TestFlight; do not invent one.
  • Umask matters: run 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.

Webhooks, Archives, and Reboot Recovery

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:

  • SSH-launched M-series Mac: keep Remote Login enabled in System Settings → General → Sharing. Jenkins reconnects on the next scheduled or manual build.
  • $29 virtual Intel VM using a JNLP agent: install a user LaunchAgent that runs 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.

Where Figment Fits

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:


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms