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
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>
280 lines
15 KiB
YAML
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 "$@"
|