🏗️ 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
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.
335 lines
14 KiB
YAML
335 lines
14 KiB
YAML
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
|