diff --git a/.github/workflows/ci-macos-compat.yml b/.github/workflows/ci-macos-compat.yml new file mode 100644 index 00000000..4bcab6b0 --- /dev/null +++ b/.github/workflows/ci-macos-compat.yml @@ -0,0 +1,165 @@ +name: macOS Compatibility + +on: + push: + branches: + - main + pull_request: + +jobs: + compat-tests: + # Only run for the repo itself, not forks (GhosttyKit download needs repo access). + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + strategy: + fail-fast: false + matrix: + os: [macos-14, macos-15] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + submodules: recursive + + - name: Select Xcode + run: | + set -euo pipefail + # Pick the latest Xcode installed on the runner. GitHub-hosted macos-14 + # defaults to Xcode 15.4, but the project needs Xcode 16+ (Swift tools + # version 6.0 required by sentry-cocoa). + XCODE_APP="$(ls -d /Applications/Xcode_*.app 2>/dev/null | sort | tail -n 1)" + if [ -z "$XCODE_APP" ]; then + XCODE_APP="/Applications/Xcode.app" + fi + XCODE_DIR="$XCODE_APP/Contents/Developer" + if [ ! -d "$XCODE_DIR" ]; then + echo "No Xcode found under /Applications" >&2 + exit 1 + fi + echo "Selected: $XCODE_APP" + echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV" + export DEVELOPER_DIR="$XCODE_DIR" + xcodebuild -version + xcrun --sdk macosx --show-sdk-path + sw_vers + + - name: Download pre-built GhosttyKit.xcframework + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + GHOSTTY_SHA=$(git -C ghostty rev-parse HEAD) + TAG="xcframework-$GHOSTTY_SHA" + URL="https://github.com/manaflow-ai/ghostty/releases/download/$TAG/GhosttyKit.xcframework.tar.gz" + echo "Downloading xcframework for ghostty $GHOSTTY_SHA" + MAX_RETRIES=30 + RETRY_DELAY=20 + for i in $(seq 1 $MAX_RETRIES); do + if curl -fSL -o GhosttyKit.xcframework.tar.gz "$URL"; then + echo "Download succeeded on attempt $i" + break + fi + if [ "$i" -eq "$MAX_RETRIES" ]; then + echo "Failed to download xcframework after $MAX_RETRIES attempts" >&2 + exit 1 + fi + echo "Attempt $i/$MAX_RETRIES failed, retrying in ${RETRY_DELAY}s..." + sleep $RETRY_DELAY + done + tar xzf GhosttyKit.xcframework.tar.gz + rm GhosttyKit.xcframework.tar.gz + test -d GhosttyKit.xcframework + + - name: Clean DerivedData + run: rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* + + - name: Resolve Swift packages + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + rm -rf "$SOURCE_PACKAGES_DIR" + mkdir -p "$SOURCE_PACKAGES_DIR" + + for attempt in 1 2 3; do + if xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -resolvePackageDependencies; then + exit 0 + fi + if [ "$attempt" -eq 3 ]; then + echo "Failed to resolve Swift packages after 3 attempts" >&2 + exit 1 + fi + echo "Package resolution failed on attempt $attempt, retrying..." + sleep $((attempt * 5)) + done + + - name: Run unit tests + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + run_unit_tests() { + xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -destination "platform=macOS" test 2>&1 + } + + set +e + OUTPUT=$(run_unit_tests) + EXIT_CODE=$? + set -e + + # SwiftPM binary artifact resolution can occasionally fail on ephemeral + # runners. Retry once after clearing caches. + if [ "$EXIT_CODE" -ne 0 ] && echo "$OUTPUT" | grep -q "Could not resolve package dependencies"; then + echo "SwiftPM package resolution failed, clearing caches and retrying once" + rm -rf ~/Library/Caches/org.swift.swiftpm + mkdir -p ~/Library/Caches/org.swift.swiftpm + rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* + set +e + OUTPUT=$(run_unit_tests) + EXIT_CODE=$? + set -e + fi + + echo "$OUTPUT" + if [ "$EXIT_CODE" -ne 0 ]; then + SUMMARY=$(echo "$OUTPUT" | grep "Executed.*tests.*with.*failures" | tail -1) + if echo "$SUMMARY" | grep -q "(0 unexpected)"; then + echo "All failures are expected, treating as pass" + else + echo "Unexpected test failures detected" + exit 1 + fi + fi + + - name: Create virtual display + run: | + set -euo pipefail + echo "=== Display before ===" + system_profiler SPDisplaysDataType 2>/dev/null || echo "(no display info)" + echo "" + clang -framework Foundation -framework CoreGraphics \ + -o /tmp/create-virtual-display scripts/create-virtual-display.m + /tmp/create-virtual-display & + VDISPLAY_PID=$! + echo "VDISPLAY_PID=$VDISPLAY_PID" >> "$GITHUB_ENV" + sleep 3 + echo "=== Display after ===" + system_profiler SPDisplaysDataType 2>/dev/null || echo "(no display info)" + + - name: Build app for smoke test + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -destination "platform=macOS" build + + - name: Smoke test + run: | + set -euo pipefail + chmod +x scripts/smoke-test-ci.sh + scripts/smoke-test-ci.sh diff --git a/scripts/smoke-test-ci.sh b/scripts/smoke-test-ci.sh new file mode 100755 index 00000000..60583bc5 --- /dev/null +++ b/scripts/smoke-test-ci.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# Smoke test for CI: launch the app, send a command, verify it stays alive for 15 seconds. +set -euo pipefail + +SOCKET_PATH="/tmp/cmux-debug.sock" +STABILITY_WAIT=15 + +echo "=== Smoke Test ===" + +# --- Find the built app --- +APP=$(find ~/Library/Developer/Xcode/DerivedData -path "*/Build/Products/Debug/cmux DEV.app" -print -quit 2>/dev/null || true) +if [ -z "$APP" ]; then + echo "ERROR: Built app not found in DerivedData" + exit 1 +fi +echo "App: $APP" +BINARY="$APP/Contents/MacOS/cmux DEV" +if [ ! -x "$BINARY" ]; then + echo "ERROR: App binary not found or not executable: $BINARY" + exit 1 +fi + +# --- Clean up stale socket and any existing instances --- +rm -f "$SOCKET_PATH" +pkill -x "cmux DEV" 2>/dev/null || true +sleep 1 + +# --- Launch the app directly (not via `open`, which can silently fail on CI) --- +echo "Launching app..." +CMUX_SOCKET_MODE=allowAll CMUX_UI_TEST_MODE=1 "$BINARY" > /tmp/cmux-smoke-stdout.log 2>&1 & +APP_PID=$! +echo "App PID: $APP_PID" + +# --- Verify process is alive after 2s --- +sleep 2 +if ! kill -0 "$APP_PID" 2>/dev/null; then + echo "ERROR: App exited immediately after launch" + echo "--- stdout/stderr ---" + cat /tmp/cmux-smoke-stdout.log 2>/dev/null | tail -50 || true + echo "--- debug log ---" + tail -50 /tmp/cmux-debug.log 2>/dev/null || true + echo "--- crash reports ---" + ls -lt ~/Library/Logs/DiagnosticReports/*cmux* 2>/dev/null | head -5 || echo "(none)" + exit 1 +fi + +# --- Wait for socket (up to 30s) --- +echo "Waiting for socket at $SOCKET_PATH..." +SOCKET_READY=false +for i in $(seq 1 60); do + if [ -S "$SOCKET_PATH" ]; then + echo "Socket ready after $((i / 2))s" + SOCKET_READY=true + break + fi + # Check if process died while waiting + if ! kill -0 "$APP_PID" 2>/dev/null; then + echo "ERROR: App crashed while waiting for socket" + echo "--- stdout/stderr ---" + cat /tmp/cmux-smoke-stdout.log 2>/dev/null | tail -50 || true + echo "--- debug log ---" + tail -50 /tmp/cmux-debug.log 2>/dev/null || true + exit 1 + fi + sleep 0.5 +done +if [ "$SOCKET_READY" != "true" ]; then + echo "ERROR: Socket not ready after 30s" + echo "--- stdout/stderr ---" + cat /tmp/cmux-smoke-stdout.log 2>/dev/null | tail -30 || true + echo "--- debug log ---" + tail -30 /tmp/cmux-debug.log 2>/dev/null || true + ls -la /tmp/cmux-debug* 2>/dev/null || true + pgrep -la "cmux" || echo "No cmux processes found" + exit 1 +fi + +# --- Ping the socket --- +echo "Pinging socket..." +PING_RESPONSE=$(python3 -c " +import socket +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +s.connect('$SOCKET_PATH') +s.settimeout(5.0) +s.sendall(b'ping\n') +data = s.recv(1024).decode().strip() +s.close() +print(data) +") +echo "Ping response: $PING_RESPONSE" +if [ "$PING_RESPONSE" != "PONG" ]; then + echo "ERROR: Expected PONG, got: $PING_RESPONSE" + exit 1 +fi + +# --- Send a command to the terminal --- +echo "Sending 'time' command to terminal..." +SEND_RESPONSE=$(python3 -c " +import socket +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +s.connect('$SOCKET_PATH') +s.settimeout(5.0) +s.sendall(b'send time\\\n\n') +data = s.recv(1024).decode().strip() +s.close() +print(data) +") +echo "Send response: $SEND_RESPONSE" + +# --- Wait and verify stability --- +echo "Waiting ${STABILITY_WAIT}s to verify stability..." +sleep "$STABILITY_WAIT" + +if ! kill -0 "$APP_PID" 2>/dev/null; then + echo "ERROR: App crashed during ${STABILITY_WAIT}s stability check" + echo "--- stdout/stderr ---" + cat /tmp/cmux-smoke-stdout.log 2>/dev/null | tail -30 || true + echo "--- debug log ---" + tail -30 /tmp/cmux-debug.log 2>/dev/null || true + exit 1 +fi + +# --- Final ping --- +FINAL_PING=$(python3 -c " +import socket +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +s.connect('$SOCKET_PATH') +s.settimeout(5.0) +s.sendall(b'ping\n') +data = s.recv(1024).decode().strip() +s.close() +print(data) +") +echo "Final ping: $FINAL_PING" +if [ "$FINAL_PING" != "PONG" ]; then + echo "ERROR: App not responsive after ${STABILITY_WAIT}s" + exit 1 +fi + +echo "=== Smoke test passed ===" + +# --- Cleanup --- +kill "$APP_PID" 2>/dev/null || true +wait "$APP_PID" 2>/dev/null || true