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
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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user