From 13528fa0f904c988af6771a8ae4ff0301b33d03b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 25 May 2026 14:53:15 +0200 Subject: [PATCH] ci: add release workflows --- .github/workflows/ci.yml | 4 +- .github/workflows/e2e.yml | 8 +- .github/workflows/e2e_migration.yml | 4 +- .github/workflows/release-internal.yml | 102 ++++++++++++ .github/workflows/release.yml | 126 ++++++++++++++ .github/workflows/reproducible-release.yml | 119 ++++++++++++++ .github/workflows/ui-tests.yml | 4 +- .gitignore | 2 + README.md | 12 +- app/google-services.json | 48 ++++++ docs/reproducible-builds.md | 105 ++++++++++++ scripts/reproduce-release.sh | 182 +++++++++++++++++++++ settings.gradle.kts | 6 +- 13 files changed, 712 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/release-internal.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/reproducible-release.yml create mode 100644 app/google-services.json create mode 100644 docs/reproducible-builds.md create mode 100755 scripts/reproduce-release.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54d33373cd..1488564c52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,9 @@ jobs: uses: gradle/actions/setup-gradle@v5 - name: Decode google-services.json - run: echo "$GOOGLE_SERVICES_JSON_BASE64" | base64 -d > app/google-services.json + run: | + mkdir -p app/src/debug + echo "$GOOGLE_SERVICES_JSON_BASE64" | base64 -d > app/src/debug/google-services.json env: GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index be8f9b14d2..3e14497322 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -57,7 +57,9 @@ jobs: uses: gradle/actions/setup-gradle@v5 - name: Decode google-services.json - run: echo "$GOOGLE_SERVICES_JSON_BASE64" | base64 -d > app/google-services.json + run: | + mkdir -p app/src/debug + echo "$GOOGLE_SERVICES_JSON_BASE64" | base64 -d > app/src/debug/google-services.json env: GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }} @@ -100,7 +102,9 @@ jobs: uses: gradle/actions/setup-gradle@v5 - name: Decode google-services.json - run: echo "$GOOGLE_SERVICES_JSON_BASE64" | base64 -d > app/google-services.json + run: | + mkdir -p app/src/debug + echo "$GOOGLE_SERVICES_JSON_BASE64" | base64 -d > app/src/debug/google-services.json env: GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }} diff --git a/.github/workflows/e2e_migration.yml b/.github/workflows/e2e_migration.yml index 373f3236e1..3e29a9e0b4 100644 --- a/.github/workflows/e2e_migration.yml +++ b/.github/workflows/e2e_migration.yml @@ -37,7 +37,9 @@ jobs: uses: gradle/actions/setup-gradle@v5 - name: Decode google-services.json - run: echo "$GOOGLE_SERVICES_JSON_BASE64" | base64 -d > app/google-services.json + run: | + mkdir -p app/src/debug + echo "$GOOGLE_SERVICES_JSON_BASE64" | base64 -d > app/src/debug/google-services.json env: GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }} diff --git a/.github/workflows/release-internal.yml b/.github/workflows/release-internal.yml new file mode 100644 index 0000000000..659dd3f097 --- /dev/null +++ b/.github/workflows/release-internal.yml @@ -0,0 +1,102 @@ +name: Release Internal + +on: + workflow_dispatch: + push: + tags: + - 'v*' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +env: + TERM: xterm-256color + FORCE_COLOR: 1 + +jobs: + build-internal: + if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/release-') || startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: release-internal + + permissions: + contents: read + packages: read + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Java + uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: 'adopt' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + + - name: Decode mainnet release google-services.json + env: + MAINNET_RELEASE_GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.MAINNET_RELEASE_GOOGLE_SERVICES_JSON_BASE64 }} + run: | + set -euo pipefail + test -n "$MAINNET_RELEASE_GOOGLE_SERVICES_JSON_BASE64" + mkdir -p app/src/mainnetRelease + printf '%s' "$MAINNET_RELEASE_GOOGLE_SERVICES_JSON_BASE64" | base64 --decode > app/src/mainnetRelease/google-services.json + + - name: Decode internal keystore + env: + INTERNAL_KEYSTORE_BASE64: ${{ secrets.INTERNAL_KEYSTORE_BASE64 }} + run: | + set -euo pipefail + test -n "$INTERNAL_KEYSTORE_BASE64" + umask 077 + keystore_path="$RUNNER_TEMP/internal.keystore" + printf '%s' "$INTERNAL_KEYSTORE_BASE64" | base64 --decode > "$keystore_path" + echo "KEYSTORE_FILE=$keystore_path" >> "$GITHUB_ENV" + + - name: Build internal release APK + env: + GPR_USER: ${{ secrets.GPR_USER || github.actor }} + GPR_TOKEN: ${{ secrets.GPR_TOKEN || github.token }} + GITHUB_TOKEN: ${{ secrets.GPR_TOKEN || github.token }} + KEYSTORE_PASSWORD: ${{ secrets.INTERNAL_KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.INTERNAL_KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.INTERNAL_KEY_PASSWORD }} + run: ./gradlew assembleMainnetRelease --no-daemon --stacktrace + + - name: Verify internal release signature + run: | + set -euo pipefail + android_sdk_root="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" + test -n "$android_sdk_root" + apksigner_path="$(find "$android_sdk_root/build-tools" -name apksigner -type f | sort -V | tail -n 1)" + test -n "$apksigner_path" + + apk_count=0 + while IFS= read -r -d '' apk_path; do + apk_count=$((apk_count + 1)) + "$apksigner_path" verify --verbose --print-certs "$apk_path" + done < <(find app/build/outputs/apk/mainnet/release -name 'bitkit-mainnet-release-*.apk' -print0) + test "$apk_count" -gt 0 + + - name: Collect internal artifacts + id: artifacts + run: | + set -euo pipefail + artifact_dir="$RUNNER_TEMP/internal-release" + mkdir -p "$artifact_dir" + find app/build/outputs/apk/mainnet/release -name 'bitkit-mainnet-release-*.apk' -print0 | + xargs -0 -I {} cp {} "$artifact_dir/" + (cd "$artifact_dir" && sha256sum *.apk > SHA256SUMS.txt) + echo "artifact_dir=$artifact_dir" >> "$GITHUB_OUTPUT" + + - name: Upload internal artifacts + uses: actions/upload-artifact@v6 + with: + name: bitkit-internal-release-${{ github.run_number }} + path: ${{ steps.artifacts.outputs.artifact_dir }} + retention-days: 30 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..0a990218b9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,126 @@ +name: Release + +on: + workflow_dispatch: + push: + tags: + - 'v*' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +env: + TERM: xterm-256color + FORCE_COLOR: 1 + +jobs: + build-release: + if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/release-') || startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: release + + permissions: + contents: read + packages: read + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Java + uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: 'adopt' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + + - name: Decode mainnet release google-services.json + env: + MAINNET_RELEASE_GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.MAINNET_RELEASE_GOOGLE_SERVICES_JSON_BASE64 }} + run: | + set -euo pipefail + test -n "$MAINNET_RELEASE_GOOGLE_SERVICES_JSON_BASE64" + mkdir -p app/src/mainnetRelease + printf '%s' "$MAINNET_RELEASE_GOOGLE_SERVICES_JSON_BASE64" | base64 --decode > app/src/mainnetRelease/google-services.json + + - name: Decode release keystore + env: + BITKIT_KEYSTORE_BASE64: ${{ secrets.BITKIT_KEYSTORE_BASE64 }} + run: | + set -euo pipefail + test -n "$BITKIT_KEYSTORE_BASE64" + umask 077 + keystore_path="$RUNNER_TEMP/bitkit.keystore" + printf '%s' "$BITKIT_KEYSTORE_BASE64" | base64 --decode > "$keystore_path" + echo "KEYSTORE_FILE=$keystore_path" >> "$GITHUB_ENV" + + - name: Build release artifacts + env: + GPR_USER: ${{ secrets.GPR_USER || github.actor }} + GPR_TOKEN: ${{ secrets.GPR_TOKEN || github.token }} + GITHUB_TOKEN: ${{ secrets.GPR_TOKEN || github.token }} + KEYSTORE_PASSWORD: ${{ secrets.BITKIT_KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.BITKIT_KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.BITKIT_KEY_PASSWORD }} + run: ./gradlew assembleMainnetRelease bundleMainnetRelease --no-daemon --stacktrace + + - name: Verify release signatures + run: | + set -euo pipefail + android_sdk_root="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" + test -n "$android_sdk_root" + apksigner_path="$(find "$android_sdk_root/build-tools" -name apksigner -type f | sort -V | tail -n 1)" + test -n "$apksigner_path" + + apk_count=0 + while IFS= read -r -d '' apk_path; do + apk_count=$((apk_count + 1)) + "$apksigner_path" verify --verbose --print-certs "$apk_path" + done < <(find app/build/outputs/apk/mainnet/release -name 'bitkit-mainnet-release-*.apk' -print0) + test "$apk_count" -gt 0 + + bundle_count=0 + while IFS= read -r -d '' bundle_path; do + bundle_count=$((bundle_count + 1)) + verify_output="$(mktemp)" + if ! jarsigner -verify -verbose -certs "$bundle_path" 2>&1 | tee "$verify_output"; then + rm -f "$verify_output" + exit 1 + fi + if grep -qi "jar is unsigned" "$verify_output"; then + echo "Unsigned bundle: $bundle_path" + rm -f "$verify_output" + exit 1 + fi + if ! grep -qi "jar verified" "$verify_output"; then + echo "Bundle signature verification did not report success: $bundle_path" + rm -f "$verify_output" + exit 1 + fi + rm -f "$verify_output" + done < <(find app/build/outputs/bundle/mainnetRelease -name 'bitkit-mainnet-release-*.aab' -print0) + test "$bundle_count" -gt 0 + + - name: Collect release artifacts + id: artifacts + run: | + set -euo pipefail + artifact_dir="$RUNNER_TEMP/release" + mkdir -p "$artifact_dir" + find app/build/outputs/bundle/mainnetRelease -name 'bitkit-mainnet-release-*.aab' -print0 | + xargs -0 -I {} cp {} "$artifact_dir/" + find app/build/outputs/apk/mainnet/release -name 'bitkit-mainnet-release-*.apk' -print0 | + xargs -0 -I {} cp {} "$artifact_dir/" + (cd "$artifact_dir" && sha256sum *.aab *.apk > SHA256SUMS.txt) + echo "artifact_dir=$artifact_dir" >> "$GITHUB_OUTPUT" + + - name: Upload release artifacts + uses: actions/upload-artifact@v6 + with: + name: bitkit-release-${{ github.run_number }} + path: ${{ steps.artifacts.outputs.artifact_dir }} + retention-days: 30 diff --git a/.github/workflows/reproducible-release.yml b/.github/workflows/reproducible-release.yml new file mode 100644 index 0000000000..122a29aa49 --- /dev/null +++ b/.github/workflows/reproducible-release.yml @@ -0,0 +1,119 @@ +name: Reproducible Release + +on: + workflow_dispatch: + inputs: + comparison_artifact_name: + description: Optional artifact name to compare against with diffoscope + required: false + default: '' + comparison_run_id: + description: Workflow run id that produced the comparison artifact + required: false + default: '' + push: + tags: + - 'v*' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +env: + TERM: xterm-256color + FORCE_COLOR: 1 + +jobs: + reproduce-mainnet: + if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/release-') || startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + timeout-minutes: 60 + environment: release + + permissions: + actions: read + contents: read + packages: read + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Java + uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: 'adopt' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + + - name: Decode mainnet release google-services.json + env: + MAINNET_RELEASE_GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.MAINNET_RELEASE_GOOGLE_SERVICES_JSON_BASE64 }} + run: | + set -euo pipefail + test -n "$MAINNET_RELEASE_GOOGLE_SERVICES_JSON_BASE64" + mkdir -p app/src/mainnetRelease + printf '%s' "$MAINNET_RELEASE_GOOGLE_SERVICES_JSON_BASE64" | base64 --decode > app/src/mainnetRelease/google-services.json + + - name: Decode release keystore + env: + BITKIT_KEYSTORE_BASE64: ${{ secrets.BITKIT_KEYSTORE_BASE64 }} + run: | + set -euo pipefail + test -n "$BITKIT_KEYSTORE_BASE64" + umask 077 + keystore_path="$RUNNER_TEMP/bitkit.keystore" + printf '%s' "$BITKIT_KEYSTORE_BASE64" | base64 --decode > "$keystore_path" + echo "KEYSTORE_FILE=$keystore_path" >> "$GITHUB_ENV" + + - name: Validate comparison inputs + if: ${{ github.event_name == 'workflow_dispatch' && inputs.comparison_artifact_name != '' }} + env: + COMPARISON_RUN_ID: ${{ inputs.comparison_run_id }} + run: | + set -euo pipefail + test -n "$COMPARISON_RUN_ID" + + - name: Download comparison artifact + if: ${{ github.event_name == 'workflow_dispatch' && inputs.comparison_artifact_name != '' }} + uses: actions/download-artifact@v6 + with: + name: ${{ inputs.comparison_artifact_name }} + path: ${{ runner.temp }}/comparison + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ inputs.comparison_run_id }} + + - name: Install diffoscope + if: ${{ github.event_name == 'workflow_dispatch' && inputs.comparison_artifact_name != '' }} + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y diffoscope + + - name: Build reproducibility artifacts + env: + GPR_USER: ${{ secrets.GPR_USER || github.actor }} + GPR_TOKEN: ${{ secrets.GPR_TOKEN || github.token }} + GITHUB_TOKEN: ${{ secrets.GPR_TOKEN || github.token }} + KEYSTORE_PASSWORD: ${{ secrets.BITKIT_KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.BITKIT_KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.BITKIT_KEY_PASSWORD }} + OUTPUT_DIR: ${{ runner.temp }}/reproducible-release + run: | + set -euo pipefail + if [ -d "$RUNNER_TEMP/comparison/extracted-apks" ]; then + export DIFFOSCOPE_COMPARE_DIR="$RUNNER_TEMP/comparison/extracted-apks" + elif [ -d "$RUNNER_TEMP/comparison" ]; then + export DIFFOSCOPE_COMPARE_DIR="$RUNNER_TEMP/comparison" + fi + scripts/reproduce-release.sh + + - name: Upload reproducibility artifacts + uses: actions/upload-artifact@v6 + with: + name: bitkit-reproducible-release-${{ github.run_number }} + path: ${{ runner.temp }}/reproducible-release + retention-days: 30 diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index c6da4ed2ea..f3b7984064 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -41,7 +41,9 @@ jobs: gradle-${{ runner.os }}- - name: Decode google-services.json - run: echo "$GOOGLE_SERVICES_JSON_BASE64" | base64 -d > app/google-services.json + run: | + mkdir -p app/src/debug + echo "$GOOGLE_SERVICES_JSON_BASE64" | base64 -d > app/src/debug/google-services.json env: GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }} diff --git a/.gitignore b/.gitignore index 1700753e3f..6048d58574 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ local.properties !*.local.template* # Secrets google-services.json +# Tracked fallback config used only so debug variants compile from a fresh clone. +!app/google-services.json .env *.keystore !debug.keystore diff --git a/README.md b/README.md index 29c01e91d9..27f51c6fc2 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,15 @@ This repository contains the **native Android app** for Bitkit. #### 1. Firebase Configuration -Download `google-services.json` from the Firebase Console for each of the following build flavor groups,: -- dev/tnet/mainnetDebug: Place in `app/google-services.json` +Dev and testnet debug builds use a checked-in placeholder at `app/google-services.json` so a fresh clone can compile without private Firebase files. + +Download `google-services.json` from the Firebase Console when you need real Firebase integration for push notifications testing: +- Debug builds: Place in `app/src/debug/google-services.json` - mainnetRelease: Place in `app/src/mainnetRelease/google-services.json` -> **Note**: Each flavor requires its own Firebase project configuration. The mainnet flavor will fail to build without its dedicated `google-services.json` file. +The debug file above is ignored by Git and takes precedence over the checked-in placeholder. To use real Firebase integration across debug variants, make sure it includes each application ID you build. + +> **Note**: Placeholder config is only for local dev and testnet debug builds. FCM token registration and push notifications require real Firebase configuration. The mainnet release flavor should always use the real `mainnetRelease/google-services.json` file. #### 2. GitHub Packages setup @@ -168,6 +172,8 @@ For Play Store submission, build an AAB instead of APK: AAB is generated in `app/build/outputs/bundle/mainnetRelease/`. +See [reproducible builds](docs/reproducible-builds.md) for the WalletScrutiny-oriented release reproduction flow. + ### Build for E2E Testing Pass `E2E=true` and build any flavor. By default, E2E uses a local Electrum override. diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 0000000000..2526ef70c3 --- /dev/null +++ b/app/google-services.json @@ -0,0 +1,48 @@ +{ + "project_info": { + "project_number": "000000000000", + "project_id": "bitkit-debug-placeholder", + "storage_bucket": "bitkit-debug-placeholder.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000000", + "android_client_info": { + "package_name": "to.bitkit.dev" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyDebugPlaceholderKeyForLocalBuilds000" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000002", + "android_client_info": { + "package_name": "to.bitkit.tnet" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyDebugPlaceholderKeyForLocalBuilds002" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} diff --git a/docs/reproducible-builds.md b/docs/reproducible-builds.md new file mode 100644 index 0000000000..549d8a6bdf --- /dev/null +++ b/docs/reproducible-builds.md @@ -0,0 +1,105 @@ +# Reproducible builds + +This document captures the current Bitkit Android release reproduction flow for WalletScrutiny-style review. + +## Current release target + +- Flavor/build type: `mainnetRelease` +- Gradle wrapper: `8.13` +- Android Gradle Plugin: `8.13.2` +- Java: `17` +- Compile SDK: `36` +- Bundletool: `1.18.1` +- AAB output: `app/build/outputs/bundle/mainnetRelease/` +- APK output: `app/build/outputs/apk/mainnet/release/` + +The release build needs the private mainnet Firebase config at `app/src/mainnetRelease/google-services.json` and release signing material. CI writes those files from protected GitHub environment secrets; external verifiers need an equivalent file from the project or an agreed public/non-secret release config strategy. + +## Local reproduction + +Configure GitHub Packages credentials without committing secrets: + +```sh +export GITHUB_ACTOR=YOUR_GITHUB_USERNAME +export GITHUB_TOKEN=YOUR_READ_PACKAGES_TOKEN +``` + +Configure release signing without `keystore.properties`: + +```sh +export KEYSTORE_FILE=/absolute/path/to/bitkit.keystore +export KEYSTORE_PASSWORD=... +export KEY_ALIAS=... +export KEY_PASSWORD=... +``` + +Build the mainnet release bundle and recreate APK splits: + +```sh +scripts/reproduce-release.sh +``` + +The script writes artifacts under `.ai/reproducible-release/` by default: + +- `artifacts/*.aab` +- `artifacts/*.apks` +- `extracted-apks/` +- `checksums/release-artifacts.sha256` +- `checksums/extracted-apks.sha256` +- `checksums/arm64-native-libs.sha256` +- `arm64-apks.txt` +- `arm64-native-libs.txt` + +To reuse an existing AAB: + +```sh +SKIP_GRADLE_BUILD=true AAB_PATH=/path/to/bitkit-mainnet-release-181.aab scripts/reproduce-release.sh +``` + +## GitHub workflow + +The `Reproducible Release` workflow builds `bundleMainnetRelease`, recreates APK splits with bundletool, extracts the `arm64-v8a` native libraries, and uploads checksums plus reproduction artifacts. Workflow behavior can only be fully verified after merge because GitHub Actions workflow changes are only active for PRs opened after the workflow change is merged. + +If a comparison artifact from this repository is available in GitHub Actions, pass its artifact name and source workflow run id to the manual workflow inputs. The workflow installs `diffoscope` and writes `diffoscope.html` and `diffoscope.txt` when possible. + +## Manual diffoscope checks + +Compare generated APK splits against a downloaded or previously generated APK set: + +```sh +diffoscope path/to/reference-apks .ai/reproducible-release/extracted-apks \ + --html .ai/reproducible-release/diffoscope.html +``` + +Compare only the arm64 native libraries: + +```sh +diffoscope path/to/reference-native-libs .ai/reproducible-release/native-libs \ + --html .ai/reproducible-release/native-libs-diffoscope.html +``` + +## Known WalletScrutiny issue + +WalletScrutiny issue `synonymdev/bitkit-android#953` previously reported that most release APK contents reproduced, with remaining differences in native libraries inside the `arm64-v8a` split. + +Known mappings: + +- `libbitkitcore.so` and `libpubky_app_specs...so` come from `com.synonym:bitkit-core-android:0.1.58` +- `libdatastore_shared_counter.so` comes from `androidx.datastore:datastore-core:1.2.0` +- `libjnidispatch.so` comes from `net.java.dev.jna:jna:5.18.1` + +The app repository can provide a stable release recipe and artifact checksums. The remaining native reproducibility work is upstream artifact provenance, especially for Rust-produced Android libraries. + +## Upstream native follow-ups + +For Rust/native Android artifacts, the upstream repositories should publish reproducible AAR/native library builds with: + +- pinned Rust toolchain +- pinned Android NDK +- committed `Cargo.lock` +- stable build paths +- `SOURCE_DATE_EPOCH` +- `codegen-units = 1` +- path remapping +- deterministic stripping +- published AAR and native `.so` checksums diff --git a/scripts/reproduce-release.sh b/scripts/reproduce-release.sh new file mode 100755 index 0000000000..4a1f35ac6d --- /dev/null +++ b/scripts/reproduce-release.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(git rev-parse --show-toplevel 2>/dev/null || pwd) +cd "$repo_root" + +output_dir=${OUTPUT_DIR:-.ai/reproducible-release} +bundletool_version=${BUNDLETOOL_VERSION:-1.18.1} +bundletool_sha256=${BUNDLETOOL_SHA256:-a73341a7945abcb0e6b8971c7b1b2801bd765006447ca0d2437a4260d572ceac} +bundletool_url=${BUNDLETOOL_URL:-https://dl.google.com/dl/android/maven2/com/android/tools/build/bundletool/${bundletool_version}/bundletool-${bundletool_version}.jar} +bundletool_jar=${BUNDLETOOL_JAR:-${output_dir}/tools/bundletool-${bundletool_version}.jar} + +artifacts_dir="$output_dir/artifacts" +checksums_dir="$output_dir/checksums" +extracted_dir="$output_dir/extracted-apks" +native_dir="$output_dir/native-libs" + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$@" + else + shasum -a 256 "$@" + fi +} + +sha256_value() { + sha256_file "$1" | awk '{ print $1 }' +} + +checksum_tree() { + local root=$1 + local output=$2 + + mkdir -p "$(dirname "$output")" + if [[ ! -d "$root" ]]; then + : > "$output" + return + fi + + ( + cd "$root" + find . -type f | LC_ALL=C sort | while IFS= read -r file; do + file=${file#./} + sha256_file "$file" + done + ) > "$output" +} + +file_mtime() { + if stat -f %m "$1" >/dev/null 2>&1; then + stat -f %m "$1" + else + stat -c %Y "$1" + fi +} + +latest_file() { + local root=$1 + local pattern=$2 + local latest= + local latest_mtime=0 + local candidate + local candidate_mtime + + while IFS= read -r -d '' candidate; do + candidate_mtime=$(file_mtime "$candidate") + if [[ -z "$latest" || "$candidate_mtime" -gt "$latest_mtime" ]]; then + latest=$candidate + latest_mtime=$candidate_mtime + fi + done < <(find "$root" -type f -name "$pattern" -print0) + + printf '%s\n' "$latest" +} + +download_bundletool() { + if [[ -f "$bundletool_jar" ]]; then + local actual + actual=$(sha256_value "$bundletool_jar") + if [[ "$actual" == "$bundletool_sha256" ]]; then + return + fi + rm -f "$bundletool_jar" + fi + + mkdir -p "$(dirname "$bundletool_jar")" + local tmp + tmp=$(mktemp) + curl --fail --location --silent --show-error "$bundletool_url" --output "$tmp" + + local actual + actual=$(sha256_value "$tmp") + if [[ "$actual" != "$bundletool_sha256" ]]; then + echo "bundletool checksum mismatch: expected '$bundletool_sha256', got '$actual'" >&2 + rm -f "$tmp" + exit 1 + fi + + mv "$tmp" "$bundletool_jar" +} + +signing_args=() +if [[ -n "${KEYSTORE_FILE:-}" ]]; then + : "${KEYSTORE_PASSWORD:?KEYSTORE_PASSWORD is required when KEYSTORE_FILE is set}" + : "${KEY_ALIAS:?KEY_ALIAS is required when KEYSTORE_FILE is set}" + signing_args+=( + "--ks=$KEYSTORE_FILE" + "--ks-pass=pass:$KEYSTORE_PASSWORD" + "--ks-key-alias=$KEY_ALIAS" + "--key-pass=pass:${KEY_PASSWORD:-$KEYSTORE_PASSWORD}" + ) +fi + +rm -rf "$artifacts_dir" "$checksums_dir" "$extracted_dir" "$native_dir" +mkdir -p "$artifacts_dir" "$checksums_dir" "$extracted_dir" "$native_dir" + +if [[ "${SKIP_GRADLE_BUILD:-false}" != "true" ]]; then + ./gradlew bundleMainnetRelease --no-daemon --stacktrace +fi + +aab_path=${AAB_PATH:-} +if [[ -z "$aab_path" ]]; then + aab_path=$(latest_file app/build/outputs/bundle/mainnetRelease 'bitkit-mainnet-release-*.aab') +fi +if [[ ! -f "$aab_path" ]]; then + echo "AAB not found. Set AAB_PATH or run bundleMainnetRelease first." >&2 + exit 1 +fi + +download_bundletool + +aab_name=$(basename "$aab_path") +apks_path="$artifacts_dir/${aab_name%.aab}.apks" +cp "$aab_path" "$artifacts_dir/$aab_name" + +java -jar "$bundletool_jar" build-apks \ + --bundle="$aab_path" \ + --output="$apks_path" \ + --mode=default \ + --overwrite \ + "${signing_args[@]}" + +unzip -q "$apks_path" -d "$extracted_dir" + +find "$extracted_dir" -type f -name '*.apk' | LC_ALL=C sort > "$output_dir/apks.txt" +grep -E 'arm64[-_]v8a|arm64-v8a|arm64_v8a' "$output_dir/apks.txt" > "$output_dir/arm64-apks.txt" || true + +while IFS= read -r apk; do + apk_name=$(basename "$apk" .apk) + mkdir -p "$native_dir/$apk_name" + unzip -q -o "$apk" 'lib/arm64-v8a/*.so' -d "$native_dir/$apk_name" 2>/dev/null || true +done < "$output_dir/apks.txt" + +find "$native_dir" -type f -name '*.so' | LC_ALL=C sort > "$output_dir/arm64-native-libs.txt" + +checksum_tree "$artifacts_dir" "$checksums_dir/release-artifacts.sha256" +checksum_tree "$extracted_dir" "$checksums_dir/extracted-apks.sha256" +checksum_tree "$native_dir" "$checksums_dir/arm64-native-libs.sha256" + +if [[ -n "${DIFFOSCOPE_COMPARE_DIR:-}" && -d "$DIFFOSCOPE_COMPARE_DIR" ]]; then + if command -v diffoscope >/dev/null 2>&1; then + diffoscope "$DIFFOSCOPE_COMPARE_DIR" "$extracted_dir" \ + --html "$output_dir/diffoscope.html" \ + > "$output_dir/diffoscope.txt" || true + else + echo "diffoscope is not installed; skipping comparison." > "$output_dir/diffoscope.txt" + fi +fi + +cat > "$output_dir/README.txt" < { - val user = System.getenv("GITHUB_ACTOR") + val user = System.getenv("GPR_USER") + ?: System.getenv("GITHUB_ACTOR") ?: providers.gradleProperty(userKey).orNull ?: localProperties.getProperty(userKey) - val key = System.getenv("GITHUB_TOKEN") + val key = System.getenv("GPR_TOKEN") + ?: System.getenv("GITHUB_TOKEN") ?: providers.gradleProperty(passKey).orNull ?: localProperties.getProperty(passKey) return user to key