mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +00:00
515 lines
No EOL
18 KiB
YAML
515 lines
No EOL
18 KiB
YAML
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
|
|
# Clean workspace but preserve .git directory
|
|
find . -maxdepth 1 -name '.*' -not -name '.git' -not -name '.' -not -name '..' -exec rm -rf {} + || true
|
|
rm -rf * || true
|
|
|
|
- name: Checkout code
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Verify Xcode
|
|
run: |
|
|
xcodebuild -version
|
|
swift --version
|
|
|
|
# Node.js/pnpm not needed for iOS builds
|
|
|
|
- name: Cache Homebrew packages
|
|
uses: actions/cache@v4
|
|
continue-on-error: true
|
|
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: actions/cache@v4
|
|
continue-on-error: true
|
|
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: |
|
|
# Skip Homebrew update for faster CI
|
|
export HOMEBREW_NO_AUTO_UPDATE=1
|
|
|
|
# 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
|
|
|
|
# Install tools without updating Homebrew
|
|
# brew install automatically upgrades if already installed
|
|
if 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"
|
|
|
|
# iOS doesn't need web dependencies - skip pnpm entirely
|
|
|
|
- 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"
|
|
|
|
# Use Release config for faster builds
|
|
set -o pipefail
|
|
xcodebuild build \
|
|
-workspace ../VibeTunnel.xcworkspace \
|
|
-scheme VibeTunnel-iOS \
|
|
-destination "generic/platform=iOS" \
|
|
-configuration Release \
|
|
-showBuildTimingSummary \
|
|
-quiet \
|
|
CODE_SIGNING_ALLOWED=NO \
|
|
CODE_SIGNING_REQUIRED=NO \
|
|
ONLY_ACTIVE_ARCH=NO \
|
|
-derivedDataPath build/DerivedData \
|
|
COMPILER_INDEX_STORE_ENABLE=NO || {
|
|
echo "::error::Build failed"
|
|
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
|
|
|
|
# Only enable coverage on main branch
|
|
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then
|
|
ENABLE_COVERAGE="YES"
|
|
else
|
|
ENABLE_COVERAGE="NO"
|
|
fi
|
|
|
|
set -o pipefail
|
|
xcodebuild test \
|
|
-workspace ../VibeTunnel.xcworkspace \
|
|
-scheme VibeTunnel-iOS \
|
|
-destination "platform=iOS Simulator,id=$SIMULATOR_ID" \
|
|
-resultBundlePath TestResults.xcresult \
|
|
-enableCodeCoverage $ENABLE_COVERAGE \
|
|
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
|
|
# Skip build artifact upload for PR builds to save time
|
|
- name: Upload build artifacts
|
|
uses: actions/upload-artifact@v4
|
|
if: success() && github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
with:
|
|
name: ios-build-artifacts
|
|
path: ios/build/DerivedData/Build/Products/Release-iphoneos/
|
|
retention-days: 3
|
|
|
|
- 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<<EOF' >> $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<<EOF' >> $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-2404-arm
|
|
needs: [build-lint-test]
|
|
# Only run coverage reporting on main branch where we actually collect coverage
|
|
if: always() && github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
|
|
steps:
|
|
- name: Clean workspace
|
|
run: |
|
|
# Clean workspace for self-hosted runner
|
|
# Clean workspace but preserve .git directory
|
|
find . -maxdepth 1 -name '.*' -not -name '.git' -not -name '.' -not -name '..' -exec 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<<EOF" >> $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 }} |