vibetunnel/.github/workflows/mac.yml
Peter Steinberger fc6999922f fix: make web artifact download optional in Mac CI
The Mac CI was failing when only Mac files changed because it tried to
download web build artifacts that were never created (Node.js CI was skipped).

Added continue-on-error to the artifact download step, allowing the Mac build
to proceed and build the web frontend itself via the existing build-web-frontend.sh
script that's already part of the Xcode build process.

This fixes CI failures like the one in PR #153 where Mac-only changes
would fail due to missing web artifacts.
2025-07-01 03:45:43 +01:00

425 lines
No EOL
14 KiB
YAML

name: Mac 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 macOS
runs-on: [self-hosted, macOS, ARM64]
timeout-minutes: 40
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
dest: ~/pnpm-${{ github.run_id }}
- 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/mac.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('mac/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 "jq: $(which jq || echo 'not found')"
- 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 hardlink
# Install with retries
for i in 1 2 3; do
echo "Install attempt $i"
if pnpm install --frozen-lockfile; then
echo "pnpm install succeeded"
# Force rebuild of native modules
echo "Rebuilding native modules..."
pnpm rebuild || true
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
continue-on-error: true
with:
name: web-build-${{ github.sha }}
path: web/
- name: Resolve Dependencies (once)
run: |
echo "Resolving Swift package dependencies..."
# Workspace is at root level
xcodebuild -resolvePackageDependencies -workspace VibeTunnel.xcworkspace -parallel || echo "Dependency resolution completed"
# BUILD PHASE
- name: Build Debug (Native Architecture)
timeout-minutes: 15
run: |
set -o pipefail && xcodebuild build \
-workspace VibeTunnel.xcworkspace \
-scheme VibeTunnel-Mac \
-configuration Debug \
-destination "platform=macOS" \
-showBuildTimingSummary \
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGN_ENTITLEMENTS="" \
ENABLE_HARDENED_RUNTIME=NO \
PROVISIONING_PROFILE_SPECIFIER="" \
DEVELOPMENT_TEAM="" \
COMPILER_INDEX_STORE_ENABLE=NO
- name: Build Release (Native Architecture)
timeout-minutes: 15
run: |
set -o pipefail && \
xcodebuild build \
-workspace VibeTunnel.xcworkspace \
-scheme VibeTunnel-Mac \
-configuration Release \
-destination "platform=macOS" \
-showBuildTimingSummary \
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGN_ENTITLEMENTS="" \
ENABLE_HARDENED_RUNTIME=NO \
PROVISIONING_PROFILE_SPECIFIER="" \
DEVELOPMENT_TEAM="" \
COMPILER_INDEX_STORE_ENABLE=NO
# LINT PHASE
- name: Run SwiftFormat (check mode)
id: swiftformat
continue-on-error: true
run: |
cd mac
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 mac
swiftlint 2>&1 | tee ../swiftlint-output.txt
echo "result=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT
# TEST PHASE
- name: Run tests with coverage
id: test-coverage
timeout-minutes: 10
run: |
# Use xcodebuild test for workspace testing with coverage enabled
set -o pipefail && \
xcodebuild test \
-workspace VibeTunnel.xcworkspace \
-scheme VibeTunnel-Mac \
-configuration Debug \
-destination "platform=macOS" \
-enableCodeCoverage YES \
-resultBundlePath TestResults.xcresult \
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO \
COMPILER_INDEX_STORE_ENABLE=NO || {
echo "::error::Tests failed"
echo "result=1" >> $GITHUB_OUTPUT
exit 1
}
echo "result=0" >> $GITHUB_OUTPUT
# COVERAGE EXTRACTION
- name: Debug coverage files
if: always()
run: |
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: |
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: List build products
if: always()
run: |
echo "Searching for build products..."
find ~/Library/Developer/Xcode/DerivedData -name "VibeTunnel.app" -type d 2>/dev/null || echo "No build products found"
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: mac-build-artifacts
path: |
~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/VibeTunnel.app
~/Library/Developer/Xcode/DerivedData/*/Build/Products/Release/VibeTunnel.app
- name: Upload coverage artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: mac-coverage
path: |
coverage-summary.json
TestResults.xcresult
# 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: 'Mac 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: 'Mac 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 Coverage Results
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: mac-coverage
path: mac-coverage-artifacts
- name: Read coverage summary
id: coverage
run: |
if [ -f mac-coverage-artifacts/coverage-summary.json ]; then
# Read the coverage summary
COVERAGE_JSON=$(cat mac-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: 'macOS Test Coverage'
lint-result: ${{ steps.coverage.outputs.result }}
lint-output: ${{ steps.coverage.outputs.output }}
github-token: ${{ secrets.GITHUB_TOKEN }}