Skip to content

refactor(ci): standardize workflows and add retry mechanisms and centralized config #245

refactor(ci): standardize workflows and add retry mechanisms and centralized config

refactor(ci): standardize workflows and add retry mechanisms and centralized config #245

name: Kotlin Multiplatform CI
on:
pull_request:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Lint (ubuntu-latest)
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: kotlin-multiplatform
steps:
- uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
cache: 'gradle'
- name: Cache Gradle dependencies
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
kotlin-multiplatform/.gradle
key: gradle-${{ runner.os }}-${{ hashFiles('kotlin-multiplatform/gradle/wrapper/gradle-wrapper.properties', 'kotlin-multiplatform/**/*.gradle*', 'kotlin-multiplatform/gradle/libs.versions.toml') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Create test .env file
run: |
echo "DITTO_APP_ID=test_app_id" > ../.env
echo "DITTO_PLAYGROUND_TOKEN=test_token" >> ../.env
echo "DITTO_AUTH_URL=https://test.com" >> ../.env
echo "DITTO_WEBSOCKET_URL=wss://test.com" >> ../.env
- name: Make gradlew executable
run: chmod +x gradlew
- name: Run Detekt lint
run: ./gradlew detekt --stacktrace
- name: Upload Detekt reports
uses: actions/upload-artifact@v4
if: always()
with:
name: detekt-reports
path: kotlin-multiplatform/composeApp/build/reports/detekt/
build-android:
name: Build Android (ubuntu-latest)
runs-on: ubuntu-latest
timeout-minutes: 30
needs: lint
defaults:
run:
working-directory: kotlin-multiplatform
steps:
- uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
cache: 'gradle'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Cache Gradle dependencies
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
kotlin-multiplatform/.gradle
key: gradle-${{ runner.os }}-${{ hashFiles('kotlin-multiplatform/gradle/wrapper/gradle-wrapper.properties', 'kotlin-multiplatform/**/*.gradle*', 'kotlin-multiplatform/gradle/libs.versions.toml') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Create production .env file
run: |
echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > ../.env
echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> ../.env
echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> ../.env
echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> ../.env
- name: Make gradlew executable
run: chmod +x gradlew
- name: Build Android APKs
run: ./gradlew :composeApp:assembleDebug :composeApp:assembleDebugAndroidTest --stacktrace
- name: Upload APK artifacts
uses: actions/upload-artifact@v4
with:
name: android-kmp-apks-${{ github.run_number }}
path: |
kotlin-multiplatform/composeApp/build/outputs/apk/debug/composeApp-debug.apk
kotlin-multiplatform/composeApp/build/outputs/apk/androidTest/debug/composeApp-debug-androidTest.apk
retention-days: 1
build-ios:
name: Build iOS (macos-latest)
runs-on: macos-latest
timeout-minutes: 30
needs: lint
outputs:
ios-build-success: ${{ steps.build-status.outputs.success }}
defaults:
run:
working-directory: kotlin-multiplatform
steps:
- uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
cache: 'gradle'
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: 'latest-stable'
- name: Cache Gradle dependencies
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
kotlin-multiplatform/.gradle
key: gradle-${{ runner.os }}-${{ hashFiles('kotlin-multiplatform/gradle/wrapper/gradle-wrapper.properties', 'kotlin-multiplatform/**/*.gradle*', 'kotlin-multiplatform/gradle/libs.versions.toml') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Write production .env
run: |
echo "DITTO_APP_ID=${{ secrets.DITTO_APP_ID }}" > ../.env
echo "DITTO_PLAYGROUND_TOKEN=${{ secrets.DITTO_PLAYGROUND_TOKEN }}" >> ../.env
echo "DITTO_AUTH_URL=${{ secrets.DITTO_AUTH_URL }}" >> ../.env
echo "DITTO_WEBSOCKET_URL=${{ secrets.DITTO_WEBSOCKET_URL }}" >> ../.env
- name: Make gradlew executable
run: chmod +x gradlew
- name: Build KMP iOS frameworks (sim + device)
run: |
./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64 --stacktrace
./gradlew :composeApp:linkDebugFrameworkIosArm64 --stacktrace
# ---- iOS app build without archive/signing (more reliable on CI) ----
- name: Prepare Xcode container (project/workspace + CocoaPods)
run: |
set -euo pipefail
if [ -f "iosApp/Podfile" ]; then
echo "Podfile found → installing pods"
sudo gem install cocoapods --no-document
cd iosApp
pod repo update
pod install
cd ..
fi
if [ -f "iosApp/iosApp.xcworkspace" ]; then
echo "XCWORKSPACE=1" >> $GITHUB_ENV
CONTAINER="iosApp/iosApp.xcworkspace"
CONTAINER_FLAG="-workspace"
else
echo "XCWORKSPACE=0" >> $GITHUB_ENV
CONTAINER="iosApp/iosApp.xcodeproj"
CONTAINER_FLAG="-project"
fi
echo "CONTAINER=$CONTAINER" >> $GITHUB_ENV
echo "CONTAINER_FLAG=$CONTAINER_FLAG" >> $GITHUB_ENV
echo "Available schemes:"
xcodebuild -list $CONTAINER_FLAG "$CONTAINER"
- name: Build device .app (no signing)
run: |
set -euo pipefail
DERIVED="$PWD/build/DerivedData"
PRODUCTS="$DERIVED/Build/Products"
xcodebuild \
$CONTAINER_FLAG "$CONTAINER" \
-scheme iosApp \
-configuration Debug \
-sdk iphoneos \
-derivedDataPath "$DERIVED" \
CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" \
ENABLE_BITCODE=NO \
ONLY_ACTIVE_ARCH=NO \
build
echo "Searching for device .app…"
APP_DIR=$(find "$PRODUCTS/Debug-iphoneos" -maxdepth 1 -type d -name "*.app" | head -1 || true)
if [ -z "${APP_DIR:-}" ]; then
echo "❌ Could not locate Debug-iphoneos/*.app. Dumping tree for debugging:"
find "$PRODUCTS" -maxdepth 3 -print
exit 1
fi
echo "APP_DIR=$APP_DIR" >> $GITHUB_ENV
echo "✅ Found app: $APP_DIR"
- name: Create unsigned .ipa for BrowserStack
run: |
set -euo pipefail
mkdir -p build/Payload
cp -R "$APP_DIR" build/Payload/
(cd build && zip -qry iosApp-unsigned.ipa Payload && rm -rf Payload)
test -f build/iosApp-unsigned.ipa || (echo "❌ IPA not created" && exit 1)
ls -la build/iosApp-unsigned.ipa
- name: Upload iOS IPA Artifact
uses: actions/upload-artifact@v4
with:
name: ios-kmp-ipa-${{ github.run_number }}
path: kotlin-multiplatform/build/iosApp-unsigned.ipa
retention-days: 1
- name: Set build status
id: build-status
run: echo "success=true" >> $GITHUB_OUTPUT
build-desktop:
name: Build Desktop (ubuntu-latest)
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: kotlin-multiplatform
steps:
- uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
cache: 'gradle'
- name: Cache Gradle dependencies
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
kotlin-multiplatform/.gradle
key: gradle-${{ runner.os }}-${{ hashFiles('kotlin-multiplatform/gradle/wrapper/gradle-wrapper.properties', 'kotlin-multiplatform/**/*.gradle*', 'kotlin-multiplatform/gradle/libs.versions.toml') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Create test .env file
run: |
echo "DITTO_APP_ID=test_app_id" > ../.env
echo "DITTO_PLAYGROUND_TOKEN=test_token" >> ../.env
echo "DITTO_AUTH_URL=https://test.com" >> ../.env
echo "DITTO_WEBSOCKET_URL=wss://test.com" >> ../.env
- name: Make gradlew executable
run: chmod +x gradlew
- name: Build Desktop application
run: ./gradlew :composeApp:packageDistributionForCurrentOS --stacktrace
- name: Upload Desktop build outputs
uses: actions/upload-artifact@v4
if: always()
with:
name: desktop-build-outputs
path: |
kotlin-multiplatform/composeApp/build/compose/binaries/main/
kotlin-multiplatform/composeApp/build/reports/tests/desktopTest/
browserstack-android:
name: BrowserStack Android Testing
runs-on: ubuntu-latest
needs: [build-android]
if: github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
timeout-minutes: 150
steps:
- uses: actions/checkout@v4
- name: Download Android APK artifacts
uses: actions/download-artifact@v4
with:
name: android-kmp-apks-${{ github.run_number }}
path: kotlin-multiplatform/composeApp/build/outputs/apk/
- name: Upload APKs to BrowserStack
id: upload
run: |
CREDS="${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}"
# Upload app APK
echo "Uploading app APK to BrowserStack..."
APP_RESPONSE=$(curl -u "$CREDS" \
-X POST "https://api-cloud.browserstack.com/app-automate/espresso/v2/app" \
-F "file=@kotlin-multiplatform/composeApp/build/outputs/apk/debug/composeApp-debug.apk" \
-F "custom_id=ditto-kotlin-multiplatform-app")
APP_URL=$(echo "$APP_RESPONSE" | jq -r .app_url)
if [ "$APP_URL" = "null" ] || [ -z "$APP_URL" ]; then
echo "Error: Failed to upload app APK"
echo "Response: $APP_RESPONSE"
exit 1
fi
echo "app_url=$APP_URL" >> "$GITHUB_OUTPUT"
echo "App APK uploaded: $APP_URL"
# Upload test APK
echo "Uploading test APK to BrowserStack..."
TEST_RESPONSE=$(curl -u "$CREDS" \
-X POST "https://api-cloud.browserstack.com/app-automate/espresso/v2/test-suite" \
-F "file=@kotlin-multiplatform/composeApp/build/outputs/apk/androidTest/debug/composeApp-debug-androidTest.apk" \
-F "custom_id=ditto-kotlin-multiplatform-test")
TEST_URL=$(echo "$TEST_RESPONSE" | jq -r .test_suite_url)
if [ "$TEST_URL" = "null" ] || [ -z "$TEST_URL" ]; then
echo "Error: Failed to upload test APK"
echo "Response: $TEST_RESPONSE"
exit 1
fi
echo "test_url=$TEST_URL" >> "$GITHUB_OUTPUT"
echo "Test APK uploaded: $TEST_URL"
- name: Seed and execute tests on BrowserStack
id: test
uses: nick-fields/retry@v3
with:
max_attempts: 5
timeout_minutes: 20
retry_wait_seconds: 900
command: |
# Seed test task to Ditto Cloud
echo "Seeding test task to Ditto Cloud..."
TIMESTAMP=$(date +%s)
INVERTED_TIMESTAMP=$((9999999999 - TIMESTAMP))
SEED_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
-H 'Content-type: application/json' \
-H "Authorization: Bearer ${{ secrets.DITTO_API_KEY }}" \
-d "{
\"statement\": \"INSERT INTO tasks DOCUMENTS (:newTask) ON ID CONFLICT DO UPDATE\",
\"args\": {
\"newTask\": {
\"_id\": \"${INVERTED_TIMESTAMP}_kotlin-multiplatform_ci_test_${{ github.run_id }}_${{ github.run_number }}\",
\"title\": \"${INVERTED_TIMESTAMP}_kotlin-multiplatform_ci_test_${{ github.run_id }}_${{ github.run_number }}\",
\"done\": false,
\"deleted\": false
}
}
}" \
"https://${{ secrets.DITTO_API_URL }}/api/v4/store/execute")
HTTP_CODE=$(echo "$SEED_RESPONSE" | tail -n1)
BODY=$(echo "$SEED_RESPONSE" | sed '$d')
if [ "$HTTP_CODE" -eq 200 ] || [ "$HTTP_CODE" -eq 201 ]; then
TASK_TITLE="${INVERTED_TIMESTAMP}_kotlin-multiplatform_ci_test_${{ github.run_id }}_${{ github.run_number }}"
echo "Seeded task: $TASK_TITLE"
else
echo "Error: Failed to seed task. HTTP Status: $HTTP_CODE"
echo "Response: $BODY"
exit 1
fi
# Load devices from centralized config
DEVICES=$(jq -c '.["kotlin-multiplatform"].android.devices' .github/browserstack-devices.json)
BUILD_RESPONSE=$(curl -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \
-X POST "https://api-cloud.browserstack.com/app-automate/espresso/v2/build" \
-H "Content-Type: application/json" \
-d "{
\"app\": \"${{ steps.upload.outputs.app_url }}\",
\"testSuite\": \"${{ steps.upload.outputs.test_url }}\",
\"devices\": $DEVICES,
\"project\": \"QuickStart Kotlin Multiplatform\",
\"buildName\": \"CI Build #${{ github.run_number }}\",
\"buildTag\": \"${{ github.ref_name }}\",
\"deviceLogs\": true,
\"video\": true,
\"networkLogs\": true,
\"autoGrantPermissions\": true,
\"acceptInsecureCerts\": true,
\"enableWebsocketTunneling\": true,
\"instrumentationOptions\": {
\"DITTO_CLOUD_TASK_TITLE\": \"$TASK_TITLE\"
}
}")
echo "BrowserStack API Response:"
echo "$BUILD_RESPONSE"
BUILD_ID=$(echo "$BUILD_RESPONSE" | jq -r .build_id)
# Check if BUILD_ID is null or empty
if [ "$BUILD_ID" = "null" ] || [ -z "$BUILD_ID" ]; then
echo "Error: Failed to create BrowserStack build"
echo "Response: $BUILD_RESPONSE"
exit 1
fi
echo "Build started with ID: $BUILD_ID"
# Wait for BrowserStack tests to complete
MAX_WAIT_TIME=1080 # 18 minutes
CHECK_INTERVAL=30 # Check every 30 seconds
ELAPSED=0
while [ $ELAPSED -lt $MAX_WAIT_TIME ]; do
BUILD_STATUS_RESPONSE=$(curl -s -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \
"https://api-cloud.browserstack.com/app-automate/espresso/v2/builds/$BUILD_ID")
BUILD_STATUS=$(echo "$BUILD_STATUS_RESPONSE" | jq -r .status)
# Check for API errors
if [ "$BUILD_STATUS" = "null" ] || [ -z "$BUILD_STATUS" ]; then
echo "Error getting build status. Response: $BUILD_STATUS_RESPONSE"
sleep $CHECK_INTERVAL
ELAPSED=$((ELAPSED + CHECK_INTERVAL))
continue
fi
echo "Build status: $BUILD_STATUS (elapsed: ${ELAPSED}s)"
# Check for completion states
if [ "$BUILD_STATUS" = "done" ] || [ "$BUILD_STATUS" = "failed" ] || [ "$BUILD_STATUS" = "error" ] || [ "$BUILD_STATUS" = "passed" ] || [ "$BUILD_STATUS" = "completed" ]; then
echo "Build completed with status: $BUILD_STATUS"
break
fi
sleep $CHECK_INTERVAL
ELAPSED=$((ELAPSED + CHECK_INTERVAL))
done
# Get final results
FINAL_RESULT=$(curl -s -u "${{ secrets.BROWSERSTACK_USERNAME }}:${{ secrets.BROWSERSTACK_ACCESS_KEY }}" \
"https://api-cloud.browserstack.com/app-automate/espresso/v2/builds/$BUILD_ID")
echo "Final build result:"
echo "$FINAL_RESULT" | jq .
# Check if we got valid results
if echo "$FINAL_RESULT" | jq -e .devices > /dev/null 2>&1; then
# Check if the overall build passed
BUILD_STATUS=$(echo "$FINAL_RESULT" | jq -r .status)
if [ "$BUILD_STATUS" != "passed" ]; then
echo "Build failed with status: $BUILD_STATUS"
exit 1
else
echo "All tests passed successfully!"
fi
else
echo "Warning: Could not parse final results"
echo "Raw response: $FINAL_RESULT"
fi
# TEMPORARILY DISABLED: BrowserStack iOS Testing (will be re-enabled later)
# FIXME: Accessibility tags were not working to detect labels on BrowserStack at time of commenting
# browserstack-ios job removed for now
summary:
name: CI Report
runs-on: ubuntu-latest
needs: [lint, build-android, build-ios, build-desktop, browserstack-android]
if: always()
steps:
- name: Check build results
run: |
echo "## Kotlin Multiplatform CI Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Target | Status |" >> $GITHUB_STEP_SUMMARY
echo "|--------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Lint | ${{ needs.lint.result == 'success' && '✅ Passed' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY
echo "| Android Build | ${{ needs.build-android.result == 'success' && '✅ Passed' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY
echo "| iOS Build | ${{ needs.build-ios.result == 'success' && '✅ Passed' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY
echo "| Desktop Build | ${{ needs.build-desktop.result == 'success' && '✅ Passed' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY
echo "| Android BrowserStack | ${{ needs.browserstack-android.result == 'success' && '✅ Passed' || (needs.browserstack-android.result == 'skipped' && '⏭️ Skipped') || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY
echo "| iOS BrowserStack | ⏭️ Skipped (Temporarily Disabled) |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Check core build results
if [[ "${{ needs.lint.result }}" != "success" ]] || \
[[ "${{ needs.build-android.result }}" != "success" ]] || \
[[ "${{ needs.build-ios.result }}" != "success" ]] || \
[[ "${{ needs.build-desktop.result }}" != "success" ]]; then
echo "❌ Core builds failed" >> $GITHUB_STEP_SUMMARY
exit 1
else
echo "✅ All core builds passed successfully!" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Testing results summary
if [[ "${{ needs.browserstack-android.result }}" == "success" ]]; then
echo "✅ Android BrowserStack testing passed. iOS BrowserStack testing temporarily disabled." >> $GITHUB_STEP_SUMMARY
else
echo "⚠️ Core builds passed but testing had issues. Check testing logs above." >> $GITHUB_STEP_SUMMARY
fi
fi
# BrowserStack link
if [[ "${{ needs.browserstack-android.result }}" != "skipped" ]]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "### BrowserStack Session" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "🤖 [View Android Test Results](https://app-automate.browserstack.com/builds?project=QuickStart+Kotlin+Multiplatform&build=CI+Build+%23${{ github.run_number }})" >> $GITHUB_STEP_SUMMARY
fi