Skip to content

Make release APKs reproducible - #6219

Closed
AcideFluorhydrique wants to merge 3 commits into
2dust:masterfrom
AcideFluorhydrique:pr/reproducible-release
Closed

AcideFluorhydrique wants to merge 3 commits into
2dust:masterfrom
AcideFluorhydrique:pr/reproducible-release

Conversation

@AcideFluorhydrique

Copy link
Copy Markdown

Companion to 2dust/AndroidLibXrayLite#207, which makes the released libv2ray.aar reproducible. With both, anyone can rebuild a published APK from source and compare it byte for byte, which is what IzzyOnDroid and F-Droid's reproducible-builds verification needs. Each commit here stands on its own.

Changes

  1. NDK: declare ndkVersion in build.gradle.kts and drop the sed in build.yml that inserted it at a hardcoded line number. The NDK strips the jniLibs, so its version is part of the APK, and the sed only took effect inside CI.
  2. JDK: pin Temurin 21.0.12.1+1 instead of '21', which resolves to whatever JDK 21 the runner image has preinstalled. It is written as 21.0.12+101.0.LTS, the form setup-java matches against Adoptium; '21.0.12.1' only matches the preinstalled copy by accident, and '21.0.12+101' matches nothing.
  3. AndroidLibXrayLite tag: use git describe --tags --exact-match instead of --abbrev=0. When the submodule is past a tag, --abbrev=0 resolves to the nearest older tag and the older aar is shipped. That happened for 1.10.30 to 1.10.32: the submodule pointed at 4ba2d04a and 1655d531 ("Updating xray-core to v25.12.8"), neither tagged, and --abbrev=0 resolved both to v25.12.2, so build.yml downloaded the v25.12.2 aar for all three. The step now fails with an explanation instead. 27 of the last 30 submodule updates were on a tag, where nothing changes.

Validation

  • A from-source workflow in a fork built this ndkVersion in three independent GitHub-hosted runs, all producing identical APKs: 34460850995, 34466248610, 34475374598. The third used this JDK pin and a newer runner image; the first two used the runner's preinstalled copy of the same JDK build. All three passed :app:assemblePlaystoreDebug and :app:testPlaystoreDebugUnitTest with a build.gradle.kts identical to this branch's apart from the comment.
  • The exact-match step was run against a submodule sitting on a tag (passes, exports the tag) and one commit past it (fails with the message).
  • Not run: build.yml on this branch, since it needs the release keystore.

Two inputs to the release build were not fixed by anything in the
repository.

The NDK strips the jniLibs, so its version shapes the APK, but
ndkVersion was only ever inserted into build.gradle.kts by a sed in
build.yml at a hardcoded line number. Outside CI the Android Gradle Plugin
default applied instead. Declare it in build.gradle.kts and drop the sed.

setup-java was asked for '21', which resolves to whatever JDK 21 the
runner image has preinstalled, so the JDK changed whenever GitHub updated
the image. Pin the exact Temurin build, 21.0.12.1+1. It has to be written
as the Adoptium semver 21.0.12+101.0.LTS: setup-java checks the runner's
preinstalled JDKs before Adoptium, '21.0.12.1' happens to match the
preinstalled folder today but nothing on Adoptium, and '21.0.12+101'
matches nothing at all. That was checked by running setup-java v6.0.1's
own version-matching code against the live Adoptium release list.

The same ndkVersion line and JDK pin built identical APKs across three
independent GitHub-hosted runs, one of them on a newer runner image.
build.yml downloads libv2ray.aar from the AndroidLibXrayLite release named
by `git describe --tags --abbrev=0`. When the submodule sits past a tag,
that silently resolves to the nearest older tag, and the APK ships a core
that is not the one the submodule points at.

This has happened. 1.10.30 was built with the submodule at 4ba2d04a, and
1.10.31 and 1.10.32 with it at 1655d531 ("Updating xray-core to v25.12.8"),
none of them tagged; describe resolved all three to v25.12.2, so they
shipped the v25.12.2 aar while the source tree said v25.12.8.

Use --exact-match and fail with an explanation instead. Nothing changes
when the submodule is on a tag, which was the case for 27 of the last 30
submodule updates.
@eliotcougar

Copy link
Copy Markdown
Contributor

Pairs well with #6181 ...

2dust#6181 also drops the sed, and for a better reason: it adds imports for
the public variant API, which moves the line the sed counts to. It passes
the version as -PNDK_VERSION instead, with AGP's default applying when the
property is absent.

That default is the problem for reproducibility. Someone rebuilding a
published APK runs Gradle without knowing about the property, gets AGP's
default NDK, and strips the jniLibs with a different one, so the bytes
differ with nothing to indicate why.

Read the property and keep the pinned value as its default, so both hold:
CI or a local build can override it, and a plain Gradle invocation still
uses the NDK the release was built with.
@AcideFluorhydrique

Copy link
Copy Markdown
Author

Pairs well with #6181 ...

Thanks — agreed, and there is one real overlap: #6181 drops the same sed, for a better reason than mine (the imports it adds move the line the sed counts to).

The one thing I would ask for in whichever version lands: the pinned NDK has to remain the default, not only a CI-supplied -PNDK_VERSION. The NDK strips the jniLibs, so its version is part of the APK. Anyone rebuilding a published APK — an F-Droid recipe, IzzyOnDroid's rebuilder, or just a user checking a release — runs Gradle without knowing about the property, falls back to AGP's default (28.2.13676358, per your own validation notes), strips with a different NDK, and gets different bytes with nothing to indicate why.

So I have changed my commit to do both:

ndkVersion = providers.gradleProperty("NDK_VERSION").getOrElse("29.0.14206865")

That keeps your override and keeps a plain ./gradlew assembleFdroidRelease building with the NDK the release was built with. If #6181 lands first I will happily drop my NDK commit entirely, as long as the default stays pinned there.

Nothing else here should collide: #6181 does not touch setup-java, and the --exact-match commit is independent. Your AGP, Gradle and license-plugin upgrades will change the APK bytes, which is fine for reproducibility — it only needs those versions pinned in the repo, and they are, down to the wrapper distribution checksum.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants