Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Trigger iOS CI Builds on Pull Request Events

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.

PR Trigger Events: opened, synchronize, reopened, and ready_for_review

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.

Skipping Redundant Runs: paths-ignore for Non-Code Changes

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.

Cancelling Stale Runs with a Concurrency Group

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.

Skipping Draft PRs Until Marked Ready for Review

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.

The Complete Workflow

# .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/.

Posting a Required Status Check That Blocks the Merge Button

Branch protection makes the status check mandatory before merge (GitHub protected branches docs):

  1. Settings › Branches › Add branch ruleset (or Branch protection rule) for main.
  2. Enable Require status checks to pass before merging.
  3. Search for and select iOS Build & Test — the exact job name: value.
  4. Optionally enable Require branches to be up to date before merging to block stale-base merges.

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.

Keeping the PR Build Fast: Subset vs Full Suite

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:

ApproachTypical impactTradeoff
Separate unit and UI test schemes; run only unit on PRs, full suite nightly on mainLargeUI regressions caught at merge, not before
Test-impact analysis: run only targets whose sources changedLargeRequires tooling and a dependency graph
Incremental build with cached DerivedDataMediumStale cache can mask failures — invalidate on Podfile/Package.resolved change
ONLY_ACTIVE_ARCH=YES, -jobs <cores>SmallNone for PR builds; release builds need all archs
Pre-boot the simulator before xcodebuild testSmallRemoves cold-boot delay from the measured window

The two-scheme split is usually the fastest path to under 10 minutes without adopting new tooling.

Equivalent Setup for GitLab and Buildkite

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.

Mac Runner for This Pipeline

PR builds need macOS to run Xcode and the iOS simulator. Here is how Figment's catalog maps to this specific job:

  • Virtual Intel macOS, $29/mo (2 vCPU, 8 GiB RAM, 80 GiB disk). Runs a real macOS environment on Linux/KVM — no GPU acceleration and no audio. Compatible Intel Xcode toolchains (Xcode 26 or earlier) can build and sign supported targets; check the exact Xcode/macOS pair you depend on against the Apple release notes. A reasonable fit for a single-developer PR runner on a Swift-only target without GPU-bound UI tests.
  • i3 Mac, $49/mo (Intel). Same Intel toolchain constraint; physical hardware. Request-based while supply is confirmed.
  • M-series Mac from $95/mo. Required for Apple-silicon-only toolchains, including Xcode 27 which drops Intel per Apple's porting guide. Exact chip, RAM, and disk are confirmed in your quote.
  • Managed Build Mac, $249/mo. One M-series Mac with CI setup, backups, monitoring, and 30 minutes of monthly support — the shortcut if you want this exact PR workflow configured for you rather than written from scratch.
  • Managed Mac Team, $999/mo. Two M-series Macs, two CI runners, team setup, backups, monitoring, and one hour of monthly support.

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:


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms