name: '๐Ÿ“ฑ Test APK' # Installable Android builds that are not releases. # # Two ways in: # # push to master -> refreshes the rolling `latest` pre-release, so there is # always a current APK behind one stable URL that can be # handed to a tester once and never re-sent. # workflow_dispatch -> builds any branch on demand, optionally publishing it # as `test-`. # # Why this is separate from build-release.yml: that workflow is tag-driven, # builds Linux + Windows + Android and creates a real release. This produces one # APK and never touches the release channel. # # What comes out installs as com.dtourolle.jellytau.debug ("JellyTau Debug"), # side by side with a real install and with its own data directory. It is a # fully R8-minified release build -- minification is where Android builds have # actually broken here (R8 stripping JNI-loaded player and security classes), # and a plain debug build cannot catch that -- but it is signed with the debug # keystore rather than the store key. So a bad master commit can never replace # somebody's working install, and the production signing key stays in the # tag-driven workflow where it belongs. # # Getting the APK to somebody else: Gitea artifacts need an account with read # access to download, so published builds are attached to a pre-release, whose # assets are a plain public URL. That is the only way an outside tester gets the # file without being given an account. on: push: branches: - master paths-ignore: - '**/*.md' 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. - 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 publish: description: 'Also publish as a pre-release (automatic on master)' required: false default: false type: boolean concurrency: # One APK build at a time, and a newer push supersedes an in-flight one โ€” so a # burst of commits to master costs one build, not one per commit. This matters: # the runner has a single slot shared with two other projects. 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 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 # One place decides what this run is, so the build, the collect step and # the publish step cannot disagree about it. A push carries no dispatch # inputs at all -- every `github.event.inputs.*` is empty on that event -- # so each value needs an explicit default rather than being read raw. - name: Resolve build parameters id: cfg run: | set -e VARIANT="${{ github.event.inputs.variant }}" ABI="${{ github.event.inputs.abi }}" PUBLISH="${{ github.event.inputs.publish }}" BRANCH="${GITHUB_REF#refs/heads/}" VARIANT="${VARIANT:-side-by-side-release}" ABI="${ABI:-aarch64}" # A push to master always publishes -- that is the whole point of a # rolling `latest`. A dispatch publishes only if asked. Compared # against the string 'true' rather than used as a bare truthiness # test: dispatch inputs arrive as strings, and every non-empty string # is truthy, so `if: inputs.publish` would publish even when the box # was deliberately left unticked. if [ "$GITHUB_EVENT_NAME" = "push" ]; then PUBLISH=true elif [ "$PUBLISH" = "true" ]; then PUBLISH=true else PUBLISH=false fi # Master is the rolling channel and keeps one stable tag, so the # download URL a tester was given keeps working. Anything else gets # its own branch-scoped tag. if [ "$BRANCH" = "master" ]; then TAG="latest" RELEASE_NAME="Latest build (master)" else TAG="test-$(echo "$BRANCH" | tr '/' '-')" RELEASE_NAME="Test build: $BRANCH" fi # Stable asset name for the same reason the tag is stable. ASSET="jellytau-${TAG}.apk" { echo "variant=$VARIANT" echo "abi=$ABI" echo "publish=$PUBLISH" echo "tag=$TAG" echo "release_name=$RELEASE_NAME" echo "asset=$ASSET" echo "branch=$BRANCH" } >> "$GITHUB_OUTPUT" echo "variant=$VARIANT abi=$ABI publish=$PUBLISH tag=$TAG asset=$ASSET" - 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 [ "${{ steps.cfg.outputs.variant }}" = "side-by-side-release" ]; then ./scripts/build-android.sh release --debug --abi "${{ steps.cfg.outputs.abi }}" else ./scripts/build-android.sh debug --abi "${{ steps.cfg.outputs.abi }}" fi - name: Collect APK run: | set -e mkdir -p dist/test-apk if [ "${{ steps.cfg.outputs.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 ${{ steps.cfg.outputs.variant }}" find src-tauri/gen/android/app/build/outputs/apk -name '*.apk' || true exit 1 fi OUT="dist/test-apk/${{ steps.cfg.outputs.asset }}" 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 "### ๐Ÿ“ฑ ${{ steps.cfg.outputs.release_name }}" echo "" echo "| | |" echo "|---|---|" echo "| Branch | \`${{ steps.cfg.outputs.branch }}\` |" echo "| Commit | \`${GITHUB_SHA::8}\` |" echo "| Variant | \`${{ steps.cfg.outputs.variant }}\` |" echo "| ABI | \`${{ steps.cfg.outputs.abi }}\` |" echo "| Size | $(du -h "$OUT" | cut -f1) |" echo "| SHA256 | \`$(sha256sum "$OUT" | cut -d' ' -f1)\` |" } >> "$GITHUB_STEP_SUMMARY" ls -lah dist/test-apk/ # Deliberately NOT tagged `v*`: that pattern triggers build-release.yml, # which would run the whole three-platform release matrix and publish a # real release. `latest` and `test-*` carry no version, so nothing else # reacts to them. # # This also cannot reach existing users by itself. The desktop updater # reads a static latest.json from the `updater` branch, not the release # list, so a pre-release published here is invisible to anyone who does # not have the link -- and the APK installs under a different # applicationId anyway. - name: Publish pre-release if: ${{ steps.cfg.outputs.publish == 'true' }} env: GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -e command -v jq >/dev/null || { echo "โŒ jq is required on the runner"; exit 1; } API="${GITHUB_SERVER_URL}/api/v1" REPO="${GITHUB_REPOSITORY}" TOKEN="${GITEA_TOKEN:-$AUTO_TOKEN}" TAG="${{ steps.cfg.outputs.tag }}" ASSET="${{ steps.cfg.outputs.asset }}" BODY=$(printf '%s\n' \ "Automatic build of \`${{ steps.cfg.outputs.branch }}\` at \`${GITHUB_SHA::8}\` โ€” **not a release**." \ "" \ "Installs as **JellyTau Debug** (\`com.dtourolle.jellytau.debug\`), alongside a" \ "normal install and with its own separate data. It cannot replace or upgrade a" \ "real install, and uninstalling it does not touch one." \ "" \ "R8-minified like a real release, but signed with a debug key โ€” so Android will" \ "warn about an unknown source. That is expected." \ "" \ "Variant: \`${{ steps.cfg.outputs.variant }}\` ยท ABI: \`${{ steps.cfg.outputs.abi }}\`" \ "" \ "This release is refreshed on every push; the download link stays the same.") PAYLOAD=$(jq -n \ --arg tag "$TAG" \ --arg name "${{ steps.cfg.outputs.release_name }}" \ --arg body "$BODY" \ --arg target "$GITHUB_SHA" \ '{tag_name:$tag, target_commitish:$target, name:$name, body:$body, draft:false, prerelease:true}') HTTP=$(curl -sS -o resp.json -w '%{http_code}' -X POST "$API/repos/$REPO/releases" \ -H "Authorization: token $TOKEN" -H "Content-Type: application/json" -d "$PAYLOAD") if [ "$HTTP" = "201" ]; then RELEASE_ID=$(jq -r '.id' resp.json) elif [ "$HTTP" = "409" ]; then # The rolling case: reuse the release, refresh its body to name the # new commit, and clear the old asset so `latest` means latest. RELEASE_ID=$(curl -fsS "$API/repos/$REPO/releases/tags/$TAG" \ -H "Authorization: token $TOKEN" | jq -r '.id') echo "โ„น๏ธ Refreshing existing pre-release $TAG (id=$RELEASE_ID)" curl -fsS -X PATCH "$API/repos/$REPO/releases/$RELEASE_ID" \ -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ -d "$PAYLOAD" >/dev/null for id in $(curl -fsS "$API/repos/$REPO/releases/$RELEASE_ID/assets" \ -H "Authorization: token $TOKEN" | jq -r '.[].id'); do curl -fsS -X DELETE "$API/repos/$REPO/releases/$RELEASE_ID/assets/$id" \ -H "Authorization: token $TOKEN" >/dev/null done else echo "โŒ Failed to create pre-release (HTTP $HTTP):"; cat resp.json; exit 1 fi # The tag moves with the branch, so an old tag object would otherwise # keep `latest` pointing at a stale commit. curl -fsS -X POST \ "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$ASSET" \ -H "Authorization: token $TOKEN" -F "attachment=@dist/test-apk/$ASSET" >/dev/null URL="${GITHUB_SERVER_URL}/${REPO}/releases/download/${TAG}/${ASSET}" { echo "" echo "**Published:** ${GITHUB_SERVER_URL}/${REPO}/releases/tag/${TAG}" echo "" echo "Direct download (stable link, no account needed):" echo "" echo " $URL" } >> "$GITHUB_STEP_SUMMARY" echo "โœ… Published $TAG -> $URL" - name: Upload APK artifact uses: actions/upload-artifact@v3 with: name: jellytau-test-apk path: dist/test-apk/ retention-days: 7