
A PR build that runs on every push, never cancels stale jobs, and fires for a README edit wastes Mac runner minutes and teaches engineers to ignore CI. This guide gives you the complete GitHub Actions workflow for an iOS repo on a self-hosted Mac runner, plus the branch protection setting that makes the status check block the merge button.
By default, a pull_request workflow runs on three activity types: opened, synchronize, and reopened (GitHub Actions events docs). To also run when a draft is converted to ready, add ready_for_review explicitly:
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
branches: [main, 'release/**']
Without ready_for_review, promoting a draft does not trigger a build and the developer has to push an empty commit to unblock CI.
paths-ignore prevents the workflow from starting when only excluded files changed. For an iOS repo, exclude documentation, App Store metadata, and tool configuration that cannot affect the build:
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
branches: [main, 'release/**']
paths-ignore:
- '**.md'
- '**.txt'
- 'docs/**'
- '.github/CODEOWNERS'
- 'fastlane/metadata/**'
- 'fastlane/screenshots/**'
A PR that only touches README.md skips the workflow, and the merge button stays green if the last code commit passed. paths-ignore and paths are mutually exclusive at the same level — if you need include-and-exclude logic, write explicit paths include patterns instead.
When a developer pushes several commits in quick succession, each push queues a run. Without a concurrency policy you pay for builds nobody will read.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
The group key uses the PR number for pull requests (one group per PR) and falls back to github.ref for push events on main (so a merge to main never cancels an in-flight release build). cancel-in-progress is true only for PR events; main-branch builds run to completion.
Two strategies work. Strategy A relies on event types alone: ready_for_review runs once when the draft is promoted. Strategy B adds a job-level if condition and is easier to audit because the intent is visible in the job definition:
jobs:
ios-build-test:
name: iOS Build & Test # must match the branch protection check name
runs-on: [self-hosted, macOS]
if: github.event.pull_request.draft == false
With strategy A alone, pushing to a draft after it was converted back to draft still fires synchronize. Strategy B blocks that cleanly.
# .github/workflows/pr-build.yml
name: PR iOS Build
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
branches: [main, 'release/**']
paths-ignore:
- '**.md'
- '**.txt'
- 'docs/**'
- 'fastlane/metadata/**'
- 'fastlane/screenshots/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
ios-build-test:
name: iOS Build & Test # must match the required check name in branch protection
runs-on: [self-hosted, macOS]
if: github.event.pull_request.draft == false
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Select Xcode
run: sudo xcode-select --switch "${XCODE_PATH}"
env:
XCODE_PATH: ${{ vars.XCODE_PATH }} # e.g. /Applications/Xcode_16.2.app/Contents/Developer
- name: Resolve simulator UDID
id: sim
run: |
set -euo pipefail
UDID=$(xcrun simctl list devices available -j | python3 -c "
import sys, json
devs = json.load(sys.stdin)['devices']
print(next(d['udid'] for runtime in devs.values()
for d in runtime if 'iPhone' in d['name']))")
echo "udid=${UDID}" >> "$GITHUB_OUTPUT"
- name: Build and test
env:
XCODE_WORKSPACE: ${{ vars.XCODE_WORKSPACE }}
XCODE_SCHEME: ${{ vars.XCODE_SCHEME }}
run: |
set -euo pipefail
set -o pipefail
xcodebuild test \
-workspace "${XCODE_WORKSPACE}" \
-scheme "${XCODE_SCHEME}" \
-configuration Debug \
-destination "id=${{ steps.sim.outputs.udid }}" \
-resultBundlePath build/results.xcresult \
-jobs 4 \
ONLY_ACTIVE_ARCH=YES \
| xcbeautify
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results-${{ github.run_id }}
path: build/results.xcresult
retention-days: 7
Fill XCODE_WORKSPACE and XCODE_SCHEME from your project — this file does not know your scheme name, and inventing one here would silently fail with "scheme not found" on the first run. The scheme string must match a shared scheme checked into *.xcodeproj/xcshareddata/xcschemes/ or *.xcworkspace/xcshareddata/xcschemes/.
Branch protection makes the status check mandatory before merge (GitHub protected branches docs):
main.iOS Build & Test — the exact job name: value.The GitHub docs explicitly warn that job names must be unique across workflows; two workflows with a job called build produce ambiguous check results. Prefix with the area: iOS Build & Test, not build.
A 25-minute PR build gives developers time to context-switch and forget what the PR was about. Target 10 minutes or less. Options, in order of leverage:
| Approach | Typical impact | Tradeoff |
|---|---|---|
Separate unit and UI test schemes; run only unit on PRs, full suite nightly on main | Large | UI regressions caught at merge, not before |
| Test-impact analysis: run only targets whose sources changed | Large | Requires tooling and a dependency graph |
| Incremental build with cached DerivedData | Medium | Stale cache can mask failures — invalidate on Podfile/Package.resolved change |
ONLY_ACTIVE_ARCH=YES, -jobs <cores> | Small | None for PR builds; release builds need all archs |
Pre-boot the simulator before xcodebuild test | Small | Removes cold-boot delay from the measured window |
The two-scheme split is usually the fastest path to under 10 minutes without adopting new tooling.
GitLab merge request pipelines:
# .gitlab-ci.yml
ios-build-test:
rules:
- if: $CI_MERGE_REQUEST_IID
changes:
- "**/*.swift"
- "**/*.xcodeproj/**"
- Podfile.lock
when: on_success
script:
- xcodebuild test -workspace "$XCODE_WORKSPACE" -scheme "$XCODE_SCHEME" -destination "$DEST"
$CI_MERGE_REQUEST_IID is set only for MR pipelines, not branch pipelines. changes: is an allowlist (like paths: in GitHub Actions), not a denylist — list what should trigger, not what to skip.
Buildkite: Gate the step with if: build.pull_request.id != null and use a pre-command hook or a monorepo-diff plugin to skip when only docs changed.
PR builds need macOS to run Xcode and the iOS simulator. Here is how Figment's catalog maps to this specific job:
Physical and M-series plans collect requests until supply is confirmed, and specific chip/RAM/disk at the $95 entry price is settled in the quote rather than advertised as one SKU. For build-time comparisons against your current runner, measure your own project — vendor speed claims (ours included) are not a substitute for a timed xcodebuild run on your branch.
Start a quote at /mac-hosting or jump straight to /mac-hosting/start.
Related guides: