Files
jellytau/.gitea/workflows/build-test-apk.yml
T
dtourolle c44070e720
🏗️ 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
fix(ci): don't use bash-only syntax in the APK workflow
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.
2026-09-05 16:25:29 +02:00

349 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
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