# Android client CI (Gitea Actions). Builds the Rust JNI core (clients/android/native) via # cargo-ndk for all three shipping ABIs and assembles the debug APK (clients/android). Mirrors apple.yml # but on a Linux runner — the NDK is cross-platform, so no self-hosted host is needed. # # Runs in the punktfunk-android-ci builder image (ci/android-ci.Dockerfile, content-keyed on # the LAN registry): JDK 21, the Android SDK/NDK/CMake pins, cargo-ndk and sccache are all # baked, so the multi-GB per-run Google downloads this job used to make are gone. Emulator # instrumentation tests are deferred until a KVM-capable runner exists (they self-skip # otherwise, like apple.yml's RemoteFirstLightTests). name: android # One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels # it (a canary only needs the latest commit; each release tag is its own ref so tag runs never # cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the # runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933). concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true on: push: branches: [main] # Scope canary builds to what this artifact is built FROM — a docs-only or # web-only push should not light up the whole fleet. Applies to branch pushes; # tag runs are matched by `tags:` (proven by flatpak/windows-msix releases). paths: - 'crates/**' - 'clients/android/**' # The builder image is part of what this artifact is built from — an image # change must exercise its consumer. - 'ci/android-ci.Dockerfile' - 'Cargo.toml' - 'Cargo.lock' - 'rust-toolchain.toml' - 'scripts/ci/**' - '.gitea/workflows/android.yml' # Single project version: a `vX.Y.Z` tag is THE release (uploads to Play's `alpha` closed # track for manual promotion + attaches the .aab/.apk to the unified Gitea Release). A main # push is canary (Play `internal`). tags: ['v*'] pull_request: paths: - 'crates/**' - 'clients/android/**' # The builder image is part of what this artifact is built from — an image # change must exercise its consumer. - 'ci/android-ci.Dockerfile' - 'Cargo.toml' - 'Cargo.lock' - 'rust-toolchain.toml' - 'scripts/ci/**' - '.gitea/workflows/android.yml' workflow_dispatch: # Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's # unbound). The NDK clang targets get their own key universes automatically (keys embed # compiler hash + target), so the three ABI builds share the bucket with everything else. env: RUSTC_WRAPPER: sccache SCCACHE_BUCKET: unom-ci-sccache SCCACHE_ENDPOINT: https://storage.unom.io SCCACHE_REGION: home-central AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }} # sccache and incremental compilation are mutually exclusive; CI wants the shared # cache, dev boxes keep incremental. CARGO_INCREMENTAL: "0" jobs: android: runs-on: ubuntu-24.04 container: image: 192.168.1.58:5010/punktfunk-android-ci:latest timeout-minutes: 60 steps: - uses: actions/checkout@v4 # Everything below the checkout used to be four download steps (JDK, SDK, # NDK+CMake, cargo-ndk — the flakiest, heaviest part of the job); it is all baked # into the image now. This guard only re-asserts the Android targets so a # rust-toolchain.toml pin bump keeps working against an older image (:latest lags # one image rebuild, same bootstrap note as ci.yml's dep steps). - name: Rust Android targets (no-op unless the toolchain pin outran the image) run: rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android # Same key namespace as ci.yml/deb.yml ON PURPOSE: identical Cargo.lock, identical # CARGO_HOME layout (/usr/local/cargo), so the registry/git downloads dedupe with # the rest of the fleet in the central cache. target/ is deliberately NOT cached # anymore — sccache covers recompilation without shipping multi-GB tars per run. - name: Cache (cargo registry) uses: actions/cache@v4 with: path: | /usr/local/cargo/registry /usr/local/cargo/git key: cargo-home-${{ hashFiles('Cargo.lock') }} restore-keys: cargo-home- - name: Cache (gradle) uses: actions/cache@v4 with: path: | ~/.gradle/caches ~/.gradle/wrapper # gradle-wrapper.properties is in the key on purpose: `~/.gradle/wrapper` caches the # Gradle DISTRIBUTION, so a wrapper bump with no .gradle.kts change would otherwise # restore a key that can never hold the new one. Namespace shared with # android-screenshots.yml — same content, one copy in the central store. key: gradle-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }} restore-keys: gradle- - name: assembleDebug (cargo-ndk → jniLibs → APK) working-directory: clients/android env: VERSION_CODE: ${{ github.run_number }} run: ./gradlew :app:assembleDebug --stacktrace # Single source of the version name + the Play track for the release steps below. versionCode # stays github.run_number (monotonic across both tracks; Play rejects a regressed code). - name: Version + channel if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) run: | eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag) case "$GITHUB_REF" in refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="alpha" ;; # alpha = built-in closed testing *) VN="${PF_BASE}-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;; esac echo "VERSION_NAME=$VN" >> "$GITHUB_ENV" echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV" echo "android version $VN -> Play track '$TRACK'" - name: Build Release (signed AAB + universal APK) if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) working-directory: clients/android env: VERSION_CODE: ${{ github.run_number }} # VERSION_NAME comes from the Version+channel step (GITHUB_ENV) RELEASE_KEYSTORE_FILE: "../release.jks" RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} run: | echo "${{ secrets.RELEASE_KEYSTORE_BASE64 }}" | base64 -d > release.jks # AAB for Play; a universal APK (all ABIs) for direct sideload/testing — same upload key. ./gradlew :app:bundleRelease :app:assembleRelease --stacktrace # Attach the built APK to the workflow run so it's a one-click sideload download straight from the # run page (no need to know the generic-registry URL) — on EVERY trigger, incl. a PR or a # workflow_dispatch. The debug APK is built on every run; the signed universal release APK exists # only on a main/tag push (its build step above is push-gated), so grab whichever were produced. - name: Attach APK(s) to the workflow run if: always() # v3, not v4: Gitea's artifact backend identifies as GHES, which upload-artifact@v4 refuses # (same reason as apple.yml / *-screenshots.yml). Download is a zip of the matched APK(s). uses: actions/upload-artifact@v3 with: name: punktfunk-android-apk path: | clients/android/app/build/outputs/apk/release/*.apk clients/android/app/build/outputs/apk/debug/*.apk if-no-files-found: warn retention-days: 30 # Publish BEFORE the Play upload so artifacts land even while the Play step is still failing. # Generic registry is public for reads — matches windows-msix.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler). # main = canary store + `canary/` sideload alias; a `vX.Y.Z` tag = `latest/` alias + attached # to the unified Gitea Release. - name: Publish to generic registry + attach to Gitea release if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) env: REGISTRY: git.unom.io OWNER: unom PKG: punktfunk-android VERSION: ${{ github.run_number }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | AAB=clients/android/app/build/outputs/bundle/release/app-release.aab APK=clients/android/app/build/outputs/apk/release/app-release.apk base="https://$REGISTRY/api/packages/$OWNER/generic/$PKG" # 1) immutable, run-number-versioned store (sideload + provenance) curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$AAB" "$base/$VERSION/punktfunk-android-r$VERSION.aab" curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$APK" "$base/$VERSION/punktfunk-android-r$VERSION.apk" echo "published store version $VERSION (versionCode)" # 2) channel alias for a predictable sideload URL: stable -> latest/, canary -> canary/ case "$GITHUB_REF" in refs/tags/v*) ALIAS=latest ;; *) ALIAS=canary ;; esac curl -fsS -o /dev/null --user "enricobuehler:$REGISTRY_TOKEN" -X DELETE "$base/$ALIAS/punktfunk-android.apk" || true curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$APK" "$base/$ALIAS/punktfunk-android.apk" echo "sideload alias: $base/$ALIAS/punktfunk-android.apk" # 3) on a real release, attach the .aab + .apk to the unified Gitea Release (X.Y.Z names) case "$GITHUB_REF" in refs/tags/v*) . scripts/ci/gitea-release.sh RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto) upsert_asset "$RID" "$AAB" "punktfunk-${VERSION_NAME}.aab" upsert_asset "$RID" "$APK" "punktfunk-${VERSION_NAME}.apk" ;; esac # Direct Publishing-API upload instead of r0adkll/upload-google-play — that action hides the # real API error behind "Unknown error occurred."; this prints it. stdlib + openssl only (no # pip), reuses SERVICE_ACCOUNT_JSON (raw JSON or base64), auto-handles changesNotSentForReview. # Track: canary main -> `internal`; a vX.Y.Z release -> `alpha` (closed testing) for manual # promotion to production in the Play console. - name: Upload to Google Play if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) env: SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }} run: | echo "uploading to Play track '$PLAY_TRACK'" python3 clients/android/ci/play-upload.py \ --package io.unom.punktfunk \ --aab clients/android/app/build/outputs/bundle/release/app-release.aab \ --track "$PLAY_TRACK" --status completed