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 @@