🏗️ Build and Test JellyTau / Run Tests (push) Successful in 24m28s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 55s
📱 Test APK / Build test APK (push) Successful in 51m15s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 9m8s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 7m9s
The runner hands `run:` blocks to sh (dash), where `${GITHUB_SHA::8}` is
not substring expansion but a syntax error. It failed as
/var/run/act/workflow/9.sh: 29: Bad substitution
which names a temp file and no line of the workflow, after a 51-minute
build that had already produced a correctly signed APK and only needed to
write a summary table.
Two changes, either of which would have been enough, because this is a
silly way to lose an hour:
- The job declares `shell: bash`, so the rest of the file's assumptions
hold and a future bash-ism does not resurface this.
- The short SHA is computed once with `cut` in the resolve step and read
as a step output, so the two places that wanted it no longer depend on
which shell runs them at all.
Everything before this point is confirmed working from the same run: the
SDK is found, the Rust cross-compile completes, gradle mints a debug
keystore, R8 runs, and apksigner reports "CN=Android Debug" -- the
side-by-side signing this workflow is supposed to produce.
349 lines
14 KiB
YAML
349 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
|
||
defaults:
|
||
run:
|
||
# This runner executes `run:` blocks with `sh` (dash) unless told
|
||
# otherwise, so bash-only syntax fails with a bare "Bad substitution"
|
||
# naming a temp file and no line of your workflow. Say bash explicitly.
|
||
# The short-SHA output below avoids depending on it regardless.
|
||
shell: bash
|
||
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"
|
||
|
||
# Computed once, with `cut` rather than `${GITHUB_SHA::8}`. The
|
||
# substring form is bash-only and this runner may hand a step to
|
||
# `sh`; that cost a 51-minute build which produced a perfectly good
|
||
# APK and then died formatting the summary table.
|
||
SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-8)
|
||
|
||
{
|
||
echo "variant=$VARIANT"
|
||
echo "abi=$ABI"
|
||
echo "publish=$PUBLISH"
|
||
echo "tag=$TAG"
|
||
echo "release_name=$RELEASE_NAME"
|
||
echo "asset=$ASSET"
|
||
echo "branch=$BRANCH"
|
||
echo "short_sha=$SHORT_SHA"
|
||
} >> "$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 | \`${{ steps.cfg.outputs.short_sha }}\` |"
|
||
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 \`${{ steps.cfg.outputs.short_sha }}\` — **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
|