From 7a8231ba9c5154c5e85170f6aef8037d0e7c3914 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 23 Oct 2026 07:03:54 -0500 Subject: [PATCH] feat(release): target API 36 and upload each tag's bundle to Google Play Step 1 of M8 (docs/android/PLAN.md item 9). - compileSdk/targetSdk 36 (Play's floor since 2026-08-31). AGP 8.6.1 cannot build against 36, so AGP goes to 8.13.2 (the last 8.x) with the Gradle 8.14.3 wrapper. Kotlin, KSP and Hilt are unchanged. All three workflows install platforms;android-36; AGP 8.13 still defaults to build-tools 35.0.0. - release.yml also runs bundleRelease, signed with the same keystore, and attaches the .aab to the Gitea release beside the APK (the first bundle has to be uploaded by hand, and this is where it comes from). - A last step uploads the bundle to Play's internal track through the Play Developer API v3 edit flow. The OAuth token comes from a service-account JWT signed with openssl; the key is the new PLAY_SERVICE_ACCOUNT_JSON secret. While that secret is unset, the step warns and skips. The step runs after the Gitea release, so a Play failure never holds back the APK. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .gitea/workflows/pr-checks.yml | 2 +- .gitea/workflows/release.yml | 122 ++++++++++++++++-- .gitea/workflows/sonarqube.yml | 2 +- app/build.gradle.kts | 4 +- .../main/res/xml/network_security_config.xml | 2 +- gradle/libs.versions.toml | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 7 files changed, 119 insertions(+), 17 deletions(-) diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index 1df823f..374f506 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -63,7 +63,7 @@ jobs: - name: Install Android SDK packages run: | set +o pipefail - yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0" + yes | sdkmanager "platform-tools" "platforms;android-36" "build-tools;35.0.0" - name: Cache Gradle uses: actions/cache@v4 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 7f75623..de3808c 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -18,6 +18,17 @@ # ANDROID_KEYSTORE_PASSWORD — keystore password # ANDROID_KEY_ALIAS — key alias (e.g. runicgateway) # ANDROID_KEY_PASSWORD — key password (== store password for a PKCS12 keystore) +# PLAY_SERVICE_ACCOUNT_JSON — Google Cloud service-account key (the whole JSON file) +# with release rights on com.runicgateway.app only. Unset, +# the Play step is skipped with a warning (PLAN.md M8). +# +# Google Play (PLAN.md M8): the same keystore also signs an app bundle (.aab). After +# the Gitea release exists, the bundle is uploaded to Play's *internal* track through +# the Play Developer API. Promotion to closed testing / production stays a click in +# the console, so a tag never reaches testers or review on its own. Google requires +# the app's FIRST bundle to be uploaded by hand: take it from the Gitea release +# assets. The Play step runs last, so a Play failure fails the run but never holds +# back the APK release. # # Runner handling matches pr-checks.yml (self-hosted `ubuntu-latest`): the container # lacks git/curl/unzip and can't reach api.adoptium.net, so we apt-install the base @@ -56,7 +67,7 @@ jobs: - name: Install base tools + JDK 17 run: | apt-get update - apt-get install -y git curl unzip jq openjdk-17-jdk-headless + apt-get install -y git curl unzip jq openssl openjdk-17-jdk-headless echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV" - name: Check out the release tag (full history for the changelog) @@ -103,6 +114,7 @@ jobs: echo echo "---" echo "Signed APK — sideload on Android 10+ (§10). The app self-configures its shard site on first run." + echo "The .aab is the same build as a Google Play bundle; it does not install on a phone." } > dist/CHANGELOG.md echo "version=${VERSION}" >> "$GITHUB_OUTPUT" @@ -123,7 +135,7 @@ jobs: - name: Install Android SDK packages run: | set +o pipefail - yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0" + yes | sdkmanager "platform-tools" "platforms;android-36" "build-tools;35.0.0" - name: Decode signing keystore env: @@ -148,7 +160,7 @@ jobs: sed -i -E "s/(toIntOrNull\(\) \?: )[0-9]+/\1${VERSION_CODE}/" "${GRADLE_MODULE}/build.gradle.kts" grep -nE "versionCode = |versionName = " "${GRADLE_MODULE}/build.gradle.kts" - - name: Unit tests + signed release APK + - name: Unit tests + signed release APK + app bundle env: ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} @@ -156,15 +168,20 @@ jobs: run: | set -euo pipefail chmod +x ./gradlew - ./gradlew --no-daemon :${GRADLE_MODULE}:testDebugUnitTest :${GRADLE_MODULE}:assembleRelease + ./gradlew --no-daemon :${GRADLE_MODULE}:testDebugUnitTest \ + :${GRADLE_MODULE}:assembleRelease :${GRADLE_MODULE}:bundleRelease - - name: Package APK + SHA256SUMS + - name: Package APK + AAB + SHA256SUMS run: | set -euo pipefail - SRC="${GRADLE_MODULE}/build/outputs/apk/release/app-release.apk" - test -f "$SRC" || { echo "::error::release APK not found at $SRC"; exit 1; } - cp "$SRC" "dist/runic-gateway-${{ steps.plan.outputs.version }}.apk" - ( cd dist && sha256sum "runic-gateway-${{ steps.plan.outputs.version }}.apk" > SHA256SUMS ) + VERSION="${{ steps.plan.outputs.version }}" + APK="${GRADLE_MODULE}/build/outputs/apk/release/app-release.apk" + AAB="${GRADLE_MODULE}/build/outputs/bundle/release/app-release.aab" + test -f "$APK" || { echo "::error::release APK not found at $APK"; exit 1; } + test -f "$AAB" || { echo "::error::release bundle not found at $AAB"; exit 1; } + cp "$APK" "dist/runic-gateway-${VERSION}.apk" + cp "$AAB" "dist/runic-gateway-${VERSION}.aab" + ( cd dist && sha256sum "runic-gateway-${VERSION}.apk" "runic-gateway-${VERSION}.aab" > SHA256SUMS ) ls -l dist && cat dist/SHA256SUMS # ── Create the Gitea release + upload assets (no push to main) ─────── @@ -186,9 +203,94 @@ jobs: | jq -r '.id')" echo "Created release ${TAG} (id=${REL_ID})" - for f in "runic-gateway-${{ steps.plan.outputs.version }}.apk" SHA256SUMS; do + for f in "runic-gateway-${{ steps.plan.outputs.version }}.apk" \ + "runic-gateway-${{ steps.plan.outputs.version }}.aab" SHA256SUMS; do curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \ -H "Authorization: token ${CI_TOKEN}" \ -F "attachment=@dist/${f}" >/dev/null echo " uploaded ${f}" done + + # ── Google Play: bundle → internal track (PLAN.md M8) ──────────────── + # Play Developer API v3 edit flow: open an edit, upload the bundle, put its + # versionCode on the internal track, commit. The OAuth token comes from a + # service-account JWT signed with openssl, so no Gradle plugin and no Google + # SDK. The release is "completed" on the internal track, which Play only + # accepts once the app has had a release rolled out; before that, its error + # is "Only releases with status draft may be created on draft app", and the + # fix is the hand upload of the first bundle, not a change here. + - name: Upload the bundle to Google Play (internal track) + env: + PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + run: | + set -euo pipefail + if [ -z "${PLAY_SERVICE_ACCOUNT_JSON:-}" ]; then + echo "::warning::PLAY_SERVICE_ACCOUNT_JSON is not set; the bundle was not uploaded to Google Play." + exit 0 + fi + + PACKAGE="com.runicgateway.app" + TAG="${{ steps.plan.outputs.tag }}" + VERSION_CODE="${{ steps.plan.outputs.versionCode }}" + AAB="dist/runic-gateway-${{ steps.plan.outputs.version }}.aab" + API="https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${PACKAGE}" + UPLOAD_API="https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/${PACKAGE}" + + # The service-account key only ever touches the runner's temp dir. + umask 077 + SA_KEY="${RUNNER_TEMP}/play-sa.pem" + trap 'rm -f "$SA_KEY"' EXIT + printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" | jq -r '.private_key' > "$SA_KEY" + CLIENT_EMAIL="$(printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" | jq -r '.client_email')" + + # play : prints the response body; on a non-2xx it prints + # Google's error to stderr and fails, so `set -e` stops the step. + play() { + local out code + out="$(mktemp)" + code="$(curl -sS -o "$out" -w '%{http_code}' "$@")" + if [ "${code:0:1}" != "2" ]; then + echo "::error::Google Play API returned HTTP ${code}" >&2 + cat "$out" >&2; echo >&2 + rm -f "$out" + return 1 + fi + cat "$out" + rm -f "$out" + } + + # Service-account JWT → OAuth access token (RFC 7523). + b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; } + NOW="$(date +%s)" + JWT_HEAD="$(printf '%s' '{"alg":"RS256","typ":"JWT"}' | b64url)" + JWT_CLAIMS="$(jq -cn --arg iss "$CLIENT_EMAIL" --argjson iat "$NOW" \ + '{iss:$iss, scope:"https://www.googleapis.com/auth/androidpublisher", + aud:"https://oauth2.googleapis.com/token", iat:$iat, exp:($iat + 3600)}' | b64url)" + JWT_SIG="$(printf '%s' "${JWT_HEAD}.${JWT_CLAIMS}" | openssl dgst -sha256 -sign "$SA_KEY" -binary | b64url)" + ACCESS_TOKEN="$(play -X POST https://oauth2.googleapis.com/token \ + --data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer' \ + --data-urlencode "assertion=${JWT_HEAD}.${JWT_CLAIMS}.${JWT_SIG}" \ + | jq -r '.access_token')" + echo "::add-mask::${ACCESS_TOKEN}" + AUTH="Authorization: Bearer ${ACCESS_TOKEN}" + + EDIT_ID="$(play -X POST "${API}/edits" -H "$AUTH" \ + -H 'Content-Type: application/json' -d '{}' | jq -r '.id')" + echo "Opened edit ${EDIT_ID}" + + UPLOADED_CODE="$(play -X POST "${UPLOAD_API}/edits/${EDIT_ID}/bundles?uploadType=media" \ + -H "$AUTH" -H 'Content-Type: application/octet-stream' \ + --data-binary "@${AAB}" | jq -r '.versionCode')" + if [ "$UPLOADED_CODE" != "$VERSION_CODE" ]; then + echo "::error::Play read versionCode ${UPLOADED_CODE} from the bundle, expected ${VERSION_CODE}" + exit 1 + fi + echo "Uploaded bundle (versionCode ${UPLOADED_CODE})" + + play -X PUT "${API}/edits/${EDIT_ID}/tracks/internal" -H "$AUTH" \ + -H 'Content-Type: application/json' \ + -d "$(jq -n --arg name "$TAG" --arg code "$VERSION_CODE" \ + '{track:"internal", releases:[{name:$name, versionCodes:[$code], status:"completed"}]}')" \ + >/dev/null + play -X POST "${API}/edits/${EDIT_ID}:commit" -H "$AUTH" -d '' >/dev/null + echo "Released ${TAG} (versionCode ${VERSION_CODE}) to the internal track" diff --git a/.gitea/workflows/sonarqube.yml b/.gitea/workflows/sonarqube.yml index c7f9b6b..6bd1a87 100644 --- a/.gitea/workflows/sonarqube.yml +++ b/.gitea/workflows/sonarqube.yml @@ -64,7 +64,7 @@ jobs: - name: Install Android SDK packages run: | set +o pipefail - yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0" + yes | sdkmanager "platform-tools" "platforms;android-36" "build-tools;35.0.0" - name: Cache Gradle uses: actions/cache@v4 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4594d20..9cc1f0e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -38,13 +38,13 @@ val hasReleaseSigning = ksStoreFilePath != null && ksStorePassword != null && android { namespace = "com.runicgateway.app" - compileSdk = 35 + compileSdk = 36 defaultConfig { // Target application id per docs/android/PLAN.md §13 (pending runicgateway.app domain). applicationId = "com.runicgateway.app" minSdk = 29 - targetSdk = 35 + targetSdk = 36 // These committed defaults are the version source of truth (PLAN.md §10). // release.yml's conventional-commit engine bumps versionName here and commits // it on release; versionCode is derived from it (major*10000+minor*100+patch) diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index 9aac908..9de30f4 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -3,7 +3,7 @@