From e1614c972102a3e83a42901a4b2bec6a124cf4cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:09:14 +0800 Subject: [PATCH 1/3] fix(ci): build the release APKs with build-fdroid.sh, then sign them The release ran a plain flutter build through fl_build, which compiles a different libapp.so from what F-Droid rebuilds with build-fdroid.sh. Refs #1274 --- .github/workflows/build.yml | 101 +++++++++++++++++++++++++----------- 1 file changed, 71 insertions(+), 30 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e38cfb80e4..2a835e3852 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -566,14 +566,33 @@ jobs: - name: Fix the Android build epoch shell: bash run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "$GITHUB_ENV" - - name: Install the pinned Android NDK + - name: Install the pinned Android SDK and NDK shell: bash run: | set -euo pipefail + compile_sdk="$(tr -d '[:space:]' < android/compile-sdk-version.txt)" + build_tools="$(tr -d '[:space:]' < android/build-tools-version.txt)" ndk_version="$(tr -d '[:space:]' < android/ndk-version.txt)" sdkmanager="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" - "$sdkmanager" "ndk;$ndk_version" + "$sdkmanager" \ + "platforms;android-$compile_sdk" \ + "build-tools;$build_tools" \ + "ndk;$ndk_version" echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/$ndk_version" >> "$GITHUB_ENV" + echo "APKSIGNER=$ANDROID_HOME/build-tools/$build_tools/apksigner" >> "$GITHUB_ENV" + - name: Fetch dependencies + run: flutter pub get --enforce-lockfile + # The script F-Droid runs, so what it rebuilds from this commit is this + # APK without its signature. `fl_build` ran a plain `flutter build apk` + # instead: no dev plugins pruned, the plugin registrant left at a path + # under `.dart_tool`, so `libapp.so` came out different from F-Droid's + # (#1274) and tied to this runner's checkout path. The script also + # patches jni and builds proot for arm64. + - name: Build + shell: bash + run: scripts/release/build-fdroid.sh ${{ matrix.abi }} + # After the build, not before: with `key.properties` present Gradle signs + # the release itself, and the unsigned APK above is never produced. - name: Fetch secrets # Through the environment, and quoted. Expanded into the script the way # they were, a space or a shell metacharacter in either value is parsed @@ -585,26 +604,57 @@ jobs: set -euo pipefail curl --fail --show-error --location -u "$BASIC_AUTH" -o android/app/app.key "${URL_PREFIX}app.key" curl --fail --show-error --location -u "$BASIC_AUTH" -o android/key.properties "${URL_PREFIX}key.properties" - - name: Patch JNI build-id - run: | - flutter pub get - scripts/release/patch-jni-build-id.sh - # The Linux userland the terminal tab offers. Not in the repository — - # these are binaries, and they are built from pinned upstream sources - # here. Without this step the feature is absent from the build rather - # than broken in it, which is why the check below exists. - - name: Build proot - if: matrix.abi == 'arm64' - shell: bash - run: scripts/build-proot-android.sh - - name: Build (release) - if: needs.reproducibilityCheck.outputs.isRelease == 'true' - shell: bash - run: dart run fl_build -bp -p android -- --target-platform=${{ matrix.target }} - - name: Build (test) - if: needs.reproducibilityCheck.outputs.isRelease != 'true' + # Signing adds a signing block and touches no entry, which is what lets + # F-Droid copy the signature onto its own build to compare the two. + # `--alignment-preserved` keeps the entries where Gradle put them; the + # check after it fails the job if any moved or changed. + - name: Sign shell: bash - run: dart run fl_build -p android -- --target-platform=${{ matrix.target }} + env: + ABI: ${{ matrix.abi }} + run: | + set -euo pipefail + case "$ABI" in + arm64) unsigned=app-arm64-v8a-release-unsigned.apk ;; + arm) unsigned=app-armeabi-v7a-release-unsigned.apk ;; + amd64) unsigned=app-x86_64-release-unsigned.apk ;; + *) echo "::error::unknown ABI $ABI"; exit 1 ;; + esac + unsigned="build/app/outputs/apk/release/$unsigned" + out="build/app/outputs/flutter-apk/${APP_NAME}_v1.0.${BUILD_NUMBER}_${ABI}.apk" + + prop() { + sed -n -E "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*(.*)$/\1/p" android/key.properties | + tail -n 1 | tr -d '\r' + } + store_file="$(prop storeFile)" + key_alias="$(prop keyAlias)" + KS_PASS="$(prop storePassword)" + KEY_PASS="$(prop keyPassword)" + echo "::add-mask::$KS_PASS" + echo "::add-mask::$KEY_PASS" + export KS_PASS KEY_PASS + # Relative to the app module, as Gradle's `file()` resolved it. + [[ "$store_file" == /* ]] || store_file="android/app/$store_file" + + mkdir -p "$(dirname "$out")" + "$APKSIGNER" sign \ + --ks "$store_file" \ + --ks-key-alias "$key_alias" \ + --ks-pass env:KS_PASS \ + --key-pass env:KEY_PASS \ + --alignment-preserved \ + --out "$out" \ + "$unsigned" + "$APKSIGNER" verify "$out" + + entries() { + zipinfo -v "$1" | grep -E '^ (offset of local header|32-bit CRC value|compressed size)' + } + diff <(entries "$unsigned") <(entries "$out") || + { echo "::error::signing changed the APK's entries"; exit 1; } + # Flutter's copies of the unsigned APKs, which are not published. + rm -f build/app/outputs/flutter-apk/app-*-release.apk # `useLegacyPackaging` off means nothing is extracted from the APK, so # `nativeLibraryDir` does not exist and proot cannot be run from # anywhere. The APK still builds, installs and runs — the rootfs entry @@ -622,15 +672,6 @@ jobs: unzip -l "$apk" | grep -q 'lib/arm64-v8a/libproot-loader.so' || { echo "$apk carries proot but not its loader; entering the rootfs would be refused"; exit 1; } done - - name: Rename - shell: bash - run: | - cd build/app/outputs/flutter-apk - for f in *_"${BUILD_NUMBER}"_*.apk; do - mv "$f" "${f/_${BUILD_NUMBER}_/_v1.0.${BUILD_NUMBER}_}" - done - # Remove original APKs copied by fl_build - rm -f app-*-release.apk # Each matrix leg names one exact APK. Besides making a missing ABI fail # at its source, this keeps the release build invocation identical to the # one F-Droid runs for that ABI. From 8299c8d550ff0d5bafd64ab1320f774bdfb011e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:28:34 +0800 Subject: [PATCH 2/3] fix(ci): sign with v2 alone, as Gradle did --- .github/workflows/build.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2a835e3852..0fcc7cd3f0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -607,7 +607,9 @@ jobs: # Signing adds a signing block and touches no entry, which is what lets # F-Droid copy the signature onto its own build to compare the two. # `--alignment-preserved` keeps the entries where Gradle put them; the - # check after it fails the job if any moved or changed. + # check after it fails the job if any moved or changed. v2 alone, as Gradle + # signed: apksigner also adds a JAR (v1) signature by default, which is + # three more entries. - name: Sign shell: bash env: @@ -644,6 +646,10 @@ jobs: --ks-pass env:KS_PASS \ --key-pass env:KEY_PASS \ --alignment-preserved \ + --v1-signing-enabled false \ + --v2-signing-enabled true \ + --v3-signing-enabled false \ + --v4-signing-enabled false \ --out "$out" \ "$unsigned" "$APKSIGNER" verify "$out" From b7b3a6fe94336d7ca3727743754d9bb6bc015035 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:08:35 +0800 Subject: [PATCH 3/3] rm: fdroid/README.md, the F-Droid recipe lives in fdroiddata --- fdroid/README.md | 183 ------------------------------ scripts/release/prepare-fdroid.sh | 2 +- 2 files changed, 1 insertion(+), 184 deletions(-) delete mode 100644 fdroid/README.md diff --git a/fdroid/README.md b/fdroid/README.md deleted file mode 100644 index 4dc4040e48..0000000000 --- a/fdroid/README.md +++ /dev/null @@ -1,183 +0,0 @@ -# F-Droid release recipe - -The canonical metadata lives in -[`fdroiddata/metadata/tech.lolli.toolbox.yml`](https://gitlab.com/fdroid/fdroiddata/-/blob/master/metadata/tech.lolli.toolbox.yml), -not in this repository. Submit an fdroiddata update only after a -`v1.0.` tag and its GitHub release APKs exist; before that, neither the immutable commit nor the -reference binaries can be named. - -## Pinned inputs - -- Flutter is read from `.github/actions/setup-flutter/action.yml`. Do not copy - the version into F-Droid metadata without checking that file. -- Android NDK r28c is pinned as `28.2.13676358` in - `android/ndk-version.txt`. -- Android compile SDK 36 and SDK Build Tools 35.0.0 are pinned in - `android/compile-sdk-version.txt` and `android/build-tools-version.txt`. -- The app's Rust native asset pins its compiler and targets in - `crates/sbm_ffi/rust-toolchain.toml`. -- `pubspec.lock`, `Cargo.lock`, Gradle's wrapper, every git dependency, and all - submodules must remain locked at the release commit. -- proot and talloc are fetched, authenticated, and built by - `scripts/build-proot-android.sh` during `build` (arm64 only). - -## Build phases - -F-Droid's scanner runs after `prebuild`, and nothing may be compiled before it. -So `prebuild` only runs `flutter pub get` into an in-tree `.pub-cache` (the -scanner has to see those packages), and `build` runs -`scripts/release/build-fdroid.sh ` once. That script sets the pinned -build environment, patches jni, builds proot and talloc for arm64, prunes -dev-only plugins, and checks the APK set; a plain `flutter build apk` does not -match the release. - -Rust is not fetched in `prebuild`: rustup installs the toolchain and targets -from `crates/sbm_ffi/rust-toolchain.toml` when the build hook first runs. - -`scripts/release/prepare-fdroid.sh` and `FDROID_OFFLINE=true` are for -`android-reproducible.yml` only, which proves the build fetches nothing. The -preparation compiles, so it has no place in an F-Droid recipe. - -## ABI and version-code mapping - -| F-Droid build argument | Flutter target | APK filename | versionCode | -| --- | --- | --- | --- | -| `amd64` | `android-x64` | `app-x86_64-release-unsigned.apk` | `01` | -| `arm` | `android-arm` | `app-armeabi-v7a-release-unsigned.apk` | `02` | -| `arm64` | `android-arm64` | `app-arm64-v8a-release-unsigned.apk` | `03` | - -The output directory is `build/app/outputs/apk/release/`. Flutter also copies -the same APKs to `build/app/outputs/flutter-apk/`, but drops `-unsigned` from -their filenames; the recipe deliberately uses Gradle's unambiguously named -originals. These codes match -`android/app/build.gradle` and the existing `VercodeOperation` entries in -fdroiddata. - -## Metadata template for the next tag - -Replace every `` with the numeric suffix of the final tag and every -`` with that tag's full commit SHA. Add all three records to the -existing metadata file. Keep `binary:` enabled: it is the F-Droid check that -the unsigned rebuild differs from the published APK only by the allowed -signature. - -```yaml - - versionName: 1.0. - versionCode: 01 - commit: - submodules: true - sudo: - - apt-get update - - apt-get install -y zip unzip make rustup gcc libc-dev - output: build/app/outputs/apk/release/app-x86_64-release-unsigned.apk - binary: - https://github.com/lollipopkit/flutter_server_box/releases/download/v%v/ServerBox_v%v_amd64.apk - srclibs: - - flutter@stable - rm: - - ios - - linux - - macos - - test - - windows - prebuild: - - flutterVersion=$(sed -n -E 's/^[[:space:]]*flutter-version:[[:space:]]*([^[:space:]]+).*/\1/p' - .github/actions/setup-flutter/action.yml) - - '[[ $flutterVersion ]]' - - git -C $$flutter$$ checkout -f $flutterVersion - - export PATH=$$flutter$$/bin:$PATH - - export PUB_CACHE=$(pwd)/.pub-cache - - flutter config --no-analytics - - flutter pub get --enforce-lockfile - scandelete: - - .pub-cache - - packages/xterm/example/assets/specs_v1.json.gz - - third_party/ish-arm64/benchmark/assets - build: - - export PATH=$$flutter$$/bin:$PATH - - export PUB_CACHE=$(pwd)/.pub-cache - - scripts/release/build-fdroid.sh amd64 - target: android-36 - ndk: r28c - - - versionName: 1.0. - versionCode: 02 - commit: - submodules: true - sudo: - - apt-get update - - apt-get install -y zip unzip make rustup gcc libc-dev - output: build/app/outputs/apk/release/app-armeabi-v7a-release-unsigned.apk - binary: - https://github.com/lollipopkit/flutter_server_box/releases/download/v%v/ServerBox_v%v_arm.apk - srclibs: - - flutter@stable - rm: - - ios - - linux - - macos - - test - - windows - prebuild: - - flutterVersion=$(sed -n -E 's/^[[:space:]]*flutter-version:[[:space:]]*([^[:space:]]+).*/\1/p' - .github/actions/setup-flutter/action.yml) - - '[[ $flutterVersion ]]' - - git -C $$flutter$$ checkout -f $flutterVersion - - export PATH=$$flutter$$/bin:$PATH - - export PUB_CACHE=$(pwd)/.pub-cache - - flutter config --no-analytics - - flutter pub get --enforce-lockfile - scandelete: - - .pub-cache - - packages/xterm/example/assets/specs_v1.json.gz - - third_party/ish-arm64/benchmark/assets - build: - - export PATH=$$flutter$$/bin:$PATH - - export PUB_CACHE=$(pwd)/.pub-cache - - scripts/release/build-fdroid.sh arm - target: android-36 - ndk: r28c - - - versionName: 1.0. - versionCode: 03 - commit: - submodules: true - sudo: - - apt-get update - - apt-get install -y zip unzip make rustup gcc libc-dev - output: build/app/outputs/apk/release/app-arm64-v8a-release-unsigned.apk - binary: - https://github.com/lollipopkit/flutter_server_box/releases/download/v%v/ServerBox_v%v_arm64.apk - srclibs: - - flutter@stable - rm: - - ios - - linux - - macos - - test - - windows - prebuild: - - flutterVersion=$(sed -n -E 's/^[[:space:]]*flutter-version:[[:space:]]*([^[:space:]]+).*/\1/p' - .github/actions/setup-flutter/action.yml) - - '[[ $flutterVersion ]]' - - git -C $$flutter$$ checkout -f $flutterVersion - - export PATH=$$flutter$$/bin:$PATH - - export PUB_CACHE=$(pwd)/.pub-cache - - flutter config --no-analytics - - flutter pub get --enforce-lockfile - scandelete: - - .pub-cache - - packages/xterm/example/assets/specs_v1.json.gz - - third_party/ish-arm64/benchmark/assets - build: - - export PATH=$$flutter$$/bin:$PATH - - export PUB_CACHE=$(pwd)/.pub-cache - - scripts/release/build-fdroid.sh arm64 - target: android-36 - ndk: r28c -``` - -Before opening the fdroiddata MR, confirm that all three GitHub release APK -names above exist. A published release has already passed -`Android reproducible build`, which gates publication. F-Droid's `binary:` comparison remains -the final cross-environment reproducibility verdict. diff --git a/scripts/release/prepare-fdroid.sh b/scripts/release/prepare-fdroid.sh index 0b75db3ff1..e290ea7b89 100755 --- a/scripts/release/prepare-fdroid.sh +++ b/scripts/release/prepare-fdroid.sh @@ -3,7 +3,7 @@ # `FDROID_OFFLINE=true build-fdroid.sh` can prove nothing is fetched during the # build. Used by android-reproducible.yml only: it compiles (proot and one full # release build to seed Gradle's cache), so it must not run in an F-Droid -# `prebuild`. F-Droid's recipe is in fdroid/README.md. +# `prebuild`. set -euo pipefail