# 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 (publishes to Play `production` at # 100% + attaches the .aab/.apk to the unified Gitea Release). A main push is canary # (Play `internal`). Production access was granted 2026-08-01; before that a tag could only # reach `alpha` and someone had to promote it by hand in the Console. 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 # FIRST, because it costs a second and everything after it costs ten minutes. # # A release tag MUST carry its own Play "What's new". If the file is absent Play does not # show nothing — it carries the PREVIOUS release's text onto this version, so production # users read notes for a build they are not getting. That is the same defect the v0.22.3 # notes shipped (see docs/releases/README.md), and it is invisible until someone reads the # store listing. Failing here also means a missing file cannot leave a half-published # release: nothing is built, nothing is attached to the Gitea release, nothing reaches Play. # # Canary is exempt on purpose: it has no curated notes, and Play reusing text for internal # testers costs nothing. - name: Play release notes gate (tags only) if: startsWith(github.ref, 'refs/tags/v') run: | NOTES="docs/releases/whatsnew/${GITHUB_REF_NAME}.txt" if [ ! -f "$NOTES" ]; then echo "ERROR: $NOTES does not exist." echo "A production release needs its own Play 'What's new' (<=500 chars, written for" echo "phone/TV users). Without it Play reuses the previous release's text." echo "See docs/releases/README.md; copy docs/releases/whatsnew/TEMPLATE.txt." exit 1 fi # A verbatim copy of another release's file is the same bug wearing a hat: the store # listing still describes the wrong build. Cheap to check, and only ever trips on an # actual copy-paste that was never edited. for other in docs/releases/whatsnew/*.txt; do if [ "$other" != "$NOTES" ] && [ "$other" != "docs/releases/whatsnew/TEMPLATE.txt" ]; then if cmp -s "$NOTES" "$other"; then echo "ERROR: $NOTES is byte-identical to $other." echo "Write notes describing THIS release, not the one before it." exit 1 fi fi done # Length is checked here as well as in play-upload.py. Not redundant: the uploader is # the last line of defence (and the only one android-promote.yml gets), but it runs at # step 9 — this catches an unedited TEMPLATE copy at step 1 instead of after the build. # Must count CHARACTERS, not bytes: Play's cap is 500 chars and `•` is 3 bytes in UTF-8, # so `wc -c` would reject a file that is comfortably legal. python3 - "$NOTES" <<'PY' import sys path = sys.argv[1] text = open(path, encoding="utf-8").read().strip() if not text: sys.exit(f"ERROR: {path} is empty.") if len(text) > 500: sys.exit(f"ERROR: {path} is {len(text)} chars; Play allows 500. Trim it.") print(f"Play release notes OK: {path} ({len(text)}/500 chars)") PY # 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- # Clippy for the ANDROID target. Like the kit tests below, this was running NOWHERE: ci.yml # lints `--workspace` on the host, where `clients/android/native` and every # `#[cfg(target_os = "android")]` module elsewhere compile out, and this workflow only ever # built. Discovered in 2026-08 with five lints already resident — code no gate had ever read. # # Placed BEFORE assembleDebug deliberately: a lint failure should cost the ~10 s the lint # takes, not the full three-ABI build first. It shares sccache and the target dir with the # build that follows, so the compile is not paid twice. # # The task lints arm64-v8a AND armeabi-v7a, and reuses the build task's exact cargo-ndk # environment — see the long note on `registerCargoNdkClippy` in kit/build.gradle.kts for why # both pointer widths are load-bearing and why the environment must not be duplicated here. - name: Clippy (Android target, deny warnings) working-directory: clients/android run: ./gradlew :kit:cargoNdkClippy --stacktrace # The kit's JVM unit tests — the pure parsers, migrations and feedback policies. They were # running nowhere: this workflow only assembled, and android-screenshots.yml runs the :app # module's tests, so nothing enforced :kit's. Cheap (a couple of seconds against an already # built module) and it is the only automated cover those behaviours have. - name: kit unit tests working-directory: clients/android run: ./gradlew :kit:testDebugUnitTest --stacktrace - 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="production" ;; *) VN="${PF_BASE}-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;; esac echo "VERSION_NAME=$VN" >> "$GITHUB_ENV" echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV" # Play's own "What's new" (500-char cap, its own file — the vX.Y.Z.md body is ~34 KB). # On a tag the gate step above already proved this exists, so the else branch is only # ever the canary path. See docs/releases/README.md. NOTES="docs/releases/whatsnew/${GITHUB_REF_NAME}.txt" if [ -f "$NOTES" ]; then echo "PLAY_NOTES=$NOTES" >> "$GITHUB_ENV" else echo "no Play release notes at $NOTES (canary — Play keeps the previous text)" fi 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 -> `production` at 100% (`completed`). # # A tag therefore ships to real users with no further click. Two things keep that honest: # the tag is only pushed once every platform is green, and Play reviews each production # release before it reaches anyone. To ramp instead of going straight to 100%, this is # `--status inProgress --user-fraction 0.2`; to undo a bad one, halt or roll back from the # Console (or `android-promote.yml`, which can re-point production at an older versionCode). - 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'" set -- --package io.unom.punktfunk \ --aab clients/android/app/build/outputs/bundle/release/app-release.aab \ --track "$PLAY_TRACK" --status completed if [ -n "${PLAY_NOTES:-}" ]; then set -- "$@" --release-notes-file "$PLAY_NOTES"; fi python3 clients/android/ci/play-upload.py "$@"