Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Universal Binary CI Pipeline for macOS Apps

Run the merge job on an Apple silicon host so the same CI stage can execute the arm64 slice for a smoke test. Figment's M-series plans start at $95/month; the exact chip, RAM and disk are set in the configuration quote, and physical plans collect requests until supply is confirmed. Start a Figment Mac. The $29 virtual Intel macOS VM can still produce both slices through cross-compilation with a supported Intel Xcode toolchain, but it cannot execute the arm64 output.

A universal binary holds an x86_64 slice and an arm64 slice in a single Mach-O fat file. macOS picks the matching slice at launch, so one download runs natively on every Mac. In CI that means two xcodebuild invocations, a lipo -create merge step, and a verification pass before anything is signed or packaged. This page walks the whole pipeline and ends with a Makefile you can drop into a repo.

What a Universal Binary Is and When You Need One

Mach-O fat binaries have been part of macOS since the PowerPC-to-Intel transition. The current use case is Intel to Apple silicon. If only x86_64 is present on an M-series Mac, the system runs the binary under Rosetta, which Apple describes in Porting your macOS apps to Apple silicon. Rosetta works, but it adds launch overhead and prevents native arm64 performance features.

You need a universal binary when:

  • You ship a DMG or ZIP directly (outside the App Store) and want one artifact for every Mac.
  • Your framework or library is linked by other apps; forcing them to pick a slice is awkward.
  • You publish a CLI through Homebrew, whose modern formula flow expects universal binaries.

Apps delivered through the Mac App Store do not need a hand-merged fat binary — Xcode archives multiple architectures at submission.

Building the x86_64 and arm64 Slices with xcodebuild

Each architecture needs its own invocation and its own derived-data path so outputs do not collide. ARCHS is defined in Apple's Build Settings Reference, and command-line xcodebuild usage is in Technical Note TN2339. For projects targeting Xcode 27 on Intel, Apple's porting guide explicitly instructs adding x86_64 to ARCHS; if you still run Xcode on an Intel host, Apple says use Xcode 26 or earlier.

# arm64 slice
xcodebuild \
  -scheme "MyCLITool" \
  -configuration Release \
  -derivedDataPath build/derived/arm64 \
  ARCHS=arm64 \
  ONLY_ACTIVE_ARCH=NO \
  build

# x86_64 slice
xcodebuild \
  -scheme "MyCLITool" \
  -configuration Release \
  -derivedDataPath build/derived/x86_64 \
  ARCHS=x86_64 \
  ONLY_ACTIVE_ARCH=NO \
  build

Replace MyCLITool with the scheme name reported by xcodebuild -list in your project — this file does not know your scheme. ONLY_ACTIVE_ARCH=NO forces xcodebuild to honour ARCHS instead of defaulting to the host architecture, which is what makes cross-arch compilation work from one machine.

Running Both Builds in Parallel with a CI Matrix

Most CI systems support a matrix or fan-out. Here is a GitHub Actions example that builds both architectures in parallel and merges in a dependent job:

jobs:
  build-slice:
    runs-on: [self-hosted, macOS, arm64]   # Figment M-series runner
    strategy:
      fail-fast: true
      matrix:
        arch: [arm64, x86_64]
    steps:
      - uses: actions/checkout@v4
      - name: Build ${{ matrix.arch }} slice
        run: |
          set -euo pipefail
          xcodebuild \
            -scheme MyCLITool \
            -configuration Release \
            -derivedDataPath build/derived/${{ matrix.arch }} \
            ARCHS=${{ matrix.arch }} ONLY_ACTIVE_ARCH=NO \
            build
          cp "build/derived/${{ matrix.arch }}/Build/Products/Release/MyCLITool" \
             "MyCLITool-${{ matrix.arch }}"
      - uses: actions/upload-artifact@v4
        with:
          name: slice-${{ matrix.arch }}
          path: MyCLITool-${{ matrix.arch }}

  merge:
    needs: build-slice
    runs-on: [self-hosted, macOS, arm64]
    steps:
      - uses: actions/download-artifact@v4
        with: { pattern: slice-*, merge-multiple: true }
      - run: make universal

set -euo pipefail and fail-fast: true ensure a failed xcodebuild aborts the pipeline rather than being treated as a quick success in later timing reports. Times from this script are only meaningful when the build itself succeeds.

Merging Slices with lipo -create

lipo -create takes two or more architecture-specific Mach-O files and produces a fat binary:

lipo -create \
  MyCLITool-arm64 \
  MyCLITool-x86_64 \
  -output MyCLITool-universal

For a .app bundle, the merge target is the main executable under Contents/MacOS/. If the bundle also contains helper tools, dylibs or embedded .framework executables, every Mach-O file must be merged individually. The Makefile below handles a single-binary CLI tool; for app bundles, enumerate Mach-O files with find Contents -type f -exec file {} + and merge each one.

Verifying and Re-Signing the Merged Binary

After the merge, assert both slices survived and that the merged file still passes codesign -v:

set -euo pipefail

lipo -info MyCLITool-universal | grep -qw "arm64"  || { echo "FAIL: arm64 missing";  exit 1; }
lipo -info MyCLITool-universal | grep -qw "x86_64" || { echo "FAIL: x86_64 missing"; exit 1; }

# Re-sign the merged binary. Use your Developer ID identity for distribution;
# the keychain and private key must already be unlocked in this job via your
# CI secret manager (never commit certificate files or paste secrets into env).
codesign --force --timestamp --options=runtime --sign "$DEVELOPER_ID_IDENTITY" MyCLITool-universal
codesign -v --strict MyCLITool-universal

lipo creates a new file, so any signature placed on the individual slices is gone. Signing must happen after the merge, not before.

Makefile universal Target

Drop this at the repo root and run make universal. Set SCHEME to the value xcodebuild -list prints for your project.

# Makefile — universal binary pipeline
SCHEME    := MyCLITool
CONFIG    := Release
DERIVED   := build/derived
PRODUCTS  := build/products

.PHONY: arm64-slice x86_64-slice universal clean

$(PRODUCTS):
	mkdir -p $@

arm64-slice: | $(PRODUCTS)
	set -euo pipefail; \
	xcodebuild \
	  -scheme "$(SCHEME)" \
	  -configuration "$(CONFIG)" \
	  -derivedDataPath "$(DERIVED)/arm64" \
	  ARCHS=arm64 ONLY_ACTIVE_ARCH=NO \
	  build
	cp "$(DERIVED)/arm64/Build/Products/$(CONFIG)/$(SCHEME)" \
	   "$(PRODUCTS)/$(SCHEME)-arm64"

x86_64-slice: | $(PRODUCTS)
	set -euo pipefail; \
	xcodebuild \
	  -scheme "$(SCHEME)" \
	  -configuration "$(CONFIG)" \
	  -derivedDataPath "$(DERIVED)/x86_64" \
	  ARCHS=x86_64 ONLY_ACTIVE_ARCH=NO \
	  build
	cp "$(DERIVED)/x86_64/Build/Products/$(CONFIG)/$(SCHEME)" \
	   "$(PRODUCTS)/$(SCHEME)-x86_64"

universal: arm64-slice x86_64-slice
	@echo "-> Merging slices with lipo..."
	lipo -create \
	  "$(PRODUCTS)/$(SCHEME)-arm64" \
	  "$(PRODUCTS)/$(SCHEME)-x86_64" \
	  -output "$(PRODUCTS)/$(SCHEME)"
	@echo "-> Verifying both slices..."
	@lipo -info "$(PRODUCTS)/$(SCHEME)" | grep -qw "arm64" \
	  || { echo "FAIL: arm64 slice missing after merge"; exit 1; }
	@lipo -info "$(PRODUCTS)/$(SCHEME)" | grep -qw "x86_64" \
	  || { echo "FAIL: x86_64 slice missing after merge"; exit 1; }
	@echo "PASS: $(PRODUCTS)/$(SCHEME) is a valid universal binary."
	@echo "      Next: sign with Developer ID, then package with dmgbuild."

clean:
	rm -rf build

This is an example, not a benchmark. Run it on your own project before wiring it into CI; derived-data paths differ if you override CONFIGURATION_BUILD_DIR in an xcconfig.

Which Figment Mac to Use as the Merge Host

Figment host (listed entry price)Builds arm64 sliceBuilds x86_64 sliceRuns arm64 output
M-series Mac (from $95/mo, request-based; exact chip/RAM/disk in quote)Yes, nativelyYes, via ARCHS=x86_64Yes
i3 Mac ($49/mo, request-based)Yes, via ARCHS=arm64 cross-compileYes, nativelyNo
Virtual Intel macOS ($29/mo, 2 vCPU, 8 GiB RAM, 80 GiB disk)Yes, via ARCHS=arm64 cross-compile with Xcode 26 or earlierYes, nativelyNo

Prices are listed entry prices, not same-spec comparisons. The $29 plan is a virtual Intel macOS environment on Linux/KVM — a real macOS runtime without GPU acceleration or audio — reached through a browser console. If you also need to execute the arm64 output in the same CI stage (integration tests, UI smoke tests, Rosetta-vs-native perf checks), use M-series.

For a straight equal-price comparison, CloudMac publishes an M4 with 16 GB RAM and 256 GB SSD at $99.99/month, and an annual rate of $999/year (about $83.25/month effective) — $141 less than twelve months at Figment's $95 entry price if you only need an unmanaged M-series Mac. Figment's $249 Managed Build Mac is a different product: it bundles one M-series Mac with CI setup, backups, monitoring and 30 minutes of monthly support, so the comparison is managed CI work, not just the box. Confirm the exact Figment chip, RAM and disk in the quote before you compare.

Start the Pipeline

The merge job only needs lipo, codesign and dmgbuild, all of which run on any Mac. Choose M-series when the same job also has to execute the arm64 binary; choose the virtual Intel macOS for cheap cross-compiled builds that stop at artifact production.

Start a Figment Mac for your universal binary pipeline → · See all Mac hosting plans

Related guides:


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms