name: iOS CI on: workflow_call: permissions: contents: read pull-requests: write issues: write # Single job for efficient execution on shared runner jobs: build-lint-test: name: Build, Lint, and Test iOS runs-on: [self-hosted, macOS, ARM64] timeout-minutes: 30 env: GITHUB_REPO_NAME: ${{ github.repository }} steps: - name: Clean workspace run: | # Clean workspace for self-hosted runner rm -rf * || true rm -rf .* || true - name: Checkout code uses: actions/checkout@v4 - name: Verify Xcode run: | xcodebuild -version swift --version - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '24' - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 9 run_install: false - name: Cache Homebrew packages uses: useblacksmith/cache@v5 with: path: | ~/Library/Caches/Homebrew /opt/homebrew/Cellar/swiftlint /opt/homebrew/Cellar/swiftformat /opt/homebrew/Cellar/xcbeautify key: ${{ runner.os }}-brew-${{ hashFiles('.github/workflows/ios.yml') }} restore-keys: | ${{ runner.os }}-brew- - name: Cache Swift packages uses: useblacksmith/cache@v5 with: path: | ~/Library/Developer/Xcode/DerivedData ~/.swiftpm key: ${{ runner.os }}-spm-${{ hashFiles('ios/VibeTunnel-iOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} restore-keys: | ${{ runner.os }}-spm- - name: Install all tools shell: bash run: | # Retry logic for brew commands to handle concurrent access MAX_ATTEMPTS=5 WAIT_TIME=5 for attempt in $(seq 1 $MAX_ATTEMPTS); do echo "Attempting Homebrew operations (attempt $attempt/$MAX_ATTEMPTS)" # Check if another brew process is running if pgrep -x "brew" > /dev/null; then echo "Another brew process detected, waiting ${WAIT_TIME}s..." sleep $WAIT_TIME WAIT_TIME=$((WAIT_TIME * 2)) # Exponential backoff continue fi # Update Homebrew and install all tools in one command # brew install automatically upgrades if already installed if brew update && brew install swiftlint swiftformat xcbeautify; then echo "Successfully installed/upgraded all tools" break else if [ $attempt -eq $MAX_ATTEMPTS ]; then echo "Failed to install tools after $MAX_ATTEMPTS attempts" exit 1 fi echo "Command failed, waiting ${WAIT_TIME}s before retry..." sleep $WAIT_TIME WAIT_TIME=$((WAIT_TIME * 2)) # Exponential backoff fi done # Show versions echo "SwiftLint: $(swiftlint --version || echo 'not found')" echo "SwiftFormat: $(swiftformat --version || echo 'not found')" echo "xcbeautify: $(xcbeautify --version || echo 'not found')" echo "PATH: $PATH" - name: Cache pnpm store uses: useblacksmith/cache@v5 with: path: ~/.local/share/pnpm/store key: ${{ runner.os }}-pnpm-store-${{ hashFiles('web/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- - name: Install web dependencies run: | cd web # Clean any stale lock files rm -f .pnpm-store.lock .pnpm-debug.log || true # Set pnpm to use fewer workers to avoid crashes on self-hosted runners export NODE_OPTIONS="--max-old-space-size=4096" pnpm config set store-dir ~/.local/share/pnpm/store pnpm config set package-import-method copy pnpm config set node-linker hoisted # Install with retries for i in 1 2 3; do echo "Install attempt $i" if pnpm install --frozen-lockfile; then echo "pnpm install succeeded" break else echo "pnpm install failed, cleaning and retrying..." rm -rf node_modules .pnpm-store.lock || true sleep 5 fi done - name: Download web build artifacts uses: actions/download-artifact@v4 with: name: web-build-${{ github.sha }} path: web/ - name: Resolve Dependencies (once) run: | cd ios echo "Resolving iOS package dependencies..." xcodebuild -resolvePackageDependencies -workspace ../VibeTunnel.xcworkspace || echo "Dependency resolution completed" # BUILD PHASE - name: Build iOS app run: | cd ios # Ensure xcbeautify is in PATH export PATH="/usr/local/bin:/opt/homebrew/bin:$PATH" set -o pipefail xcodebuild build \ -workspace ../VibeTunnel.xcworkspace \ -scheme VibeTunnel-iOS \ -destination "generic/platform=iOS" \ -configuration Release \ -showBuildTimingSummary \ CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_REQUIRED=NO \ ONLY_ACTIVE_ARCH=NO \ -derivedDataPath build/DerivedData \ COMPILER_INDEX_STORE_ENABLE=NO \ 2>&1 | tee build.log || { echo "Build failed. Last 100 lines of output:" tail -100 build.log exit 1 } - name: List build products if: always() run: | echo "Searching for iOS build products..." find ios/build -name "*.app" -type d 2>/dev/null || echo "No build products found" ls -la ios/build/DerivedData/Build/Products/ 2>/dev/null || echo "Build products directory not found" # LINT PHASE - name: Run SwiftFormat (check mode) id: swiftformat continue-on-error: true run: | cd ios swiftformat . --lint 2>&1 | tee ../swiftformat-output.txt echo "result=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT - name: Run SwiftLint id: swiftlint continue-on-error: true run: | cd ios swiftlint 2>&1 | tee ../swiftlint-output.txt echo "result=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT # TEST PHASE - name: Create and boot simulator id: simulator run: | echo "Creating iOS simulator for tests..." # Generate unique simulator name to avoid conflicts SIMULATOR_NAME="VibeTunnel-iOS-${GITHUB_RUN_ID}-${GITHUB_JOB}-${RANDOM}" echo "Simulator name: $SIMULATOR_NAME" # Cleanup function cleanup_simulator() { local sim_id="$1" if [ -n "$sim_id" ]; then echo "Cleaning up simulator $sim_id..." xcrun simctl shutdown "$sim_id" 2>/dev/null || true xcrun simctl delete "$sim_id" 2>/dev/null || true fi } # Pre-cleanup: Remove old VibeTunnel test simulators from previous runs echo "Cleaning up old test simulators..." xcrun simctl list devices | grep "VibeTunnel-iOS-" | grep -E "\(.*\)" | \ sed -n 's/.*(\(.*\)).*/\1/p' | while read -r old_sim_id; do cleanup_simulator "$old_sim_id" done # Get the latest iOS runtime RUNTIME=$(xcrun simctl list runtimes | grep "iOS" | tail -1 | awk '{print $NF}') echo "Using runtime: $RUNTIME" # Create a new simulator with retry logic SIMULATOR_ID="" for attempt in 1 2 3; do echo "Creating simulator (attempt $attempt)..." SIMULATOR_ID=$(xcrun simctl create "$SIMULATOR_NAME" "iPhone 15" "$RUNTIME" 2>/dev/null || \ xcrun simctl create "$SIMULATOR_NAME" "com.apple.CoreSimulator.SimDeviceType.iPhone-15" "$RUNTIME" 2>/dev/null) && break echo "Creation failed, waiting before retry..." sleep $((attempt * 2)) done if [ -z "$SIMULATOR_ID" ]; then echo "::error::Failed to create simulator after 3 attempts" exit 1 fi echo "Created simulator: $SIMULATOR_ID" echo "SIMULATOR_ID=$SIMULATOR_ID" >> $GITHUB_ENV echo "simulator_id=$SIMULATOR_ID" >> $GITHUB_OUTPUT # Boot the simulator with retry logic echo "Booting simulator..." for attempt in 1 2 3; do if xcrun simctl boot "$SIMULATOR_ID" 2>/dev/null; then echo "Simulator booted successfully" break fi # Check if already booted if xcrun simctl list devices | grep "$SIMULATOR_ID" | grep -q "Booted"; then echo "Simulator already booted" break fi echo "Boot attempt $attempt failed, waiting..." sleep $((attempt * 3)) done # Wait for simulator to be ready echo "Waiting for simulator to be ready..." for i in {1..30}; do if xcrun simctl list devices | grep "$SIMULATOR_ID" | grep -q "Booted"; then echo "Simulator is ready" break fi sleep 1 done - name: Run iOS tests run: | cd ios # Ensure xcbeautify is in PATH export PATH="/usr/local/bin:/opt/homebrew/bin:$PATH" # Set up cleanup trap cleanup_and_exit() { local exit_code=$? echo "Test execution finished with exit code: $exit_code" # Attempt to shutdown simulator gracefully if [ -n "$SIMULATOR_ID" ]; then echo "Shutting down simulator..." xcrun simctl shutdown "$SIMULATOR_ID" 2>/dev/null || true # Give it a moment to shutdown sleep 2 # Force terminate if still running if xcrun simctl list devices | grep "$SIMULATOR_ID" | grep -q "Booted"; then echo "Force terminating simulator..." xcrun simctl terminate "$SIMULATOR_ID" com.apple.springboard 2>/dev/null || true fi fi exit $exit_code } trap cleanup_and_exit EXIT echo "Running iOS tests using Swift Testing framework..." echo "Simulator ID: $SIMULATOR_ID" # Verify simulator is still booted if ! xcrun simctl list devices | grep "$SIMULATOR_ID" | grep -q "Booted"; then echo "::error::Simulator is not in booted state" exit 1 fi set -o pipefail xcodebuild test \ -workspace ../VibeTunnel.xcworkspace \ -scheme VibeTunnel-iOS \ -destination "platform=iOS Simulator,id=$SIMULATOR_ID" \ -resultBundlePath TestResults.xcresult \ -enableCodeCoverage YES \ CODE_SIGN_IDENTITY="" \ CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_ALLOWED=NO \ COMPILER_INDEX_STORE_ENABLE=NO \ -quiet \ 2>&1 || { echo "::error::iOS tests failed" exit 1 } echo "Tests completed successfully" # Add cleanup step that always runs - name: Cleanup simulator if: always() && steps.simulator.outputs.simulator_id != '' run: | SIMULATOR_ID="${{ steps.simulator.outputs.simulator_id }}" echo "Cleaning up simulator $SIMULATOR_ID..." # Shutdown simulator xcrun simctl shutdown "$SIMULATOR_ID" 2>/dev/null || true # Wait a bit for shutdown sleep 2 # Delete simulator xcrun simctl delete "$SIMULATOR_ID" 2>/dev/null || true echo "Simulator cleanup completed" # COVERAGE EXTRACTION - name: Debug coverage files if: always() run: | cd ios echo "=== Checking TestResults.xcresult ===" if [ -f TestResults.xcresult ]; then echo "TestResults.xcresult exists" ls -la TestResults.xcresult echo "\n=== Checking for coverage data in xcresult ===" xcrun xcresulttool get --path TestResults.xcresult --format json 2>/dev/null | jq '.actions._values[].actionResult.coverage' 2>/dev/null | head -20 || echo "No coverage data found in xcresult" echo "\n=== Attempting direct coverage view ===" xcrun xccov view --report TestResults.xcresult 2>&1 | head -20 || echo "Direct coverage view failed" else echo "TestResults.xcresult not found" ls -la fi - name: Extract coverage summary if: always() id: coverage run: | cd ios if [ -f TestResults.xcresult ]; then # Try multiple extraction methods echo "=== Method 1: Standard extraction ===" COVERAGE_PCT=$(xcrun xccov view --report TestResults.xcresult --json 2>/dev/null | jq -r '.lineCoverage // 0' | awk '{printf "%.1f", $1 * 100}') || COVERAGE_PCT="0" if [ "$COVERAGE_PCT" = "0" ] || [ -z "$COVERAGE_PCT" ]; then echo "Method 1 failed, trying alternative methods" echo "\n=== Method 2: Without --json flag ===" COVERAGE_LINE=$(xcrun xccov view --report TestResults.xcresult 2>&1 | grep -E "^[0-9]+\.[0-9]+%" | head -1) || true if [ -n "$COVERAGE_LINE" ]; then COVERAGE_PCT=$(echo "$COVERAGE_LINE" | sed 's/%.*//g') echo "Extracted coverage from text output: $COVERAGE_PCT%" fi fi if [ "$COVERAGE_PCT" = "0" ] || [ -z "$COVERAGE_PCT" ]; then echo "\n=== Method 3: Check if tests ran ===" if xcrun xcresulttool get --path TestResults.xcresult --format json 2>/dev/null | grep -q '"testsCount"' && \ xcrun xcresulttool get --path TestResults.xcresult --format json 2>/dev/null | jq -e '.metrics.testsCount > 0' >/dev/null 2>&1; then echo "Tests ran but coverage extraction failed" # Set to 0.1% to indicate tests ran but coverage couldn't be extracted COVERAGE_PCT="0.1" else echo "No tests were found or run" COVERAGE_PCT="0" fi fi # Create minimal summary JSON echo "{\"coverage\": \"$COVERAGE_PCT\"}" > coverage-summary.json echo "Final Coverage: ${COVERAGE_PCT}%" # Any coverage above 0% is acceptable for now if (( $(echo "$COVERAGE_PCT > 0" | bc -l) )); then echo "coverage_result=success" >> $GITHUB_OUTPUT else echo "coverage_result=failure" >> $GITHUB_OUTPUT fi else echo '{"error": "No test results bundle found"}' > coverage-summary.json echo "coverage_result=failure" >> $GITHUB_OUTPUT fi # ARTIFACT UPLOADS - name: Upload build artifacts uses: actions/upload-artifact@v4 if: success() with: name: ios-build-artifacts path: ios/build/DerivedData/Build/Products/Release-iphoneos/ retention-days: 7 - name: Upload coverage artifacts if: always() uses: actions/upload-artifact@v4 with: name: ios-coverage path: | ios/coverage-summary.json ios/TestResults.xcresult retention-days: 1 # LINT REPORTING - name: Read SwiftFormat Output if: always() id: swiftformat-output run: | if [ -f swiftformat-output.txt ]; then echo 'content<> $GITHUB_OUTPUT cat swiftformat-output.txt >> $GITHUB_OUTPUT echo 'EOF' >> $GITHUB_OUTPUT else echo "content=No output" >> $GITHUB_OUTPUT fi - name: Read SwiftLint Output if: always() id: swiftlint-output run: | if [ -f swiftlint-output.txt ]; then echo 'content<> $GITHUB_OUTPUT cat swiftlint-output.txt >> $GITHUB_OUTPUT echo 'EOF' >> $GITHUB_OUTPUT else echo "content=No output" >> $GITHUB_OUTPUT fi - name: Report SwiftFormat Results if: always() uses: ./.github/actions/lint-reporter with: title: 'iOS Formatting (SwiftFormat)' lint-result: ${{ steps.swiftformat.outputs.result == '0' && 'success' || 'failure' }} lint-output: ${{ steps.swiftformat-output.outputs.content }} github-token: ${{ secrets.GITHUB_TOKEN }} - name: Report SwiftLint Results if: always() uses: ./.github/actions/lint-reporter with: title: 'iOS Linting (SwiftLint)' lint-result: ${{ steps.swiftlint.outputs.result == '0' && 'success' || 'failure' }} lint-output: ${{ steps.swiftlint-output.outputs.content }} github-token: ${{ secrets.GITHUB_TOKEN }} report-coverage: name: Report iOS Coverage runs-on: blacksmith-8vcpu-ubuntu-2204 needs: [build-lint-test] if: always() && github.event_name == 'pull_request' steps: - name: Clean workspace run: | # Clean workspace for self-hosted runner rm -rf * || true rm -rf .* || true - name: Checkout code uses: actions/checkout@v4 - name: Download coverage artifacts uses: actions/download-artifact@v4 with: name: ios-coverage path: ios-coverage-artifacts - name: Read coverage summary id: coverage run: | if [ -f ios-coverage-artifacts/coverage-summary.json ]; then # Read the coverage summary COVERAGE_JSON=$(cat ios-coverage-artifacts/coverage-summary.json) echo "summary<> $GITHUB_OUTPUT echo "$COVERAGE_JSON" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT # Extract coverage percentage COVERAGE_PCT=$(echo "$COVERAGE_JSON" | jq -r '.coverage // 0') # Any coverage above 0% is acceptable for now if (( $(echo "$COVERAGE_PCT > 0" | bc -l) )); then echo "result=success" >> $GITHUB_OUTPUT else echo "result=failure" >> $GITHUB_OUTPUT fi # Format output - just show the percentage echo "output=• Coverage: ${COVERAGE_PCT}%" >> $GITHUB_OUTPUT else echo "summary={\"error\": \"No coverage data found\"}" >> $GITHUB_OUTPUT echo "result=failure" >> $GITHUB_OUTPUT echo "output=Coverage data not found" >> $GITHUB_OUTPUT fi - name: Report Coverage Results uses: ./.github/actions/lint-reporter with: title: 'iOS Test Coverage' lint-result: ${{ steps.coverage.outputs.result }} lint-output: ${{ steps.coverage.outputs.output }} github-token: ${{ secrets.GITHUB_TOKEN }}