
Rent a hosted Mac from Figment and wire the shell post-step below into your pipeline so every Safari crash during a test run gets packaged as a dated .tar.gz CI artefact before the next job sweeps ~/Library/Logs/DiagnosticReports clean. For current Safari on current macOS an M-series plan from $95/month (Figment Mac hosting) is the recommended starting point; the $29 virtual Intel macOS instance also writes DiagnosticReports and runs the same script when you only need to exercise the collection path. The script timestamps the test window, picks up only new Safari .ips files, extracts the crashing thread, and tarballs the bundle as a named CI artefact.
macOS writes application crash reports to two locations, documented by Apple in Diagnosing issues using crash reports and device logs:
~/Library/Logs/DiagnosticReports/ — per-user crash reports; writable by the current user process and the useful one for CI scripts./Library/Logs/DiagnosticReports/ — system-wide reports; needs elevated privileges and is rarely required for Safari automation.Since macOS 12 (Monterey) crash reports use the .ips extension and store their payload as two-part JSON: the first line is a compact metadata header containing bug_type, os_version, timestamp and incident_identifier; the remaining lines form the larger JSON body with thread stacks, binary images and exception details. Older .crash files (plain text) can still appear from older tooling, so your collection script should handle both extensions.
For Safari specifically, crash files are named with a Safari- prefix followed by a timestamp, e.g. Safari-2026-11-10-141523.ips.
The header line of an .ips file is a single JSON object you can read with python3 or jq. Fields worth recording as evidence:
| Field | Meaning |
|---|---|
os_version | macOS version at time of crash, e.g. "macOS 15.5 (24F74)" |
timestamp | Local crash time with timezone offset |
bug_type | 309 for a standard application crash |
incident_identifier | Unique UUID for this crash report |
name | Process name, e.g. "Safari" |
Extract them with a small helper:
# header_fields.sh
IPS_FILE="$1"
python3 - "$IPS_FILE" <<'EOF'
import sys, json
with open(sys.argv[1]) as f:
header = json.loads(f.readline())
print(json.dumps({
"incident": header.get("incident_identifier"),
"timestamp": header.get("timestamp"),
"os_version": header.get("os_version"),
"bug_type": header.get("bug_type"),
"name": header.get("name"),
}, indent=2))
EOF
This is an unexecuted example — point it at a real .ips file in your own DiagnosticReports directory before relying on the field names.
You only want crash files that appeared during your test run. Record a marker epoch with date +%s before the first test invocation, then compare each file's modification time:
TEST_START=$(date +%s)
# ... run xcodebuild test / pytest / WebDriver ...
find ~/Library/Logs/DiagnosticReports \
\( -name 'Safari-*.ips' -o -name 'Safari-*.crash' \) -print |
while read -r f; do
MTIME=$(stat -f '%m' "$f")
if [ "$MTIME" -ge "$TEST_START" ]; then
echo "$f"
fi
done
stat -f '%m' is the BSD form shipped on macOS and returns the modification time as a Unix epoch integer — directly comparable with the recorded TEST_START.
Save the script below as scripts/collect_safari_crashes.sh. It ties together timestamp gating, header extraction, crashing-thread extraction for legacy .crash files, and final tarball packaging.
#!/usr/bin/env bash
# collect_safari_crashes.sh
# Usage: collect_safari_crashes.sh <TEST_START_EPOCH> <OUTPUT_DIR>
set -euo pipefail
TEST_START="${1:?Provide TEST_START epoch as first argument}"
OUTPUT_DIR="${2:?Provide OUTPUT_DIR as second argument}"
DIAG_DIR="$HOME/Library/Logs/DiagnosticReports"
BUNDLE_NAME="safari-crashes-$(date -u +%Y%m%dT%H%M%SZ)"
STAGE_DIR="$(mktemp -d)"
trap 'rm -rf "$STAGE_DIR"' EXIT
found=0
while IFS= read -r f; do
MTIME=$(stat -f '%m' "$f")
if [ "$MTIME" -ge "$TEST_START" ]; then
cp "$f" "$STAGE_DIR/"
found=$((found + 1))
echo " Collected: $(basename "$f")"
if [[ "$f" == *.crash ]]; then
awk '/^Thread [0-9]+ Crashed/,/^$/' "$f" \
> "$STAGE_DIR/$(basename "${f%.crash}").crashing-thread.txt" || true
fi
if [[ "$f" == *.ips ]]; then
python3 - "$f" > "$STAGE_DIR/$(basename "${f%.ips}").header.json" <<'PYEOF' || true
import sys, json
with open(sys.argv[1]) as fh:
try:
header = json.loads(fh.readline())
except json.JSONDecodeError:
header = {}
print(json.dumps({
"incident": header.get("incident_identifier", "unknown"),
"timestamp": header.get("timestamp", "unknown"),
"os_version": header.get("os_version", "unknown"),
"bug_type": header.get("bug_type", "unknown"),
}, indent=2))
PYEOF
fi
fi
done < <(find "$DIAG_DIR" \( -name 'Safari-*.ips' -o -name 'Safari-*.crash' \) 2>/dev/null)
if [ "$found" -gt 0 ]; then
mkdir -p "$OUTPUT_DIR"
ARCHIVE="$OUTPUT_DIR/${BUNDLE_NAME}.tar.gz"
tar -czf "$ARCHIVE" -C "$STAGE_DIR" .
echo "Archived $found crash file(s) to $ARCHIVE"
else
echo "No new Safari crash files found."
fi
Design notes:
set -euo pipefail plus the EXIT trap keeps the staging directory from leaking.|| true around the per-file extractors prevents one malformed crash blocking the archive of valid ones.awk extraction only runs on plain-text .crash files; .ips files are JSON and get the structured header dump instead.GitHub Actions:
- name: Record test start
id: ts
run: echo "epoch=$(date +%s)" >> "$GITHUB_OUTPUT"
- name: Run Safari tests
run: xcodebuild test -scheme "$XCODE_SCHEME" -destination 'platform=macOS'
- name: Collect Safari crash logs
if: always()
run: bash scripts/collect_safari_crashes.sh "${{ steps.ts.outputs.epoch }}" crash-artefacts
- name: Upload crash bundle
if: always()
uses: actions/upload-artifact@v4
with:
name: safari-crashes-${{ github.sha }}
path: crash-artefacts/
retention-days: 14
Pass your project's real scheme in XCODE_SCHEME; there is no default scheme name here and inventing one will just make xcodebuild fail silently inside the matrix.
GitLab CI:
GitLab's after_script runs in a fresh shell, so variables set in script do not survive. Persist the epoch to a file:
test:safari:
script:
- date +%s > .test_start_epoch
- xcodebuild test -scheme "$XCODE_SCHEME" -destination 'platform=macOS'
after_script:
- bash scripts/collect_safari_crashes.sh "$(cat .test_start_epoch)" crash-artefacts
artifacts:
name: "safari-crashes-${CI_COMMIT_SHA}"
paths:
- crash-artefacts/
expire_in: 14 days
when: always
The when: always / if: always() guards are the point: a Safari crash exits the test step non-zero, and without those guards the upload step is skipped — exactly when you need the evidence.
The .ips header timestamp field pins the crash to the second. To correlate it with a specific test case, record each test's start time in your test body and pull it out of the .xcresult afterwards:
func testSafariRendersCheckout() throws {
let caseStart = ISO8601DateFormatter().string(from: Date())
XCTContext.runActivity(named: "test-window-\(caseStart)") { _ in }
// ... run your test ...
}
After the run, parse .xcresult with xcrun xcresulttool and align the recorded test-window-... activities with the timestamp from each collected crash header. One crash file falling inside one test window is the strongest signal you get before attaching a debugger.
The script above targets DiagnosticReports, which records crashes from macOS-native processes on the host. The iOS Simulator keeps its own path at ~/Library/Logs/CoreSimulator/ and only captures Simulator-process crashes, not Safari for macOS. So you need an actual macOS host — any of these works, with different trade-offs:
DiagnosticReports, drives Safari for scripted collection. No GPU acceleration, no audio, Intel architecture only. VM capacity is being verified; ask for current availability. Access is via the browser console, and outbound CI agents can call back to GitHub/GitLab.For context when comparing: CloudMac publishes a Mac mini M4 (16 GB / 256 GB) at $99.99/month, and their homepage lists a $999/year prepay on the same configuration — roughly $83.25 effective monthly, below 12×$95 at Figment's entry price. Figment's $95 is an entry price for an unspecified M-series configuration; if you need an exact M4/16 GB match, request a quote. Figment's difference on the managed tier is the specific CI setup, backups, monitoring and included support minutes bundled into one line item — ask for those explicitly if that is the work you need done.
Browse plans and request a configuration at Figment Mac hosting → get started.