android: sign the release build, and give it a version that rises
Two separate reasons updates were impossible, both fixed here. **Every CI build was signed with a different key** (issue #2803, measured with `apksigner --print-certs` across two runs). No signing config meant AGP's debug keystore, which AGP GENERATES when absent — and every job starts from a fresh container. So no build could ever be installed over another: the only way through was uninstall-then-install, which deletes the app's database and every local note with it. **versionCode was hardcoded to 1.** `build.gradle.kts` has read a `THOUGHTSYNC_VERSION_CODE` property since the skeleton landed; nothing ever passed it. Even with signing fixed, every APK would have claimed to be the same version and nothing could tell a newer one existed. It now comes from `GITHUB_RUN_NUMBER` — the same monotonic counter the desktop's version scheme already uses, needing no state between runs and immune to the shallow checkout that makes a commit count useless here. The version NAME comes from the desktop's `build-version.sh`, so both surfaces report one product version rather than two that can disagree. **The alias is hardcoded, not a secret.** It is fixed for the life of the app and already written into the certificate every install carries; hiding it would buy nothing and stop this file describing its own signing. Two secrets, not three — and PKCS12 cannot hold a key password distinct from the store password anyway, so `keyPassword` is the same value by necessity rather than by shortcut. **The lane now builds RELEASE when it can sign, debug when it cannot.** That is not cosmetic. A debug APK is `debuggable`, which on a phone holding personal notes and a device sync token means anyone with adb can read both. Which meant confronting something the release path would have shipped quietly: `cargoNdkDebug` was hardcoded to the debug Cargo profile and every variant took its `.so` from it, so `assembleRelease` would have packaged an UNOPTIMISED store and sync engine. Now one `cargoNdk` task takes its profile from a property, and the whole run uses one profile. A debug/release task pair would have been the tidier shape and would have made a run that both type-checks and packages pay the four-minute cross-compile twice — this runner has no working Gradle or Cargo cache, so that cost is real on every push. The run prints the signing certificate after assembling, so the fingerprint can be compared against the one recorded at generation. Signing with the wrong key produces a perfectly valid APK that simply refuses to install — a failure that otherwise surfaces on the device, long after the run is green. `.gitignore` learns `*.jks`, `*.keystore`, `*.p12`, `*.b64` first, so generating a keystore anywhere near this tree cannot go wrong. Also corrects the record: the comment this replaces cited "Scribe task 2136" as though it were a standing rule. It is not one — none of the 46 always-on rules mentions signing keys. 2136 is a desktop-updater task whose REASONING got repeated until it sounded like policy. The reasoning holds, and holds harder on Android where a key cannot be rotated without the original, so the practice is unchanged; the citation is now honest about what it is.
This commit is contained in:
@@ -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