diff --git a/.gitea/workflows/build-test-apk.yml b/.gitea/workflows/build-test-apk.yml index dbe1fe70f..3b09f6d4c 100644 --- a/.gitea/workflows/build-test-apk.yml +++ b/.gitea/workflows/build-test-apk.yml @@ -1,27 +1,39 @@ name: '๐Ÿ“ฑ Test APK' -# An installable APK from any branch, on demand, without cutting a release. +# Installable Android builds that are not releases. # -# Why this exists separately from build-release.yml: that workflow is tag-driven, -# builds Linux + Windows + Android and then *creates a release*, which is not -# what you want from a feature branch. This builds one Android APK from whatever -# ref you dispatch it on and hands it back as an artifact. +# Two ways in: # -# Deliberately `workflow_dispatch` only โ€” no push trigger. The runner has a -# single slot shared with two other projects, so a build on every feature-branch -# commit would starve everything else. Dispatch it when you actually want to -# install something. +# 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-`. # -# Both variants install as com.dtourolle.jellytau.debug ("JellyTau Debug"), -# side by side with a real install and with their own data directory. Neither -# needs the release signing key. +# 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 `publish: true` also attaches the APK 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. +# 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: @@ -30,9 +42,7 @@ on: default: 'side-by-side-release' type: choice options: - # R8-minified, exactly what ships, in the debug slot. Use this unless - # you need stack traces: R8 stripping JNI-loaded classes has broken - # release APKs here before, and a plain debug build cannot catch it. + # 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. @@ -47,13 +57,15 @@ on: - armv7 - x86_64 publish: - description: 'Also publish as a pre-release, for testers with no Gitea account' + description: 'Also publish as a pre-release (automatic on master)' required: false default: false type: boolean concurrency: - # One test build at a time; a newer dispatch supersedes an in-flight one. + # 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 @@ -63,7 +75,7 @@ env: jobs: build: - name: Build test APK (${{ github.event.inputs.variant }}, ${{ github.event.inputs.abi }}) + name: Build test APK runs-on: linux/amd64 container: image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1 @@ -79,6 +91,62 @@ jobs: # 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: @@ -129,30 +197,29 @@ jobs: # APK actually carries, which has silently regressed before. - name: Build APK run: | - if [ "${{ github.event.inputs.variant }}" = "side-by-side-release" ]; then - ./scripts/build-android.sh release --debug --abi "${{ github.event.inputs.abi }}" + 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 "${{ github.event.inputs.abi }}" + ./scripts/build-android.sh debug --abi "${{ steps.cfg.outputs.abi }}" fi - name: Collect APK - id: collect run: | + set -e mkdir -p dist/test-apk - if [ "${{ github.event.inputs.variant }}" = "side-by-side-release" ]; then + 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 ${{ github.event.inputs.variant }}" + 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 - REF_NAME=$(echo "${GITHUB_REF#refs/heads/}" | tr '/' '-') - OUT="dist/test-apk/jellytau-${REF_NAME}-${GITHUB_SHA::8}-${{ github.event.inputs.variant }}.apk" + OUT="dist/test-apk/${{ steps.cfg.outputs.asset }}" cp "$APK" "$OUT" # Report what the thing actually is, not what it was meant to be. @@ -160,42 +227,32 @@ jobs: "$APKSIGNER" verify --print-certs "$OUT" || echo "โš ๏ธ Could not verify signature" { - echo "### ๐Ÿ“ฑ Test APK" + echo "### ๐Ÿ“ฑ ${{ steps.cfg.outputs.release_name }}" echo "" echo "| | |" echo "|---|---|" - echo "| Branch | \`${GITHUB_REF#refs/heads/}\` |" + echo "| Branch | \`${{ steps.cfg.outputs.branch }}\` |" echo "| Commit | \`${GITHUB_SHA::8}\` |" - echo "| Variant | \`${{ github.event.inputs.variant }}\` |" - echo "| ABI | \`${{ github.event.inputs.abi }}\` |" + 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)\` |" - echo "" - echo "Installs as \`com.dtourolle.jellytau.debug\` โ€” side by side with a real" - echo "install, with its own data directory. Download the artifact, then:" - echo "" - echo '```' - echo "adb install -r $(basename "$OUT")" - echo '```' } >> "$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 off a feature branch. The tag here is derived from the - # branch name and carries no version, so nothing else reacts to it. + # real release. `latest` and `test-*` carry no version, so nothing else + # reacts to them. # - # This also cannot reach existing users. 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 without the link. - - name: Publish as a pre-release - # Compared against the string 'true', not used as a bare truthiness - # test. A dispatch input arrives as a *string*, and every non-empty - # string is truthy in the expression language โ€” so `if: inputs.publish` - # would publish a pre-release on every run, including the ones where the - # box was deliberately left unticked. - if: ${{ github.event.inputs.publish == 'true' }} + # 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 }} @@ -205,26 +262,26 @@ jobs: API="${GITHUB_SERVER_URL}/api/v1" REPO="${GITHUB_REPOSITORY}" TOKEN="${GITEA_TOKEN:-$AUTO_TOKEN}" - BRANCH="${GITHUB_REF#refs/heads/}" - TAG="test-$(echo "$BRANCH" | tr '/' '-')" + TAG="${{ steps.cfg.outputs.tag }}" + ASSET="${{ steps.cfg.outputs.asset }}" - # printf, not a heredoc: inside a YAML block scalar every line is - # indented, and a heredoc terminator has to sit at column 0. BODY=$(printf '%s\n' \ - "Test build of \`$BRANCH\` at \`${GITHUB_SHA::8}\` โ€” **not a release**." \ + "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. Uninstalling it does not touch" \ - "the real app." \ + "normal install and with its own separate data. It cannot replace or upgrade a" \ + "real install, and uninstalling it does not touch one." \ "" \ - "Variant: \`${{ github.event.inputs.variant }}\` ยท ABI: \`${{ github.event.inputs.abi }}\`" \ + "R8-minified like a real release, but signed with a debug key โ€” so Android will" \ + "warn about an unknown source. That is expected." \ "" \ - "Android will warn about installing from an unknown source; that is expected" \ - "for a build signed with a debug key rather than the store key.") + "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 "Test build: $BRANCH" \ + --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}') @@ -235,11 +292,14 @@ jobs: if [ "$HTTP" = "201" ]; then RELEASE_ID=$(jq -r '.id' resp.json) elif [ "$HTTP" = "409" ]; then - # Re-dispatching for the same branch replaces the previous APK rather - # than accumulating one release per attempt. - echo "โ„น๏ธ Pre-release $TAG exists; reusing it" + # 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" \ @@ -249,21 +309,24 @@ jobs: echo "โŒ Failed to create pre-release (HTTP $HTTP):"; cat resp.json; exit 1 fi - for f in dist/test-apk/*.apk; do - echo "โฌ†๏ธ $(basename "$f")" - curl -fsS -X POST \ - "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$(basename "$f")" \ - -H "Authorization: token $TOKEN" -F "attachment=@$f" >/dev/null - done + # 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 "Public link โ€” no Gitea account needed. Delete the release when testing is done." + echo "Direct download (stable link, no account needed):" + echo "" + echo " $URL" } >> "$GITHUB_STEP_SUMMARY" + echo "โœ… Published $TAG -> $URL" - - name: Upload APK + - name: Upload APK artifact uses: actions/upload-artifact@v3 with: name: jellytau-test-apk diff --git a/src-tauri/android/README_ANDROID_BUILD.md b/src-tauri/android/README_ANDROID_BUILD.md index ef23ed81e..4671d6350 100644 --- a/src-tauri/android/README_ANDROID_BUILD.md +++ b/src-tauri/android/README_ANDROID_BUILD.md @@ -94,40 +94,56 @@ Follow the right log stream with `./scripts/logcat.sh [debug|release]` ### Getting a test APK out of CI -`.gitea/workflows/build-test-apk.yml` builds one from **any branch, on demand** -โ€” run it from Gitea's Actions tab (`workflow_dispatch`) against the ref you want. -It is not a release: nothing is tagged, published, or signed with the real key. +`.gitea/workflows/build-test-apk.yml` produces installable APKs that are **not +releases**. Two ways in: -Two variants, both installing into the `com.dtourolle.jellytau.debug` slot: +| Trigger | Result | +|---------|--------| +| **push to `master`** | Refreshes the rolling **`latest`** pre-release automatically | +| **`workflow_dispatch`** | Builds any branch on demand; optionally publishes it as `test-` | + +#### The rolling `latest` build + +Every push to `master` (bar doc-only ones) rebuilds and replaces the APK on the +`latest` pre-release. Both the tag and the asset name are stable, so the +download URL never changes: + +``` +https://gitea.tourolle.paris/dtourolle/jellytau/releases/download/latest/jellytau-latest.apk +``` + +Send that link to a tester once and it keeps serving the current build. No +account needed โ€” release assets are public, unlike Actions artifacts. + +#### What you get, and why it is safe + +Both variants install into the `com.dtourolle.jellytau.debug` slot: | Variant | What it is | When | |---------|-----------|------| -| `side-by-side-release` (default) | R8-minified, exactly what ships, signed with the debug keystore | Almost always โ€” a plain debug build cannot catch R8 stripping JNI-loaded classes, which has broken release APKs here before | +| `side-by-side-release` (default, and what `latest` always is) | R8-minified, exactly what ships, signed with the **debug** keystore | Almost always โ€” a plain debug build cannot catch R8 stripping JNI-loaded classes, which has broken release APKs here before | | `debug` | Unminified | When you need readable stack traces | -There is deliberately **no push trigger**: the runner has one slot shared with -two other projects, so building on every feature-branch commit would starve -them. The APK lands as the `jellytau-test-apk` artifact (7-day retention), named -for the branch and short SHA, with its size and SHA256 in the run summary. +Three properties make an automatic build on every master push safe: -#### Sending a build to an outside tester +- **It cannot replace a real install.** The applicationId is suffixed `.debug`, + so it sits beside the store build with its own data. A broken master commit + can never take out somebody's working app. +- **The production signing key is not involved.** That stays in the tag-driven + `build-release.yml`. This workflow needs no secrets beyond the API token. +- **The tag is `latest`/`test-*`, never `v*`.** Only `v*` triggers + `build-release.yml`. And the desktop updater reads a static `latest.json` from + the `updater` branch rather than the release list, so nothing here is offered + to existing users. -Gitea **artifacts require an account** with read access to download, so an -artifact is no use to someone outside the project. Tick **`publish`** on the -dispatch and the APK is also attached to a **pre-release**, whose assets are a -plain public URL on a public repo โ€” no account, no MR, no merge to `master`. +**Known gap:** the APK builds in parallel with `build-and-test.yml`, not after +it, 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. Check the commit's +CI status before handing the link to somebody. -Two things make that safe to do from a feature branch: - -- The tag is `test-`, **not** `v*`. Only `v*` triggers - `build-release.yml`, so nothing else reacts to it. -- It cannot reach existing users. The desktop updater reads a static - `latest.json` from the `updater` branch, not the release list, so a - pre-release published this way is invisible to anyone without the link. - -Re-dispatching for the same branch replaces the APK on the existing -pre-release rather than piling up one release per attempt. Delete the release -when testing is over. +Runs are serialised and `cancel-in-progress` is on, so a burst of pushes to +master collapses into one build rather than one per commit. ### Key Files