Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions .github/workflows/ci-macos-compat.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
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
if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then
XCODE_DIR="/Applications/Xcode.app/Contents/Developer"
else
XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | head -n 1 || true)"
if [ -n "$XCODE_APP" ]; then
XCODE_DIR="$XCODE_APP/Contents/Developer"
else
echo "No Xcode.app found under /Applications" >&2
exit 1
fi
fi
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
120 changes: 120 additions & 0 deletions scripts/smoke-test-ci.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/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"

# --- Check display availability ---
if system_profiler SPDisplaysDataType 2>/dev/null | grep -q "Resolution"; then
echo "Display: available"
else
echo "WARNING: No display detected, app may fail to launch"
fi

# --- Clean up stale socket ---
rm -f "$SOCKET_PATH"

# --- Launch the app ---
echo "Launching app..."
open "$APP" --env CMUX_SOCKET_MODE=allowAll

# --- 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
sleep 0.5
done
if [ "$SOCKET_READY" != "true" ]; then
echo "ERROR: Socket not ready after 30s"
# Dump any crash logs
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')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Encode newline correctly in smoke-test send payload

The Python bytes literal here sends send time\ followed by two real newlines, not send time\\n plus the command terminator. In Sources/TerminalController.swift (sendInput), only the literal sequence \n is converted to Enter, so this payload does not actually execute time in the terminal and the smoke test can pass without validating terminal command delivery.

Useful? React with 👍 / 👎.

data = s.recv(1024).decode().strip()
s.close()
print(data)
")
echo "Send response: $SEND_RESPONSE"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fail smoke test when send command is rejected

The smoke test captures SEND_RESPONSE but never checks that it is OK, so this workflow can pass even when the terminal command path is broken (for example, if no terminal is focused or send returns an error) as long as the two ping checks succeed. That makes the new compatibility CI report false positives for exactly the command-delivery behavior it is meant to validate.

Useful? React with 👍 / 👎.


# --- Get app PID ---
APP_PID=$(pgrep -x "cmux DEV" | head -1 || true)
if [ -z "$APP_PID" ]; then
echo "ERROR: App process not found"
exit 1
fi
echo "App PID: $APP_PID"

# --- 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"
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 ---
pkill -x "cmux DEV" 2>/dev/null || true
Loading