# 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:-}" # ── 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 : 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"