From 03c0b5cd17a034fe0569cbed01f92ccf587de1b4 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 30 Aug 2026 19:48:59 +0200 Subject: [PATCH] ci: build a test APK from any branch on demand build-release.yml is tag-driven, builds three platforms and then creates a release -- none of which is what you want from a feature branch, and there was otherwise no way to get an installable build out of CI without cutting one. workflow_dispatch only, deliberately. The runner has a single slot shared with two other projects, so an APK on every feature-branch commit would starve them; dispatch it when you actually want to install something. Defaults to the R8-minified release build in the debug applicationId slot rather than a plain debug APK. Minification is where Android releases have actually broken here (R8 stripping JNI-loaded player and security classes), and a debug build cannot catch it. Neither variant needs the real signing key, and both install side by side with a real install. Builds through scripts/build-android.sh rather than a hand-rolled tauri invocation, so CI and a developer's machine produce the same thing and the script's applicationId assertion still runs. Shares the existing cargo registry cache key -- no fourth copy of the registry on a disk that has filled before. --- .gitea/workflows/build-test-apk.yml | 179 ++++++++++++++++++++++ src-tauri/android/README_ANDROID_BUILD.md | 18 +++ 2 files changed, 197 insertions(+) create mode 100644 .gitea/workflows/build-test-apk.yml diff --git a/.gitea/workflows/build-test-apk.yml b/.gitea/workflows/build-test-apk.yml new file mode 100644 index 00000000..64c4ee6d --- /dev/null +++ b/.gitea/workflows/build-test-apk.yml @@ -0,0 +1,179 @@ +name: '📱 Test APK' + +# An installable APK from any branch, on demand, without cutting a release. +# +# Why this exists separately from build-release.yml: that workflow is tag-driven, +# builds Linux + Windows + Android and then *creates a release*, which is not +# what you want from a feature branch. This builds one Android APK from whatever +# ref you dispatch it on and hands it back as an artifact. +# +# Deliberately `workflow_dispatch` only — no push trigger. The runner has a +# single slot shared with two other projects, so a build on every feature-branch +# commit would starve everything else. Dispatch it when you actually want to +# install something. +# +# Both variants install as com.dtourolle.jellytau.debug ("JellyTau Debug"), +# side by side with a real install and with their own data directory. Neither +# needs the release signing key. + +on: + workflow_dispatch: + inputs: + variant: + description: 'Which build to produce' + required: true + default: 'side-by-side-release' + type: choice + options: + # R8-minified, exactly what ships, in the debug slot. Use this unless + # you need stack traces: R8 stripping JNI-loaded classes has broken + # release APKs here before, and a plain debug build cannot catch it. + - side-by-side-release + # Unminified. Faster, readable stack traces, but does not exercise + # minification at all. + - debug + abi: + description: 'Target ABI' + required: true + default: 'aarch64' + type: choice + options: + - aarch64 + - armv7 + - x86_64 + +concurrency: + # One test build at a time; a newer dispatch supersedes an in-flight one. + group: build-test-apk + cancel-in-progress: true + +env: + # Incremental state is never reused between CI runs -- pure disk cost. + CARGO_INCREMENTAL: 0 + +jobs: + build: + name: Build test APK (${{ inputs.variant }}, ${{ inputs.abi }}) + runs-on: linux/amd64 + container: + image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1 + env: + ANDROID_HOME: /opt/android-sdk + ANDROID_SDK_ROOT: /opt/android-sdk + ANDROID_NDK_HOME: /opt/android-sdk/ndk/27.0.11902837 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + # set-version.sh derives a dev version from `git describe --tags`, so + # the tags have to be here. A shallow checkout yields 0.0.0. + fetch-depth: 0 + + - name: Cache Rust dependencies + uses: actions/cache@v3 + with: + # Registry only -- never src-tauri/target. Same reasoning (and the + # same key) as every other job: that directory is ~16 GB and caching + # it filled the runner's 74 GB disk. Sharing the key means this + # workflow restores what the others saved rather than adding a + # fourth copy of the registry. + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-registry- + + - name: Cache Node dependencies + uses: actions/cache@v3 + with: + path: | + ~/.bun/install/cache + node_modules + key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install dependencies + run: bun install + + # Before `android init`: it derives the generated project (including the + # initial versionCode) from tauri.conf.json. + - name: Stamp a dev version + run: ./scripts/set-version.sh + + - name: Initialize Android project + run: bun run tauri android init + + # Again after init: tauri.properties only exists now, and its + # autogenerated versionCode is neither large enough nor monotonic against + # the 1000 floor already shipped. On a branch this derives from + # `git describe`, so a test APK always sorts above the last release. + - name: Pin a monotonic Android versionCode + run: ./scripts/set-version.sh + + # Built through the same script used locally, rather than a hand-rolled + # gradle/tauri invocation. That is what keeps CI and a developer's machine + # producing the same thing -- and the script asserts the applicationId the + # APK actually carries, which has silently regressed before. + - name: Build APK + run: | + if [ "${{ inputs.variant }}" = "side-by-side-release" ]; then + ./scripts/build-android.sh release --debug --abi "${{ inputs.abi }}" + else + ./scripts/build-android.sh debug --abi "${{ inputs.abi }}" + fi + + - name: Collect APK + id: collect + run: | + mkdir -p dist/test-apk + if [ "${{ inputs.variant }}" = "side-by-side-release" ]; then + PATTERN='*-release.apk' + else + PATTERN='*-debug.apk' + fi + APK=$(find src-tauri/gen/android/app/build/outputs/apk -name "$PATTERN" | head -1) + if [ -z "$APK" ]; then + echo "❌ No APK produced for variant ${{ inputs.variant }}" + find src-tauri/gen/android/app/build/outputs/apk -name '*.apk' || true + exit 1 + fi + + REF_NAME=$(echo "${GITHUB_REF#refs/heads/}" | tr '/' '-') + OUT="dist/test-apk/jellytau-${REF_NAME}-${GITHUB_SHA::8}-${{ inputs.variant }}.apk" + cp "$APK" "$OUT" + + # Report what the thing actually is, not what it was meant to be. + APKSIGNER=$(find "$ANDROID_SDK_ROOT/build-tools" -name apksigner | sort -V | tail -1) + "$APKSIGNER" verify --print-certs "$OUT" || echo "⚠️ Could not verify signature" + + { + echo "### 📱 Test APK" + echo "" + echo "| | |" + echo "|---|---|" + echo "| Branch | \`${GITHUB_REF#refs/heads/}\` |" + echo "| Commit | \`${GITHUB_SHA::8}\` |" + echo "| Variant | \`${{ inputs.variant }}\` |" + echo "| ABI | \`${{ inputs.abi }}\` |" + echo "| Size | $(du -h "$OUT" | cut -f1) |" + echo "| SHA256 | \`$(sha256sum "$OUT" | cut -d' ' -f1)\` |" + echo "" + echo "Installs as \`com.dtourolle.jellytau.debug\` — side by side with a real" + echo "install, with its own data directory. Download the artifact, then:" + echo "" + echo '```' + echo "adb install -r $(basename "$OUT")" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + ls -lah dist/test-apk/ + + - name: Upload APK + uses: actions/upload-artifact@v3 + with: + name: jellytau-test-apk + path: dist/test-apk/ + retention-days: 7 diff --git a/src-tauri/android/README_ANDROID_BUILD.md b/src-tauri/android/README_ANDROID_BUILD.md index 23a30068..2680009a 100644 --- a/src-tauri/android/README_ANDROID_BUILD.md +++ b/src-tauri/android/README_ANDROID_BUILD.md @@ -92,6 +92,24 @@ That means: Follow the right log stream with `./scripts/logcat.sh [debug|release]` (defaults to debug). +### Getting a test APK out of CI + +`.gitea/workflows/build-test-apk.yml` builds one from **any branch, on demand** +— run it from Gitea's Actions tab (`workflow_dispatch`) against the ref you want. +It is not a release: nothing is tagged, published, or signed with the real key. + +Two variants, both installing into the `com.dtourolle.jellytau.debug` slot: + +| Variant | What it is | When | +|---------|-----------|------| +| `side-by-side-release` (default) | R8-minified, exactly what ships, signed with the debug keystore | Almost always — a plain debug build cannot catch R8 stripping JNI-loaded classes, which has broken release APKs here before | +| `debug` | Unminified | When you need readable stack traces | + +There is deliberately **no push trigger**: the runner has one slot shared with +two other projects, so building on every feature-branch commit would starve +them. The APK lands as the `jellytau-test-apk` artifact (7-day retention), named +for the branch and short SHA, with its size and SHA256 in the run summary. + ### Key Files Player-related Kotlin files: