Merge pull request #4142 from vitorpamplona/claude/cool-pasteur-ydq60k

Pin NDK version for reproducible APK builds
This commit is contained in:
Vitor Pamplona
2026-09-18 13:32:12 -04:00
committed by GitHub
3 changed files with 69 additions and 0 deletions
+12
View File
@@ -109,6 +109,18 @@ Rust toolchain + the exact Android NDK revision pinned in
`tools/arti-build/ANDROID_NDK_VERSION` for Arti) documented in their READMEs —
they are **not** required to build Amethyst from the committed sources.
The NDK half of that Arti pin does reach the ordinary Android build, though.
AGP strips every native library it packages with the NDK's `llvm-strip`, so
`:amethyst` sets `ndkVersion` from `ANDROID_NDK_VERSION` — one revision for the
libraries we build and the ones we merge from dependencies. `libarti_android.so`
is then excluded from that strip step (`packaging.jniLibs.keepDebugSymbols`):
the Cargo release profile already stripped it, and llvm-strip would only rewrite
its `.comment` stamps, so skipping the pass costs no size and lets the `.so`
inside an APK be compared byte-for-byte against the committed, independently
reproducible one. The Rust toolchain stays irrelevant either way; Studio/AGP
fetches the pinned NDK on demand, or pre-install it with
`sdkmanager "ndk;$(cat tools/arti-build/ANDROID_NDK_VERSION)"`.
---
## Per-format build commands
+39
View File
@@ -74,6 +74,29 @@ android {
.get()
.toInt()
// Packaging toolchain: AGP runs the NDK's llvm-strip over everything that
// lands in jniLibs — our committed libarti_android.so included — so the
// NDK revision is a build input for the APK, not just for whoever compiles
// Arti. Left unset it silently follows AGP's own default (28.2.13676358 on
// AGP 9.4.0), which moves with every AGP bump and is a different toolchain
// from the one that produced the .so, while a machine with no NDK at all
// packages the library unstripped ("Unable to strip the following
// libraries") — three different APKs from the same source, which is
// exactly what F-Droid's rebuild verification cannot have.
//
// Read straight from the Arti pin rather than copied into the version
// catalog: the two can then never drift, and bumping ANDROID_NDK_VERSION
// (which also means rebuilding the .so) moves the packaging toolchain with
// it. See tools/arti-build/README.md → "Reproducible builds".
ndkVersion =
providers
.fileContents(layout.settingsDirectory.file("tools/arti-build/ANDROID_NDK_VERSION"))
.asText
.orNull
?.trim()
?.takeIf { it.isNotEmpty() }
?: error("tools/arti-build/ANDROID_NDK_VERSION is missing or empty — it pins the NDK that strips src/main/jniLibs")
defaultConfig {
applicationId = "com.vitorpamplona.amethyst"
minSdk =
@@ -295,6 +318,22 @@ android {
resources {
excludes += listOf("/META-INF/{AL2.0,LGPL2.1}", "**/libscrypt.dylib")
}
jniLibs {
// Reproducible builds, part two: ship the Arti library exactly as
// tools/arti-build produced it. Its Cargo release profile already
// strips it (no .symtab, no .debug_*), so AGP's
// `llvm-strip --strip-unneeded` pass has nothing left to remove — but it
// still rewrites the file: llvm-strip rebuilds .comment, the section that
// records the rustc / clang / lld version stamps, which measurably changes
// 273 bytes on arm64-v8a. That made the packaged bytes a function of
// whichever NDK did the stripping, so the .so in an APK could never be
// compared against the committed, independently reproducible one.
// Excluding it from the strip step costs nothing in size (there are no
// symbols to drop) and makes that comparison exact. Dependency .so files
// are still stripped, with the NDK pinned by ndkVersion above.
keepDebugSymbols += "**/libarti_android.so"
}
}
lint {
+18
View File
@@ -49,6 +49,24 @@ committed binary wasn't tampered with. **Five** things have to be fixed:
> pinned output was verified with. `cargo-ndk` only wraps the NDK, so a mismatch
> is a warning rather than an error — but it is the next thing to check if your
> rebuild does not match.
>
> **The app build reads this pin too.** `amethyst/build.gradle.kts` sets
> `ndkVersion` from `ANDROID_NDK_VERSION`, because AGP runs the NDK's
> `llvm-strip` over every native library it packages — the toolchain that strips
> a library is as much an APK input as the one that compiled it. Unset,
> `ndkVersion` follows AGP's own default (r28 on AGP 9.4.0) and moves with every
> AGP bump. So bumping this file changes what packagers need installed, not only
> what rebuilders need: bump it, rebuild the `.so`, and commit both.
>
> **`libarti_android.so` skips that strip step.** The release profile here
> already strips it — no `.symtab`, no `.debug_*` — so `llvm-strip
> --strip-unneeded` has nothing to remove and only rebuilds `.comment`, the
> section carrying the rustc/clang/lld stamps (273 bytes change on arm64-v8a).
> `packaging.jniLibs.keepDebugSymbols` in `amethyst/build.gradle.kts` therefore
> excludes it, which costs no APK size and means the library inside a built APK
> is byte-identical to the one committed in `src/main/jniLibs/`: `unzip -p
> app.apk lib/arm64-v8a/libarti_android.so | sha256sum` can be checked straight
> against the file this script reproduces.
`repro-env.sh` (sourced by both build scripts) also sets `CARGO_INCREMENTAL=0`
and a fixed `SOURCE_DATE_EPOCH` derived from the Arti tag. The size-optimized