refactor(ci): standardize workflows and add retry mechanisms and centralized config #245
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |