M12 — the Android client, end to end #2
@@ -10,6 +10,12 @@ name: Android
|
||||
# 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:
|
||||
@@ -37,7 +43,7 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Kotlin + Rust (debug APK)
|
||||
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
|
||||
@@ -65,6 +71,38 @@ jobs:
|
||||
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
|
||||
echo "profile=release" >> $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
|
||||
|
||||
@@ -77,7 +115,7 @@ jobs:
|
||||
# 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
|
||||
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
|
||||
@@ -97,12 +135,32 @@ jobs:
|
||||
# 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 testDebugUnitTest
|
||||
run: ./gradlew test${{ steps.build.outputs.variant }}UnitTest -PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }}
|
||||
|
||||
- name: Assemble debug APK
|
||||
run: ./gradlew assembleDebug
|
||||
- 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 }}
|
||||
|
||||
- name: Upload debug APK
|
||||
# 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
|
||||
@@ -111,6 +169,6 @@ jobs:
|
||||
# owner/repo to github.com. See Scribe issues 2255 / 2270.
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
with:
|
||||
name: thoughtsync-android-debug-${{ github.sha }}
|
||||
path: android/app/build/outputs/apk/debug/app-debug.apk
|
||||
name: thoughtsync-android-${{ steps.build.outputs.profile }}-${{ github.sha }}
|
||||
path: ${{ steps.build.outputs.apk }}
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -196,3 +196,12 @@ android/local.properties
|
||||
# the working tree is a convenience, never a source. Ignored because they are
|
||||
# ~57 MB and `git add -A` would otherwise put one in history forever.
|
||||
*.apk
|
||||
|
||||
# Signing material. NEVER committed — an Android signing key cannot be rotated
|
||||
# without the original (v3 lineage needs it), so a leaked or lost one means every
|
||||
# install has to be removed and replaced by hand. Listed before any keystore
|
||||
# exists so that generating one in this directory cannot go wrong.
|
||||
*.jks
|
||||
*.keystore
|
||||
*.p12
|
||||
*.b64
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
plugins {
|
||||
@@ -120,15 +121,33 @@ val rustInputs =
|
||||
workspaceRoot.file("Cargo.lock"),
|
||||
)
|
||||
|
||||
/**
|
||||
* Which Cargo profile the `.so` is built with.
|
||||
*
|
||||
* A property rather than a debug/release task PAIR, deliberately. This runner has
|
||||
* no working Gradle or Cargo cache (`reserveCache failed` on every run), so a cold
|
||||
* cross-compile of four ABIs costs about four minutes — and a lane that both
|
||||
* type-checks and packages would pay that twice if the two used different
|
||||
* profiles. `android.yml` picks one profile and uses it for every Gradle call in
|
||||
* the run.
|
||||
*
|
||||
* Defaults to debug so a local build stays fast; CI passes release, because an
|
||||
* unoptimised store and sync engine is a real difference on a phone, not a
|
||||
* theoretical one.
|
||||
*/
|
||||
val rustProfile =
|
||||
(project.findProperty("THOUGHTSYNC_CARGO_PROFILE") as String?)?.takeIf { it.isNotBlank() }
|
||||
?: "debug"
|
||||
|
||||
val jniLibsOut = layout.buildDirectory.dir("rustJniLibs")
|
||||
val bindingsOut = layout.buildDirectory.dir("generated/uniffi")
|
||||
|
||||
val cargoNdkDebug =
|
||||
tasks.register<CargoNdkBuild>("cargoNdkDebug") {
|
||||
description = "Cross-compile thoughtsync-ffi for the Android ABIs (debug)."
|
||||
val cargoNdk =
|
||||
tasks.register<CargoNdkBuild>("cargoNdk") {
|
||||
description = "Cross-compile thoughtsync-ffi for the Android ABIs."
|
||||
rustSources.from(rustInputs)
|
||||
abis.set(androidAbis)
|
||||
cargoProfile.set("debug")
|
||||
cargoProfile.set(rustProfile)
|
||||
workspaceDir.set(workspaceRoot)
|
||||
jniLibsDir.set(jniLibsOut)
|
||||
}
|
||||
@@ -136,7 +155,7 @@ val cargoNdkDebug =
|
||||
val generateBindings =
|
||||
tasks.register<UniffiBindgen>("generateUniffiBindings") {
|
||||
description = "Generate the Kotlin bindings from the compiled .so."
|
||||
dependsOn(cargoNdkDebug)
|
||||
dependsOn(cargoNdk)
|
||||
// arm64 is arbitrary — every ABI carries the same uniffi metadata, and
|
||||
// reading one is cheaper than reading four.
|
||||
libraryFile.set(jniLibsOut.map { it.file("arm64-v8a/libthoughtsync_ffi.so") })
|
||||
@@ -174,15 +193,42 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// The signing key reaches this build only through the environment: CI decodes
|
||||
// it from a secret into a file and points ANDROID_KEYSTORE_FILE at that path.
|
||||
// It is never in the repo and never in this file. Generated by the operator
|
||||
// and never seen by an agent session, because an Android signing key cannot be
|
||||
// rotated without the original — v3 lineage needs it — so a leaked or lost one
|
||||
// means every install has to be removed and replaced by hand.
|
||||
val keystoreFile = System.getenv("ANDROID_KEYSTORE_FILE")?.takeIf { it.isNotBlank() }
|
||||
val keystorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")?.takeIf { it.isNotBlank() }
|
||||
|
||||
signingConfigs {
|
||||
if (keystoreFile != null && keystorePassword != null) {
|
||||
create("release") {
|
||||
storeFile = File(keystoreFile)
|
||||
storePassword = keystorePassword
|
||||
// Hardcoded, and NOT a secret: the alias is fixed for the life of
|
||||
// this app and is written into the certificate every install
|
||||
// already carries. Hiding it would buy nothing and stop this file
|
||||
// describing its own signing setup.
|
||||
keyAlias = "thoughtsync"
|
||||
// PKCS12 cannot hold a key password distinct from the store
|
||||
// password — keytool refuses to set one — so this is the same
|
||||
// value by necessity rather than by shortcut.
|
||||
keyPassword = keystorePassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
// Signing is deliberately absent. A release keystore that has passed
|
||||
// through an agent session or shell history is compromised by
|
||||
// construction (Scribe task 2136) — it has to be generated by the
|
||||
// operator and reach CI only as a secret. Until then a release build
|
||||
// is unsigned and CI builds debug.
|
||||
// Null when no keystore reached this build, which leaves the APK
|
||||
// unsigned and therefore uninstallable. `android.yml` builds debug in
|
||||
// that case rather than producing an artifact nobody can put on a
|
||||
// phone.
|
||||
signingConfig = signingConfigs.findByName("release")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,7 +259,7 @@ android {
|
||||
androidComponents {
|
||||
onVariants { variant ->
|
||||
variant.sources.kotlin?.addGeneratedSourceDirectory(generateBindings, UniffiBindgen::outputDir)
|
||||
variant.sources.jniLibs?.addGeneratedSourceDirectory(cargoNdkDebug, CargoNdkBuild::jniLibsDir)
|
||||
variant.sources.jniLibs?.addGeneratedSourceDirectory(cargoNdk, CargoNdkBuild::jniLibsDir)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user