
A screenshot regression pipeline catches pixel-level drift before it lands in main. With xcrun simctl, ImageMagick's compare, and a small Makefile you can wire one into a pull-request check in an afternoon. This page delivers that Makefile, a matching GitHub Actions job, and a direct Figment plan recommendation for the runner.
xcrun simctl is the command-line interface to the iOS Simulator bundled with Xcode. Apple documents the Simulator and its control commands in Running your app in Simulator or on a device and the archived Interacting with the iOS Simulator guide, which shows the xcrun simctl io booted screenshot form used below.
The sub-commands this pipeline uses:
| Sub-command | What it does |
|---|---|
xcrun simctl list devices --json | Lists every simulator with its UDID and state |
xcrun simctl boot <UDID> | Boots a named simulator |
xcrun simctl io <UDID> screenshot <file> | Captures a PNG from that device |
xcrun simctl shutdown <UDID> | Shuts the simulator down cleanly |
The exact flags accepted by io screenshot depend on your installed Xcode. Run xcrun simctl help io on your own machine to confirm the flags available there before scripting them into CI; keep PNG as the default, since it's lossless and the only sensible input for a numeric diff.
The Simulator requires macOS with Xcode installed. Figment's $29 virtual Intel macOS plan runs a compatible Intel Xcode 26 or earlier toolchain (Apple's porting guidance says to use Xcode 26 or earlier on Intel hardware), with no GPU or audio. For snappy simulator boots and parallel devices, M-series is the better fit — see the recommendation at the end.
Before the Makefile runs, pick a stable UDID per device-OS pair. Run this once:
xcrun simctl list devices --json \
| python3 -c "
import json, sys
for runtime, devices in json.load(sys.stdin)['devices'].items():
for d in devices:
if d.get('isAvailable'):
print(d['udid'], d['name'], runtime)
" | grep -E 'iPhone 15|iPad Pro'
Hardcode the UDIDs you want into a SIMULATORS variable. Querying dynamically at build time can silently pick a different runtime after an Xcode upgrade.
Save this as Makefile at your repo root. It assumes your app is already installed and in the right state on each simulator (via your existing xcodebuild test or xcrun simctl launch step); it only owns boot, screenshot, diff, archive, promote and clean.
# ── Configuration ────────────────────────────────────────────────────────────
THRESHOLD := 0.02 # max ImageMagick NRMSE distortion
BASELINE_DIR := regression/baselines
ARCHIVE_ROOT := regression/runs
RUN_DIR := $(ARCHIVE_ROOT)/$(shell date +%Y%m%d_%H%M%S)
# Space-separated list: UDID|DeviceName
SIMULATORS := \
AAAAAAAA-0000-0000-0000-000000000001|iPhone15Pro \
BBBBBBBB-0000-0000-0000-000000000002|iPadPro13
UDIDS := $(foreach p,$(SIMULATORS),$(firstword $(subst |, ,$(p))))
.PHONY: all boot screenshots diff archive promote clean
.SHELLFLAGS := -eu -o pipefail -c
SHELL := /bin/bash
all: boot screenshots diff archive
@echo "Regression run complete: $(RUN_DIR)"
$(RUN_DIR):
@mkdir -p $@ $(BASELINE_DIR)
boot:
@for u in $(UDIDS); do \
xcrun simctl boot $$u 2>/dev/null || true; \
until xcrun simctl list devices | grep $$u | grep -q Booted; do sleep 1; done; \
done
screenshots: | $(RUN_DIR)
@for p in $(SIMULATORS); do \
u=$${p%|*}; n=$${p#*|}; \
xcrun simctl io $$u screenshot --type=png $(RUN_DIR)/$$n.png; \
done
diff: screenshots
@fail=0; \
for p in $(SIMULATORS); do \
n=$${p#*|}; \
baseline=$(BASELINE_DIR)/$$n.png; current=$(RUN_DIR)/$$n.png; \
if [ ! -f "$$baseline" ]; then \
echo "No baseline for $$n – seeding from this run."; \
cp "$$current" "$$baseline"; continue; \
fi; \
err=$$(compare -metric NRMSE "$$baseline" "$$current" \
$(RUN_DIR)/$$n.diff.png 2>&1 >/dev/null || true); \
echo "$$n NRMSE=$$err"; \
over=$$(awk -v e="$$err" -v t="$(THRESHOLD)" 'BEGIN{print (e+0>t+0)?1:0}'); \
if [ "$$over" = "1" ]; then \
echo "FAIL: $$n ($$err > $(THRESHOLD))"; fail=1; \
else \
rm -f $(RUN_DIR)/$$n.diff.png; \
fi; \
done; \
exit $$fail
archive:
@tar -czf $(RUN_DIR).tar.gz -C $(ARCHIVE_ROOT) $(notdir $(RUN_DIR))
@echo "Archived: $(RUN_DIR).tar.gz"
promote:
@latest=$$(ls -1dt $(ARCHIVE_ROOT)/*/ | head -1); \
cp $$latest/*.png $(BASELINE_DIR)/
@echo "Promoted $$latest to baselines."
clean:
@for u in $(UDIDS); do xcrun simctl shutdown $$u 2>/dev/null || true; done
@rm -rf $(ARCHIVE_ROOT)
Notes on the choices above:
compare -metric NRMSE baseline current diff.png is the documented ImageMagick form; it writes err to stderr and the diff image to the named output. convert with a -compare flag is not the right tool for a scored diff..SHELLFLAGS := -eu -o pipefail -c means recipe lines stop on the first failure, so a failed screenshot aborts the run instead of being logged as a fast success.for loops in one shell, not $(foreach …) expanded into stacked ; lines, so variables like fail and u behave the way you'd expect.promote is manual. The first run seeds any missing baselines; after an intentional visual change, run make promote and commit the updated PNGs.A GitHub Actions job running on a self-hosted Mac runner:
# .github/workflows/screenshot-regression.yml
name: Screenshot Regression
on: [pull_request]
jobs:
screenshot:
runs-on: [self-hosted, macOS]
steps:
- uses: actions/checkout@v4
- name: Restore baselines cache
uses: actions/cache@v4
with:
path: regression/baselines
key: baselines-${{ github.base_ref }}
- name: Ensure ImageMagick
run: command -v compare || brew install imagemagick
- name: Run screenshot regression
run: make
- name: Upload diff archive on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: screenshot-diffs
path: regression/runs/*.tar.gz
If you register the runner on a hosted Mac, follow GitHub's current hardening guide for self-hosted runners and keep the runner restricted to trusted repositories.
Each run lands in regression/runs/YYYYMMDD_HHMMSS/:
regression/
baselines/
iPhone15Pro.png
iPadPro13.png
runs/
20261108_143022/
iPhone15Pro.png
iPadPro13.png
iPhone15Pro.diff.png # only on threshold breach
20261108_143022.tar.gz
Keeping baselines in git and run archives as CI artifacts lets you diff baselines themselves across commits — useful when a designer intentionally ships a visual change.
A worked example for storage, not a benchmark. Assume a 350 KB PNG per device, 10 devices per run, 500 runs:
Even an entry virtual plan has room for active runs if you prune or offload archives.
Direct recommendation for this job:
Pricing details live on the mac hosting page; taxes and any setup fees are stated on the actual offer at checkout. Full plan with CI setup and monitoring: start a request at /mac-hosting/start.