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