From 69457ec10dd54ce42203dca8a182cea8e4893c7a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 20:28:38 +0000 Subject: [PATCH] build: make release APKs reproducible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01JtjUcSjjpu4auFndw1QKeU --- BUILDING.md | 44 +++++++++++++++++++++++++++++++++++++++ amethyst/build.gradle.kts | 12 +++++++++++ 2 files changed, 56 insertions(+) diff --git a/BUILDING.md b/BUILDING.md index 3363d10b46..c0f7522c82 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -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 + diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index fab52d3ec9..33bf9655e4 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -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")