# Tag-driven release. Builds standalone rather than consuming ci.yml # artifacts: a tag run has no upstream run to pull from, and a release # should be reproducible from the tagged tree alone. name: android / release on: push: tags: ['v*.*.*'] permissions: contents: write concurrency: group: android-release-${{ github.ref }} cancel-in-progress: false jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: persist-credentials: false # Full history + tags for the versionCode guard below. fetch-depth: 0 # Play rejects an upload whose versionCode is not higher than the last # one, but only after the whole build has run — fail fast instead. # Compares this tag's versionCode against the previous v*.*.* tag, # reading the wrapper-era path for tags that predate the native app. - name: Guard against an unbumped versionCode run: | set -eu current=$(grep -oP 'versionCode = \K\d+' android/app/build.gradle.kts) echo "versionCode at ${GITHUB_REF_NAME}: $current" prev_tag=$(git tag --list 'v*.*.*' --sort=-v:refname | grep -Fvx "${GITHUB_REF_NAME}" | head -n1 || true) if [ -z "$prev_tag" ]; then echo "No previous release tag; skipping comparison." exit 0 fi prev=$(git show "$prev_tag:android/app/build.gradle.kts" 2>/dev/null | grep -oP 'versionCode = \K\d+' || true) if [ -z "$prev" ]; then # Wrapper-era layout (Capacitor project under android/android/). prev=$(git show "$prev_tag:android/android/app/build.gradle" 2>/dev/null | grep -oP 'versionCode \K\d+' || true) fi if [ -z "$prev" ]; then echo "::warning::Could not read versionCode at $prev_tag; skipping comparison." exit 0 fi echo "versionCode at $prev_tag: $prev" if [ "$current" -le "$prev" ]; then echo "::error::versionCode $current at ${GITHUB_REF_NAME} must be greater than $prev at $prev_tag. Bump it in android/app/build.gradle.kts before tagging." exit 1 fi - uses: ./.github/actions/setup-android # ci.yml does not run on tags, so this is the only gate before signing. - name: Lint and test run: ./gradlew :app:lint :app:test working-directory: android # An unset secret expands to an empty string, which would otherwise # decode to a zero-byte keystore and fail later inside Gradle with an # unrelated-looking error. Check every signing secret up front. - name: Decode keystore env: KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} KEY_ALIAS: ${{ secrets.KEY_ALIAS }} KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} run: | set -eu missing=0 for name in KEYSTORE_BASE64 KEYSTORE_PASSWORD KEY_ALIAS KEY_PASSWORD; do if [ -z "$(printenv "$name")" ]; then echo "::error::Repository secret $name is empty or not set; it is required to sign the release." missing=1 fi done [ "$missing" -eq 0 ] || exit 1 printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$GITHUB_WORKSPACE/keystore.p12" if [ ! -s "$GITHUB_WORKSPACE/keystore.p12" ]; then echo "::error::KEYSTORE_BASE64 decoded to an empty keystore; re-encode it with 'base64 -w0 keystore.p12'." exit 1 fi - name: Build signed AAB + APK working-directory: android env: LOTO_KEYSTORE_PATH: ${{ github.workspace }}/keystore.p12 LOTO_KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} LOTO_KEY_ALIAS: ${{ secrets.KEY_ALIAS }} LOTO_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} run: ./gradlew :app:bundleRelease :app:assembleRelease # The decoded keystore has no real exposure on this ephemeral # runner (nothing archives the workspace), but removing it explicitly # closes the class of mistake where a future step does. if: always() # so it runs even if the build step above fails. - name: Remove decoded keystore if: always() run: rm -f "$GITHUB_WORKSPACE/keystore.p12" - name: Upload to GitHub Release uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: files: | android/app/build/outputs/apk/release/*.apk android/app/build/outputs/bundle/release/*.aab # Auto-publish to Google Play (gated on the service-account secret). # Skips silently if PLAY_SERVICE_ACCOUNT_JSON is not configured, so # tagging a release before Play setup still produces a GH Release. # Uploads to "alpha", the closed-testing track. Internal testing is a # separate track and does not count toward the 12-tester requirement # for production access, so releases have to land here to reach the # testers. An unknown track name fails the step with the list of # valid tracks rather than publishing somewhere unintended. - name: Check Play Store config id: play env: PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} run: | if [ -n "$PLAY_SERVICE_ACCOUNT_JSON" ]; then echo "configured=true" >> "$GITHUB_OUTPUT" else echo "configured=false" >> "$GITHUB_OUTPUT" echo "::notice::PLAY_SERVICE_ACCOUNT_JSON not set; skipping Play Store upload." fi - name: Upload to Google Play (closed testing track) if: steps.play.outputs.configured == 'true' uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1.1.5 with: serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} packageName: com.miti99.loto releaseFiles: android/app/build/outputs/bundle/release/*.aab tracks: alpha status: completed # versionCode is bumped manually in android/app/build.gradle.kts # before tagging; the guard step above catches a forgotten bump.