Files
thoughtsync/.forgejo/workflows/android.yml
T
bvandeusen cae9888eb9
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m0s
Android / Kotlin + Rust (APK) (push) Failing after 4m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m29s
Desktop (Tauri) / Update manifest (push) Successful in 5s
android: build the release APK with a debug-profile .so, for now
`d0a9c73` switched the lane to a release Cargo profile alongside the release
variant. The variant was the point; the profile was mine, and it broke the build
(run 4077): `generateUniffiBindings` fails with "No UniFFI metadata found" on
the release `.so`.

The workspace release profile sets `strip = true`, and uniffi's `--library` mode
finds its interface metadata through symbols. That is the obvious suspect and it
is recorded as a suspect, not a finding — `lto = true` dropping the metadata
statics would print the identical message and the two have not been told apart.

Backed out to the debug profile rather than guessing at a fix, because the two
halves of that commit are not equally important. Signing and a rising
versionCode are what make an install replace the last one instead of wiping the
notes; the Rust profile only makes the result faster. The APK this produces is
no worse than every previous build, all of which shipped a debug-profile `.so`.

Recorded as Scribe #2810 with the four candidate fixes and, more usefully, the
instruction to establish the cause on a host build before spending another
four-minute cold cross-compile on a guess.
2026-08-20 19:16:03 -04:00

180 lines
8.5 KiB
YAML

name: Android
# The native Kotlin/Compose client over the shared Rust core (M12).
#
# Replaces the Tauri-mobile lane deleted in step 2. What changed is what this
# builds, not that Android has a lane: the UI is Compose, and the store and sync
# engine are `thoughtsync-core` cross-compiled by cargo-ndk and loaded through
# uniffi.
#
# CI can only prove this BUILDS. A Linux runner cannot execute an APK, so anything
# about feel, touch or on-device correctness is an operator pass on an emulator or
# phone.
#
# The artifact is a SIGNED RELEASE APK when the keystore secret is present, and an
# unsigned debug one when it is not. That distinction is not cosmetic: two builds
# signed with different keys cannot replace one another, and bridging that gap
# means uninstalling first — which deletes the app's database and every local note
# with it (Scribe issue 2803).
on:
push:
branches: [dev, main]
paths:
- "android/**"
# The Rust the .so is built from. A core change reaches the phone exactly
# as it reaches the desktop, so this lane has to rebuild on it.
- "core/**"
- "Cargo.toml"
- "Cargo.lock"
- ".forgejo/workflows/android.yml"
workflow_dispatch:
concurrency:
group: android-${{ github.ref }}
cancel-in-progress: true
env:
# Silences the JDK 22+ "restricted method in java.lang.System has been called"
# warning that Gradle 9.1's bundled native-platform jar trips at launch. This
# targets the LAUNCHER JVM, which is why org.gradle.jvmargs in
# gradle.properties is not enough on its own (Minstrel hit the same thing).
JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
jobs:
build:
name: Kotlin + Rust (APK)
# runs-on is only a scheduling label (Label Model B). flutter-ci is the
# proven-working label that can pull our container images.
runs-on: flutter-ci
container:
# The image repurposed from ci-tauri-android in M12 step 3: Rust + the four
# Android ABIs + cargo-ndk + SDK/NDK + JDK 25 + ktlint + detekt.
image: git.fabledsword.com/bvandeusen/ci-rust-android:1.97
defaults:
run:
working-directory: android
steps:
- uses: actions/checkout@v4
- name: Cache Gradle and Cargo
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.kotlin
target
key: android-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/gradle/libs.versions.toml', 'android/**/*.gradle.kts', 'Cargo.lock') }}
restore-keys: |
android-
# Everything downstream keys off this: the variant to build, the Cargo
# profile to build it with, and the version it carries. Decided once so no
# two Gradle invocations in this run can disagree and force a second
# four-minute cross-compile.
- name: Signing key, variant and version
id: build
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
version="$(sh ../desktop/packaging/build-version.sh)"
echo "name=$version" >> $GITHUB_OUTPUT
# versionCode must RISE for Android to accept an update, and the run
# number is the same monotonic counter the desktop's version scheme
# already uses — no state carried between runs, and immune to the
# shallow checkout that makes a commit count useless here.
echo "code=$GITHUB_RUN_NUMBER" >> $GITHUB_OUTPUT
if [ -n "${ANDROID_KEYSTORE_BASE64:-}" ]; then
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/thoughtsync-release.jks
echo "variant=Release" >> $GITHUB_OUTPUT
# DEBUG profile, in a release APK, deliberately — see the note above
# the cargoNdk task. The release profile strips the symbols uniffi
# reads its metadata out of, so `generateUniffiBindings` fails
# outright (run 4077). Unpicking that is worth doing and is not worth
# blocking signed builds on.
echo "profile=debug" >> $GITHUB_OUTPUT
echo "keystore=/tmp/thoughtsync-release.jks" >> $GITHUB_OUTPUT
echo "apk=android/app/build/outputs/apk/release/app-release.apk" >> $GITHUB_OUTPUT
echo "Signed release build — $version (versionCode $GITHUB_RUN_NUMBER)"
else
echo "::warning::No ANDROID_KEYSTORE_BASE64 secret. Building an UNSIGNED DEBUG APK: it cannot be installed over a signed build and cannot self-update."
echo "variant=Debug" >> $GITHUB_OUTPUT
echo "profile=debug" >> $GITHUB_OUTPUT
echo "keystore=" >> $GITHUB_OUTPUT
echo "apk=android/app/build/outputs/apk/debug/app-debug.apk" >> $GITHUB_OUTPUT
fi
- name: Make gradlew executable
run: chmod +x ./gradlew
# Fails loudly here if the wrapper and the image's JDK disagree, rather
# than thirty seconds into a compile with an opaque version message.
- name: Gradle wrapper check
run: ./gradlew --version
# Cross-compiles the core for four ABIs and generates the Kotlin bindings
# from the built .so. Run as its own step so a Rust failure is legible as a
# Rust failure instead of arriving inside a Gradle stack trace.
- name: Build the native library and bindings
run: ./gradlew generateUniffiBindings -PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }}
# The image's PINNED CLIs, not Gradle plugins. ci-rust-android carries both
# (M12 step 3) precisely so this lane needs no second image, and going
# through Gradle plugins would mean a second version of each tool resolved
# at build time and kept in lockstep with the image's by hand.
#
# Scoped to src/main: the generated uniffi bindings live under build/ and
# are not ours to style.
- name: ktlint
run: ktlint "app/src/main/**/*.kt"
- name: detekt
run: detekt --build-upon-default-config --config config/detekt.yml --input app/src/main/java
- name: Unit tests
# Host-JVM tests only. Anything touching the core needs an Android
# runtime to load the .so, so those are instrumented tests and belong on
# an emulator, not here — the Rust side is covered by the workspace
# tests in the desktop lane.
run: ./gradlew test${{ steps.build.outputs.variant }}UnitTest -PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }}
- name: Assemble the APK
env:
# Empty on the unsigned path, which build.gradle.kts reads as "no
# signing config" rather than as a path to a missing file.
ANDROID_KEYSTORE_FILE: ${{ steps.build.outputs.keystore }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
run: |
./gradlew assemble${{ steps.build.outputs.variant }} \
-PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }} \
-PTHOUGHTSYNC_VERSION_NAME=${{ steps.build.outputs.name }} \
-PTHOUGHTSYNC_VERSION_CODE=${{ steps.build.outputs.code }}
# Prints the certificate the APK was actually signed with, so the operator
# can compare it against the fingerprint recorded when the key was
# generated. Signing with the WRONG key produces a perfectly valid APK that
# simply refuses to install over the app already on the phone — a failure
# that otherwise only shows up on the device, after the run is green.
- name: Show the signing certificate
if: steps.build.outputs.keystore != ''
run: |
apksigner="$(ls /opt/android-sdk/build-tools/*/apksigner | head -1)"
"$apksigner" verify --print-certs "app/build/outputs/apk/release/app-release.apk"
- name: Upload the APK
# Mirrored action, never actions/upload-artifact. @v4+ throws
# GHESNotSupportedError client-side on this hostname, and @v3 is worse —
# it reports success while Gitea serves artifacts back only through the
# v4 API, so the upload is stored and invisible. Pinned by SHA because
# the mirror auto-syncs; full URL because DEFAULT_ACTIONS_URL sends bare
# owner/repo to github.com. See Scribe issues 2255 / 2270.
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
with:
name: thoughtsync-android-${{ steps.build.outputs.profile }}-${{ github.sha }}
path: ${{ steps.build.outputs.apk }}
if-no-files-found: error