Repository navigation
Add macOS compatibility CI: unit tests + smoke test on macos-14/15 #769
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d3f4124
62db3d1
b2333bc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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') | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The Python bytes literal here sends Useful? React with 👍 / 👎. |
||
| data = s.recv(1024).decode().strip() | ||
| s.close() | ||
| print(data) | ||
| ") | ||
| echo "Send response: $SEND_RESPONSE" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The smoke test captures Useful? React with 👍 / 👎. |
||
|
|
||
| # --- 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 | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because this step enables
set -euo pipefail, the command substitution atXCODE_APP="$(ls -d /Applications/Xcode_*.app ... | sort | tail -n 1)"exits non-zero when noXcode_*.appmatches, and the shell terminates before the/Applications/Xcode.appfallback branch executes. On runner images that only provideXcode.app(or use a different naming convention), the workflow fails during Xcode selection even though a usable Xcode is installed.Useful? React with 👍 / 👎.