Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
165 changes: 165 additions & 0 deletions .github/workflows/ci-macos-compat.yml
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)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Guard Xcode glob lookup so fallback can run

Because this step enables set -euo pipefail, the command substitution at XCODE_APP="$(ls -d /Applications/Xcode_*.app ... | sort | tail -n 1)" exits non-zero when no Xcode_*.app matches, and the shell terminates before the /Applications/Xcode.app fallback branch executes. On runner images that only provide Xcode.app (or use a different naming convention), the workflow fails during Xcode selection even though a usable Xcode is installed.

Useful? React with 👍 / 👎.

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
144 changes: 144 additions & 0 deletions 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')

Copy link
Copy Markdown

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

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 👍 / 👎.


# --- 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