android: build the signed APK's Rust with the release profile
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 12s
Android / Core and FFI clippy and tests (push) Successful in 25s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m11s
Android / Build the server image (push) Successful in 1s

Every APK so far carried a debug-profile libinkwell_ffi.so (opt-level 0),
because the workspace's release profile sets strip = true, which removes
the symbols uniffi's --library mode reads the interface from (run 4077).

The cargoNdk task now builds --release with two environment overrides, for
this build only:
- CARGO_PROFILE_RELEASE_STRIP=debuginfo keeps the symbol table. A release
  build has no debug info, so this keeps symbols and nothing else.
- CARGO_PROFILE_RELEASE_PANIC=unwind keeps a core panic reaching Kotlin as
  an exception, which the board shows as an error, not an app exit. Phones
  have always had unwind, because debug unwinds, so this keeps what they
  do.

Overrides rather than a profile of our own because cargo-ndk copies its -o
output from the release directory, and nothing says it handles another.
The desktop's binaries are unchanged. android.yml passes release on the
signed path; the unsigned debug path keeps debug.

Scribe #2810.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 08:39:36 -04:00
co-authored by Claude Opus 5.5
parent 8cdb36dc2a
commit f0ce5687cc
3 changed files with 37 additions and 24 deletions
+7 -8
View File
@@ -159,12 +159,10 @@ jobs:
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/inkwell-release.jks
echo "variant=Release" >> $GITHUB_OUTPUT
echo "label=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
# An optimised .so for the APK people install. The cargoNdk task keeps
# the symbols uniffi reads its metadata from, which the release
# profile would strip (run 4077, Scribe #2810).
echo "profile=release" >> $GITHUB_OUTPUT
echo "keystore=/tmp/inkwell-release.jks" >> $GITHUB_OUTPUT
echo "apk=android/app/build/outputs/apk/release/app-release.apk" >> $GITHUB_OUTPUT
echo "Signed release build — $version (versionCode $code)"
@@ -296,8 +294,9 @@ jobs:
# upload is stored and invisible (Scribe 2270).
uses: actions/upload-artifact@v7
with:
# The APK's variant, NOT the Cargo profile — those are the same word
# for different things and the profile is pinned to debug (#2810).
# The APK's variant, NOT the Cargo profile. They are the same word for
# different things, and an unsigned debug APK still differs from a
# signed release one in more than its Rust.
name: inkwell-android-${{ steps.build.outputs.label }}-${{ github.sha }}
path: ${{ steps.build.outputs.apk }}
if-no-files-found: error
+22 -9
View File
@@ -53,11 +53,28 @@ abstract class CargoNdkBuild : DefaultTask() {
// --locked so an Android build cannot silently re-resolve the workspace
// lockfile the desktop lanes are gated on.
args += "--locked"
if (cargoProfile.get() == "release") args += "--release"
val release = cargoProfile.get() == "release"
if (release) args += "--release"
execOps.exec {
commandLine(listOf("cargo") + args)
workingDir = workspaceDir.get().asFile
if (release) {
// The workspace's release profile with two of its settings put back,
// for this build only. Environment overrides rather than a profile of
// our own, because cargo-ndk copies its `-o` output from the
// `release` directory and nothing says it understands any other one;
// the desktop's binaries are untouched either way.
//
// Keep the symbol table: `strip = true` removes the symbols uniffi's
// `--library` mode reads the interface out of, and bindgen then fails
// with "No UniFFI metadata found" (run 4077). There is no debug info
// in a release build to strip, so this keeps symbols and nothing else.
environment("CARGO_PROFILE_RELEASE_STRIP", "debuginfo")
// Unwind, so a panic in the core reaches Kotlin as an exception the
// board can show rather than ending the app. See the ffi crate's docs.
environment("CARGO_PROFILE_RELEASE_PANIC", "unwind")
}
}
}
}
@@ -131,14 +148,10 @@ val rustInputs =
* profiles. `android.yml` picks one profile and uses it for every Gradle call in
* the run.
*
* CI currently passes `debug` even for a release APK, which is not where this
* should end up: an unoptimised store and sync engine is a real difference on a
* phone, not a theoretical one. The blocker is that the workspace's release
* profile sets `strip = true`, which removes the symbols uniffi reads its
* interface metadata from — `generateUniffiBindings` then fails with "No UniFFI
* metadata found" (run 4077). Fixing it means either an Android-specific profile
* that keeps symbols or generating the bindings from a separate unstripped
* build, and neither is worth holding signed APKs up for. Scribe #2810.
* CI passes `release` for a signed APK, so a phone runs an optimised store and sync
* engine. Until Scribe #2810 it passed `debug`, because the release profile's
* `strip = true` broke bindgen; `CargoNdkBuild` now keeps the symbols for this
* build alone.
*/
val rustProfile =
(project.findProperty("INKWELL_CARGO_PROFILE") as String?)?.takeIf { it.isNotBlank() }
+8 -7
View File
@@ -24,14 +24,15 @@
//! consistent; it simply hasn't stamped `last_sync_at`, which is only written after
//! BOTH halves of a cycle succeed. The next cycle resumes from the stored cursor.
//!
//! ## A known consequence of the release profile
//! ## A panic is an exception, not a crash
//!
//! The workspace sets `panic = "abort"` (Tauri's profile, for binary size). uniffi
//! would otherwise catch a panic crossing the FFI boundary and raise it in Kotlin as
//! an exception; with `abort` it takes the process down instead. That is the same
//! behaviour the desktop already has, so no surface is worse off than another — but
//! it is a deliberate cost, not an oversight. Revisit if a panic in the core ever
//! turns out to be recoverable enough that a phone should survive it.
//! The workspace's release profile sets `panic = "abort"` (Tauri's, for binary size).
//! The Android build overrides it back to `unwind` (`CargoNdkBuild` in
//! `app/build.gradle.kts`), so uniffi catches a panic crossing the FFI boundary and
//! raises it in Kotlin as an exception, which the board shows as an error. Under
//! `abort` the same panic would end the app. Every phone build had unwind before
//! the release profile was used (debug unwinds), so keeping it changes nothing a
//! phone does. The cost is library size (Scribe #2810).
pub mod models;