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
297 lines
14 KiB
YAML
297 lines
14 KiB
YAML
# Automated release for the Runic Gateway Android app.
|
|
#
|
|
# Trigger: pushing a version tag `v*` (e.g. `v0.1.0`). Tag-driven on purpose — the
|
|
# build never has to push to protected `main`; the tag *is* the release input.
|
|
#
|
|
# To cut a release:
|
|
# git tag v0.1.0 && git push origin v0.1.0
|
|
# (or create the tag from the Gitea UI). Re-build/re-release an existing tag via
|
|
# the workflow_dispatch input below.
|
|
#
|
|
# versionName = the tag without its leading `v`; versionCode = major*10000 +
|
|
# minor*100 + patch (deterministic + monotonic, PLAN.md §10). Both are injected
|
|
# into app/build.gradle.kts for the build only — nothing is committed back to main.
|
|
#
|
|
# Prerequisites (Settings -> Actions -> Secrets on RunicGateway/Android-app):
|
|
# REGISTRY_TOKEN — Gitea access token with `write:repository` (create the release)
|
|
# ANDROID_KEYSTORE_BASE64 — base64 of the release .jks (single line)
|
|
# 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
|
|
# tools + JDK 17 (not actions/setup-java), install the exact SDK packages, and
|
|
# `chmod +x ./gradlew` in-step (checkout drops the exec bit).
|
|
|
|
name: Release APK
|
|
|
|
on:
|
|
push:
|
|
tags:
|
|
- 'v*'
|
|
workflow_dispatch:
|
|
inputs:
|
|
tag:
|
|
description: 'Existing v* tag to (re)build and release'
|
|
required: true
|
|
|
|
concurrency:
|
|
group: release-apk-${{ github.event.inputs.tag || github.ref_name }}
|
|
cancel-in-progress: false
|
|
|
|
env:
|
|
GITEA_HOST: gitea.whitlocktech.com
|
|
REPO: RunicGateway/Android-app
|
|
GRADLE_MODULE: app
|
|
|
|
jobs:
|
|
release:
|
|
runs-on: ubuntu-latest
|
|
# Fail fast on a genuinely wedged run (e.g. a stalled SDK/network download on
|
|
# the self-hosted runner) instead of hanging forever and — because concurrency
|
|
# is `cancel-in-progress: false` — blocking every later release behind it.
|
|
timeout-minutes: 30
|
|
steps:
|
|
- name: Install base tools + JDK 17
|
|
run: |
|
|
apt-get update
|
|
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)
|
|
uses: actions/checkout@v4
|
|
with:
|
|
ref: ${{ github.event.inputs.tag || github.ref_name }}
|
|
fetch-depth: 0
|
|
|
|
# ── Derive version + changelog straight from the tag ─────────────────
|
|
- name: Plan the release (version + changelog from the tag)
|
|
id: plan
|
|
run: |
|
|
set -euo pipefail
|
|
mkdir -p dist
|
|
git fetch --tags --force >/dev/null 2>&1 || true
|
|
|
|
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
|
case "$TAG" in
|
|
v[0-9]*) : ;;
|
|
*) echo "::error::expected a v* version tag, got '$TAG'"; exit 1 ;;
|
|
esac
|
|
VERSION="${TAG#v}"
|
|
|
|
# versionCode: deterministic + monotonic from the semver (PLAN.md §10).
|
|
IFS=. read -r MA MI PA <<< "$VERSION"
|
|
: "${MA:=0}"; : "${MI:=0}"; : "${PA:=0}"
|
|
VERSION_CODE=$(( MA*10000 + MI*100 + PA ))
|
|
|
|
# Changelog: conventional-commit subjects since the previous v* tag.
|
|
PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "${TAG}^" 2>/dev/null || true)"
|
|
if [ -n "$PREV_TAG" ]; then RANGE="${PREV_TAG}..${TAG}"; else RANGE="${TAG}"; fi
|
|
SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)"
|
|
|
|
{
|
|
echo "## Runic Gateway Android ${TAG}"
|
|
echo
|
|
FEATS="$(echo "$SUBJECTS" | grep -E '^feat' || true)"
|
|
FIXES="$(echo "$SUBJECTS" | grep -E '^(fix|perf)' || true)"
|
|
[ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; }
|
|
[ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; }
|
|
echo "### All changes"
|
|
if [ -n "$PREV_TAG" ]; then echo "Since ${PREV_TAG}:"; fi
|
|
echo "$SUBJECTS" | sed 's/^/- /'
|
|
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"
|
|
echo "versionCode=${VERSION_CODE}" >> "$GITHUB_OUTPUT"
|
|
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
|
echo "==> tag=${TAG} version=${VERSION} code=${VERSION_CODE} prev_tag=${PREV_TAG:-<none>}"
|
|
|
|
# ── SDK + signing keystore ───────────────────────────────────────────
|
|
- name: Set up Android SDK
|
|
uses: android-actions/setup-android@v3
|
|
with:
|
|
# Only put cmdline-tools on PATH. The action's default package set drags in
|
|
# the whole emulator + the legacy `tools` package (hundreds of MB, network-
|
|
# bound on this runner) that a headless APK build never uses. The next step
|
|
# installs exactly the packages we need.
|
|
packages: ''
|
|
|
|
- name: Install Android SDK packages
|
|
run: |
|
|
set +o pipefail
|
|
yes | sdkmanager "platform-tools" "platforms;android-36" "build-tools;35.0.0"
|
|
|
|
- name: Decode signing keystore
|
|
env:
|
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [ -z "${ANDROID_KEYSTORE_BASE64:-}" ]; then
|
|
echo "::error::ANDROID_KEYSTORE_BASE64 secret is not set — cannot build a signed release."
|
|
exit 1
|
|
fi
|
|
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${RUNNER_TEMP}/release.jks"
|
|
echo "ANDROID_KEYSTORE_FILE=${RUNNER_TEMP}/release.jks" >> "$GITHUB_ENV"
|
|
|
|
# ── Set the version, build the signed APK ────────────────────────────
|
|
- name: Set the app version to match the tag
|
|
run: |
|
|
set -euo pipefail
|
|
VERSION="${{ steps.plan.outputs.version }}"
|
|
VERSION_CODE="${{ steps.plan.outputs.versionCode }}"
|
|
# Replace only the version defaults (the `?: "x.y.z"` / `?: N` fallbacks).
|
|
sed -i -E "s/(\?: )\"[0-9]+\.[0-9]+\.[0-9]+\"/\1\"${VERSION}\"/" "${GRADLE_MODULE}/build.gradle.kts"
|
|
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 + app bundle
|
|
env:
|
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
|
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
|
run: |
|
|
set -euo pipefail
|
|
chmod +x ./gradlew
|
|
./gradlew --no-daemon :${GRADLE_MODULE}:testDebugUnitTest \
|
|
:${GRADLE_MODULE}:assembleRelease :${GRADLE_MODULE}:bundleRelease
|
|
|
|
- name: Package APK + AAB + SHA256SUMS
|
|
run: |
|
|
set -euo pipefail
|
|
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) ───────
|
|
- name: Create Gitea release and upload assets
|
|
env:
|
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
TAG="${{ steps.plan.outputs.tag }}"
|
|
API="https://${GITEA_HOST}/api/v1/repos/${REPO}"
|
|
BODY="$(cat dist/CHANGELOG.md)"
|
|
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
|
|
|
REL_ID="$(curl -sSf -X POST "${API}/releases" \
|
|
-H "Authorization: token ${CI_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$(jq -n --arg tag "$TAG" --arg body "$BODY" \
|
|
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
|
|
| jq -r '.id')"
|
|
echo "Created release ${TAG} (id=${REL_ID})"
|
|
|
|
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"
|