name: Mobile Store Artifacts on: workflow_dispatch: inputs: version_name: description: Store version name, for example 1.4.0 required: false type: string version_code: description: Store build number/version code required: false type: string upload_android_to_play: description: Upload the signed Android App Bundle to Google Play required: false type: boolean default: true upload_ios_to_app_store: description: Upload the signed iOS IPA to App Store Connect required: false type: boolean default: true android_track: description: Google Play track for manual dispatches required: false type: choice default: production options: - production - beta - alpha - internal android_release_status: description: Google Play release status for manual dispatches required: false type: choice default: completed options: - completed - draft - inProgress - halted push: tags: - "mobile-v*" workflow_run: workflows: - Automated Tests types: - completed branches: - master permissions: contents: read concurrency: group: mobile-store-artifacts-${{ github.event.workflow_run.head_branch || github.ref_name || github.run_id }} cancel-in-progress: true jobs: android: name: Android AAB and Play upload if: > github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == github.event.repository.default_branch) runs-on: ubuntu-24.04 environment: mobile-store-production timeout-minutes: 60 env: ANDROID_PACKAGE_NAME: ${{ vars.ANDROID_PACKAGE_NAME || 'io.truckwash.twa' }} ANDROID_AAB_PATH: ${{ vars.ANDROID_AAB_PATH || 'android/app/build/outputs/bundle/release/app-release.aab' }} PLAY_STORE_TRACK: ${{ inputs.android_track || vars.PLAY_STORE_TRACK || 'production' }} PLAY_STORE_RELEASE_STATUS: ${{ inputs.android_release_status || vars.PLAY_STORE_RELEASE_STATUS || 'completed' }} PLAY_STORE_USER_FRACTION: ${{ vars.PLAY_STORE_USER_FRACTION || '' }} UPLOAD_ANDROID_TO_PLAY: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_android_to_play }} steps: - name: Checkout repository uses: actions/checkout@v5 with: ref: ${{ github.event.workflow_run.head_sha || github.sha }} - name: Guard current master release id: release-guard shell: bash env: EVENT_NAME: ${{ github.event_name }} EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch || github.ref_name }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | set -euo pipefail current=true if [[ "$EVENT_NAME" == "workflow_run" ]]; then latest_sha="$(git ls-remote origin "refs/heads/$DEFAULT_BRANCH" | awk '{print $1}')" if [[ -z "$latest_sha" ]]; then echo "Could not resolve origin/$DEFAULT_BRANCH." >&2 exit 1 fi if [[ "$latest_sha" != "$EXPECTED_SHA" ]]; then current=false echo "Skipping stale mobile upload for $EXPECTED_SHA; origin/$DEFAULT_BRANCH is $latest_sha." else echo "Mobile upload commit is current for $DEFAULT_BRANCH." fi else echo "Mobile release guard passed for $EVENT_NAME on $RELEASE_BRANCH." fi echo "current=$current" >> "$GITHUB_OUTPUT" - name: Setup Node.js if: steps.release-guard.outputs.current == 'true' uses: actions/setup-node@v5 with: node-version: 22 cache: npm - name: Setup Java if: steps.release-guard.outputs.current == 'true' uses: actions/setup-java@v4 with: distribution: temurin java-version: 21 - name: Setup Android SDK if: steps.release-guard.outputs.current == 'true' uses: android-actions/setup-android@v3 - name: Install Android SDK packages if: steps.release-guard.outputs.current == 'true' shell: bash run: | set -euo pipefail yes | sdkmanager --licenses >/dev/null || true sdkmanager "platforms;android-36" "build-tools;36.0.0" - name: Resolve mobile version if: steps.release-guard.outputs.current == 'true' shell: bash env: INPUT_VERSION_NAME: ${{ inputs.version_name || '' }} INPUT_VERSION_CODE: ${{ inputs.version_code || '' }} run: | set -euo pipefail version_name="$INPUT_VERSION_NAME" if [[ -z "$version_name" && "$GITHUB_REF_NAME" == mobile-v* ]]; then version_name="${GITHUB_REF_NAME#mobile-v}" fi if [[ -z "$version_name" ]]; then version_name="0.0.${GITHUB_RUN_NUMBER}" fi version_code="${INPUT_VERSION_CODE:-$GITHUB_RUN_NUMBER}" echo "MOBILE_VERSION_NAME=$version_name" >> "$GITHUB_ENV" echo "MOBILE_VERSION_CODE=$version_code" >> "$GITHUB_ENV" - name: Check Android store environment if: steps.release-guard.outputs.current == 'true' env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }} run: node scripts/mobile/check-store-upload-env.mjs --android - name: Install dependencies if: steps.release-guard.outputs.current == 'true' run: npm ci --legacy-peer-deps - name: Decode Android signing key if: steps.release-guard.outputs.current == 'true' shell: bash env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | set -euo pipefail keystore_path="$RUNNER_TEMP/android-release.keystore" node -e "const fs = require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.ANDROID_KEYSTORE_BASE64, 'base64'))" "$keystore_path" echo "ANDROID_KEYSTORE_FILE=$keystore_path" >> "$GITHUB_ENV" echo "ANDROID_KEYSTORE_PASSWORD=$ANDROID_KEYSTORE_PASSWORD" >> "$GITHUB_ENV" echo "ANDROID_KEY_ALIAS=$ANDROID_KEY_ALIAS" >> "$GITHUB_ENV" echo "ANDROID_KEY_PASSWORD=$ANDROID_KEY_PASSWORD" >> "$GITHUB_ENV" - name: Build and sync Android shell if: steps.release-guard.outputs.current == 'true' run: | npm run mobile:android:sync npm run mobile:permissions:check npm run mobile:android:signing:check - name: Build signed Android App Bundle if: steps.release-guard.outputs.current == 'true' working-directory: android run: ./gradlew --no-daemon bundleRelease - name: Verify Android App Bundle signature if: steps.release-guard.outputs.current == 'true' run: jarsigner -verify -certs -verbose "$ANDROID_AAB_PATH" >/dev/null - name: Upload Android artifact if: steps.release-guard.outputs.current == 'true' uses: actions/upload-artifact@v4 with: name: truck-wash-android-${{ env.MOBILE_VERSION_NAME }}-${{ github.event.workflow_run.head_sha || github.sha }} path: ${{ env.ANDROID_AAB_PATH }} if-no-files-found: error retention-days: 14 - name: Upload Android App Bundle to Google Play if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true' env: GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }} run: npm run mobile:android:play-upload ios: name: iOS IPA and App Store upload if: > github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == github.event.repository.default_branch) runs-on: macos-15 environment: mobile-store-production timeout-minutes: 90 env: IOS_PROJECT_PATH: ios/App/App.xcodeproj IOS_SCHEME: App IOS_BUNDLE_ID: io.truckwash.app UPLOAD_IOS_TO_APP_STORE: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_ios_to_app_store }} steps: - name: Checkout repository uses: actions/checkout@v5 with: ref: ${{ github.event.workflow_run.head_sha || github.sha }} - name: Guard current master release id: release-guard shell: bash env: EVENT_NAME: ${{ github.event_name }} EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch || github.ref_name }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | set -euo pipefail current=true if [[ "$EVENT_NAME" == "workflow_run" ]]; then latest_sha="$(git ls-remote origin "refs/heads/$DEFAULT_BRANCH" | awk '{print $1}')" if [[ -z "$latest_sha" ]]; then echo "Could not resolve origin/$DEFAULT_BRANCH." >&2 exit 1 fi if [[ "$latest_sha" != "$EXPECTED_SHA" ]]; then current=false echo "Skipping stale mobile upload for $EXPECTED_SHA; origin/$DEFAULT_BRANCH is $latest_sha." else echo "Mobile upload commit is current for $DEFAULT_BRANCH." fi else echo "Mobile release guard passed for $EVENT_NAME on $RELEASE_BRANCH." fi echo "current=$current" >> "$GITHUB_OUTPUT" - name: Setup Node.js if: steps.release-guard.outputs.current == 'true' uses: actions/setup-node@v5 with: node-version: 22 cache: npm - name: Resolve mobile version if: steps.release-guard.outputs.current == 'true' shell: bash env: INPUT_VERSION_NAME: ${{ inputs.version_name || '' }} INPUT_VERSION_CODE: ${{ inputs.version_code || '' }} run: | set -euo pipefail version_name="$INPUT_VERSION_NAME" if [[ -z "$version_name" && "$GITHUB_REF_NAME" == mobile-v* ]]; then version_name="${GITHUB_REF_NAME#mobile-v}" fi if [[ -z "$version_name" ]]; then version_name="0.0.${GITHUB_RUN_NUMBER}" fi version_code="${INPUT_VERSION_CODE:-$GITHUB_RUN_NUMBER}" echo "MOBILE_VERSION_NAME=$version_name" >> "$GITHUB_ENV" echo "MOBILE_VERSION_CODE=$version_code" >> "$GITHUB_ENV" - name: Check iOS store environment if: steps.release-guard.outputs.current == 'true' env: IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }} IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} IOS_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_PROVISION_PROFILE_BASE64 }} IOS_KEYCHAIN_PASSWORD: ${{ secrets.IOS_KEYCHAIN_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 }} run: node scripts/mobile/check-store-upload-env.mjs --ios - name: Install dependencies if: steps.release-guard.outputs.current == 'true' run: npm ci --legacy-peer-deps - name: Build and sync iOS shell if: steps.release-guard.outputs.current == 'true' run: | npm run build npx cap sync ios npm run mobile:permissions:check - name: Install Apple signing assets if: steps.release-guard.outputs.current == 'true' shell: bash env: IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }} IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} IOS_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_PROVISION_PROFILE_BASE64 }} IOS_KEYCHAIN_PASSWORD: ${{ secrets.IOS_KEYCHAIN_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | set -euo pipefail certificate_path="$RUNNER_TEMP/apple-distribution.p12" profile_path="$RUNNER_TEMP/app-store.mobileprovision" keychain_path="$RUNNER_TEMP/app-signing.keychain-db" profile_plist="$RUNNER_TEMP/profile.plist" node -e "const fs = require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.IOS_CERTIFICATE_BASE64, 'base64'))" "$certificate_path" node -e "const fs = require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.IOS_PROVISION_PROFILE_BASE64, 'base64'))" "$profile_path" security create-keychain -p "$IOS_KEYCHAIN_PASSWORD" "$keychain_path" security set-keychain-settings -lut 21600 "$keychain_path" security unlock-keychain -p "$IOS_KEYCHAIN_PASSWORD" "$keychain_path" security import "$certificate_path" -P "$IOS_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$keychain_path" security list-keychain -d user -s "$keychain_path" $(security list-keychains -d user | tr -d '"') security set-key-partition-list -S apple-tool:,apple: -s -k "$IOS_KEYCHAIN_PASSWORD" "$keychain_path" mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles" security cms -D -i "$profile_path" > "$profile_plist" profile_uuid="$(/usr/libexec/PlistBuddy -c 'Print UUID' "$profile_plist")" profile_name="$(/usr/libexec/PlistBuddy -c 'Print Name' "$profile_plist")" cp "$profile_path" "$HOME/Library/MobileDevice/Provisioning Profiles/$profile_uuid.mobileprovision" echo "APPLE_TEAM_ID=$APPLE_TEAM_ID" >> "$GITHUB_ENV" echo "IOS_KEYCHAIN_PATH=$keychain_path" >> "$GITHUB_ENV" echo "IOS_PROFILE_UUID=$profile_uuid" >> "$GITHUB_ENV" echo "IOS_PROFILE_NAME=$profile_name" >> "$GITHUB_ENV" - name: Install App Store Connect API key if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_IOS_TO_APP_STORE == 'true' shell: bash env: APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 }} run: | set -euo pipefail private_keys_dir="$RUNNER_TEMP/private_keys" private_key_path="$private_keys_dir/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8" mkdir -p "$private_keys_dir" node -e "const fs = require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64, 'base64'))" "$private_key_path" chmod 600 "$private_key_path" echo "API_PRIVATE_KEYS_DIR=$private_keys_dir" >> "$GITHUB_ENV" echo "APP_STORE_CONNECT_API_KEY_ID=$APP_STORE_CONNECT_API_KEY_ID" >> "$GITHUB_ENV" echo "APP_STORE_CONNECT_ISSUER_ID=$APP_STORE_CONNECT_ISSUER_ID" >> "$GITHUB_ENV" echo "APP_STORE_CONNECT_API_KEY_PATH=$private_key_path" >> "$GITHUB_ENV" - name: Resolve Swift packages if: steps.release-guard.outputs.current == 'true' run: xcodebuild -resolvePackageDependencies -project "$IOS_PROJECT_PATH" -scheme "$IOS_SCHEME" - name: Archive iOS app if: steps.release-guard.outputs.current == 'true' run: | xcodebuild \ -project "$IOS_PROJECT_PATH" \ -scheme "$IOS_SCHEME" \ -configuration Release \ -destination "generic/platform=iOS" \ -archivePath "$RUNNER_TEMP/TruckWash.xcarchive" \ archive \ DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ CODE_SIGN_STYLE=Manual \ CODE_SIGN_IDENTITY="Apple Distribution" \ PROVISIONING_PROFILE_SPECIFIER="$IOS_PROFILE_NAME" \ MARKETING_VERSION="$MOBILE_VERSION_NAME" \ CURRENT_PROJECT_VERSION="$MOBILE_VERSION_CODE" - name: Export iOS IPA if: steps.release-guard.outputs.current == 'true' shell: bash run: | set -euo pipefail export_method="app-store-connect" if ! xcodebuild -help 2>&1 | grep -q "app-store-connect"; then export_method="app-store" fi export_options="$RUNNER_TEMP/ExportOptions.plist" cat > "$export_options" < method $export_method signingStyle manual teamID $APPLE_TEAM_ID provisioningProfiles $IOS_BUNDLE_ID $IOS_PROFILE_NAME stripSwiftSymbols manageAppVersionAndBuildNumber EOF xcodebuild \ -exportArchive \ -archivePath "$RUNNER_TEMP/TruckWash.xcarchive" \ -exportPath "$RUNNER_TEMP/ios-export" \ -exportOptionsPlist "$export_options" ipa_path="$(find "$RUNNER_TEMP/ios-export" -name '*.ipa' -print -quit)" test -n "$ipa_path" echo "IOS_IPA_PATH=$ipa_path" >> "$GITHUB_ENV" - name: Upload iOS artifact if: steps.release-guard.outputs.current == 'true' uses: actions/upload-artifact@v4 with: name: truck-wash-ios-${{ env.MOBILE_VERSION_NAME }}-${{ github.event.workflow_run.head_sha || github.sha }} path: ${{ runner.temp }}/ios-export/*.ipa if-no-files-found: error retention-days: 14 - name: Validate iOS IPA with App Store Connect if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_IOS_TO_APP_STORE == 'true' shell: bash run: | set -euo pipefail xcrun altool \ --validate-app \ --type ios \ --file "$IOS_IPA_PATH" \ --apiKey "$APP_STORE_CONNECT_API_KEY_ID" \ --apiIssuer "$APP_STORE_CONNECT_ISSUER_ID" - name: Upload iOS IPA to App Store Connect if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_IOS_TO_APP_STORE == 'true' shell: bash run: | set -euo pipefail xcrun altool \ --upload-app \ --type ios \ --file "$IOS_IPA_PATH" \ --apiKey "$APP_STORE_CONNECT_API_KEY_ID" \ --apiIssuer "$APP_STORE_CONNECT_ISSUER_ID" - name: Clean up Apple signing assets if: always() shell: bash run: | if [[ -n "${IOS_KEYCHAIN_PATH:-}" ]]; then security delete-keychain "$IOS_KEYCHAIN_PATH" || true fi if [[ -n "${IOS_PROFILE_UUID:-}" ]]; then rm -f "$HOME/Library/MobileDevice/Provisioning Profiles/$IOS_PROFILE_UUID.mobileprovision" fi if [[ -n "${APP_STORE_CONNECT_API_KEY_PATH:-}" ]]; then rm -f "$APP_STORE_CONNECT_API_KEY_PATH" fi