Files
jellytau/.gitea/workflows/build-test-apk.yml
T
dtourolle 5fb9c1ff3b
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 25m52s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 46s
📱 Test APK / Build test APK (push) Failing after 1m14s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 7m54s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 6m26s
ci: publish a rolling latest APK on every push to master
The test-APK workflow was dispatch-only, so merging to master produced no
APK at all -- there was nothing to hand a tester without pressing a button
first, which is not what a "latest build" means.

Pushes to master now refresh a `latest` pre-release in place. Both the tag
and the asset name are stable, so the download URL never changes and a
link given to a tester once keeps serving the current build. Release
assets are public; Actions artifacts need an account, which is what made
them useless for this.

It stays the side-by-side variant: R8-minified like a real release, so it
still exercises the minification that has broken Android builds here
before, but signed with the debug keystore under the `.debug`
applicationId. A bad master commit therefore cannot replace anyone's
working install, and the production signing key stays in the tag-driven
release workflow.

Event handling is resolved in one step rather than read raw at each use.
A push carries no dispatch inputs -- every `github.event.inputs.*` is
empty on that event -- so the variant and ABI need real defaults, and the
publish decision differs by event. Doing it once means the build, collect
and publish steps cannot disagree about what the run is.

Known gap, documented rather than hidden: this builds in parallel with
build-and-test.yml, so `latest` can carry a commit whose tests later fail.
Cross-workflow dependencies are not reliably available here and
duplicating the test job would double an already hour-long queue on a
single-slot runner.
2026-09-05 13:42:33 +02:00

335 lines
14 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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-<branch>`.
#
# 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