Files
punktfunk/.gitea/workflows/android.yml
T
enricobuehlerandClaude Opus 5 e0427a3bb6
ci / rust (push) Canceled after 5m55s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
android / android (push) Successful in 6m28s
feat(ci/android): a release tag without Play notes fails before it builds
Play does not show an empty "What's new" when the file is missing — it carries
the PREVIOUS release's text onto the new version. So the store listing ends up
describing a build nobody is getting, and nothing surfaces it except reading
the listing. That is the shape of the v0.22.3 notes announcing a feature the
tag never contained, and a soft warning in a log nobody reads does not prevent
it.

The gate runs FIRST in the job, before the ten-minute build: a miss costs a
second and leaves nothing half-published — no build, no assets on the Gitea
release, nothing on Play. It rejects three things: a missing file, a file
byte-identical to another release's (the same bug reached by copy-paste rather
than omission), and an empty or over-500-char one.

Length is checked here as well as in play-upload.py on purpose. The uploader
stays the last line of defence and is the only check android-promote.yml gets,
but it runs at step 9; this catches an unedited TEMPLATE copy at step 1. It
counts CHARACTERS, not bytes — Play's cap is 500 chars and `•` is three bytes
in UTF-8, so a `wc -c` check would have called the 356-char v0.23.0 notes 365
and can reject a legal file.

whatsnew/TEMPLATE.txt gives the file a starting point and says what the gate
does and does not enforce: it cannot tell whether the prose was ever edited, so
a copy that still reads "<The headline change>" ships exactly as written.

Canary stays exempt — no curated notes, and Play reusing text for internal
testers costs nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 10:34:32 +02:00

280 lines
15 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/**'
# 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-
- 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 "$@"