Android was the last job still downloading its world every run: ~3 GB of SDK/NDK/CMake from Google, a from-source cargo-ndk build, and one monolithic cache key where a gradle edit invalidated the cargo registry and the Rust target/ with it. punktfunk-android-ci (content-keyed, LAN registry) bakes JDK 21 + SDK + NDK + cargo-ndk + sccache; the workflow shrinks to checkout → two caches → gradle. The cargo-home cache joins the fleet-wide namespace (same lockfile, same layout — it was the same bytes under a private key), gradle gets its own key shared with android-screenshots (which stored the identical content under a second name), and target/ leaves the cache — sccache covers the three ABI builds now. Apple: sccache (self-healing ~/.local/bin install + already provisioned on the mini) covers every cargo invocation build-xcframework.sh makes across the swift job, the screenshots job and release.yml — three jobs that each recompiled the same core. screenshots.sh learns PF_SHOT_DERIVED_DATA so CI pins one stable DerivedData root instead of cold-building into two throwaway mktemp trees per run (release.yml's disease, same cure), and the Simulators get shut down after capture (the 846-leaked-sims lesson). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
208 lines
11 KiB
YAML
208 lines
11 KiB
YAML
# 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/**'
|
|
- '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/**'
|
|
- '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
|