build: make release APKs reproducible

AGP embeds a dependency-metadata blob (the resolved dependency tree, a
protobuf encrypted with a Google public key) into the APK/AAB signing
block by default. That ciphertext is non-deterministic, so it was the one
remaining thing preventing our release artifacts from being rebuilt
bit-for-bit by a third party.

Disable it via dependenciesInfo { includeInApk = false; includeInBundle =
false }. Combined with the already-pinned toolchain (AGP/Kotlin/R8/JDK 21),
the absence of any build-time clock in BuildConfig, and a deterministic
version name, release APKs now reproduce exactly — letting F-Droid /
Zapstore independently verify our developer-signed builds. Play derives
this dependency data server-side, so nothing is lost there.

Document the guarantee and a diffoscope-based verification recipe in
BUILDING.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JtjUcSjjpu4auFndw1QKeU
This commit is contained in:
Claude
2026-06-26 20:28:38 +00:00
parent 19debffe15
commit 69457ec10d
2 changed files with 56 additions and 0 deletions
+44
View File
@@ -163,6 +163,50 @@ Examples:
---
## Reproducible Android builds
The release APKs are **bit-for-bit reproducible**: anyone can rebuild the exact
bytes we ship (minus the signature) from the tagged source and confirm the
artifact on F-Droid / Zapstore / GitHub was built from this code and nothing
else. What makes that hold:
- **Pinned toolchain.** AGP, Kotlin, R8, and the Compose compiler are pinned in
`gradle/libs.versions.toml`; the build targets **JDK 21**. R8 is deterministic
for a fixed version + inputs, so the minified output is stable. Build with the
same JDK 21 you see in `BUILDING.md` / CI.
- **No build-time clock.** Nothing injects `System.currentTimeMillis()` /
build dates into `BuildConfig` (a Spotless rule bans the call in `quartz` and
`commons`), and AGP normalizes ZIP entry timestamps, so two builds an hour
apart are identical.
- **Deterministic version name.** `generateVersionName` only appends a branch
suffix off feature branches; a release tag builds in detached-`HEAD` (or from a
source tarball with no `.git`) resolve to the bare `app` version.
- **No dependency-metadata blob.** `dependenciesInfo { includeInApk = false;
includeInBundle = false }` in `amethyst/build.gradle.kts` stops AGP from
embedding the Google-encrypted dependency protobuf in the signing block — that
ciphertext is non-deterministic and was the one remaining blocker.
### Verify a release APK reproduces
```bash
# 1. Check out the exact released tag and build the same variant unsigned.
git checkout v1.12.1
./gradlew clean :amethyst:assembleFdroidRelease
# 2. Diff your unsigned build against the published APK, ignoring only the
# signature (META-INF/*). apksigner + a zip-aware diff is the simplest check;
# diffoscope gives a human-readable breakdown of any remaining delta.
diffoscope \
amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-arm64-v8a-release-unsigned.apk \
amethyst-fdroid-arm64-v8a-1.12.1.apk
```
A clean run shows differences confined to `META-INF/` (the signing files). Any
diff in `classes*.dex`, `resources.arsc`, or native libs means something in the
toolchain drifted — file it before publishing.
---
## Release runbook
The release flow is driven by a tag push. Every cut ships Android + Desktop +
+12
View File
@@ -264,6 +264,18 @@ android {
resValues = true
}
// Reproducible builds: keep AGP from embedding the dependency-metadata blob
// in the APK/AAB. That blob is a protobuf of the resolved dependency tree
// encrypted with a Google public key; the ciphertext is non-deterministic,
// so its presence makes every release artifact impossible to reproduce
// bit-for-bit. Dropping it lets F-Droid / Zapstore independently rebuild and
// verify our developer-signed APKs. (Play still derives this data server-side
// from the upload, so nothing is lost for the Play channel.)
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
packaging {
resources {
excludes += listOf("/META-INF/{AL2.0,LGPL2.1}", "**/libscrypt.dylib")