vibetunnel/.github/workflows/ios.yml

488 lines
No EOL
16 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
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
continue-on-error: true
shell: bash
run: |
# Install linting and build tools
cat > Brewfile <<EOF
brew "swiftlint"
brew "swiftformat"
brew "xcbeautify"
EOF
brew bundle
# Show final status
echo "SwiftLint: $(which swiftlint || echo 'not found')"
echo "SwiftFormat: $(which swiftformat || echo 'not found')"
echo "xcbeautify: $(which xcbeautify || 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: Extract coverage summary
if: always()
id: coverage
run: |
cd ios
if [ -f TestResults.xcresult ]; then
# Use faster xcrun command to extract coverage percentage
COVERAGE_PCT=$(xcrun xccov view --report --json TestResults.xcresult 2>/dev/null | jq -r '.lineCoverage // 0' | awk '{printf "%.1f", $1 * 100}') || {
echo "::warning::Failed to extract coverage with xccov"
echo '{"error": "Failed to extract coverage data"}' > coverage-summary.json
echo "coverage_result=failure" >> $GITHUB_OUTPUT
exit 0
}
# Create minimal summary JSON
echo "{\"coverage\": \"$COVERAGE_PCT\"}" > coverage-summary.json
echo "Coverage: ${COVERAGE_PCT}%"
# Check if coverage meets threshold (75% for Swift projects)
THRESHOLD=75
if (( $(echo "$COVERAGE_PCT >= $THRESHOLD" | 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<<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-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<<EOF" >> $GITHUB_OUTPUT
echo "$COVERAGE_JSON" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
# Extract coverage percentage
COVERAGE_PCT=$(echo "$COVERAGE_JSON" | jq -r '.coverage // 0')
# Check if coverage meets threshold (75% for Swift)
THRESHOLD=75
if (( $(echo "$COVERAGE_PCT >= $THRESHOLD" | bc -l) )); then
echo "result=success" >> $GITHUB_OUTPUT
else
echo "result=failure" >> $GITHUB_OUTPUT
fi
# Format output with warning indicator if below threshold
if (( $(echo "$COVERAGE_PCT < $THRESHOLD" | bc -l) )); then
echo "output=• Coverage: ${COVERAGE_PCT}% ⚠️ (threshold: ${THRESHOLD}%)" >> $GITHUB_OUTPUT
else
echo "output=• Coverage: ${COVERAGE_PCT}% (threshold: ${THRESHOLD}%)" >> $GITHUB_OUTPUT
fi
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 }}