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.
This commit is contained in:
2026-08-30 19:48:59 +02:00
parent 27a995f877
commit 03c0b5cd17
2 changed files with 197 additions and 0 deletions
+179
View File
@@ -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
+18
View File
@@ -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: