feat(release): target API 36 and upload each tag's bundle to Google Play
All checks were successful
PR Checks / android-build (pull_request) Successful in 21m22s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-23 07:03:54 -05:00
parent 478206e0bd
commit 7a8231ba9c
7 changed files with 119 additions and 17 deletions

View File

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

View File

@@ -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 <curl args...>: 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"

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
<!--
The app is purely an HTTPS API client of a shard's website backend, so the base
posture forbids all cleartext (HTTP) traffic. This makes explicit what minSdk 29 /
targetSdk 35 already default to, satisfies the "usesCleartextTraffic implicitly
targetSdk 36 already default to, satisfies the "usesCleartextTraffic implicitly
enabled" scanner finding, and stops any merged library manifest from re-enabling
cleartext. It also mirrors ServerUrl's release-build rule (HTTPS required) at the
platform socket layer — defense in depth.

View File

@@ -5,7 +5,7 @@
[versions]
# Build toolchain
agp = "8.6.1"
agp = "8.13.2"
kotlin = "2.0.20"
ksp = "2.0.20-1.0.25"

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME