diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54d33373c..713ad9c54 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/devDebug + echo "$GOOGLE_SERVICES_JSON_BASE64" | base64 -d > app/src/devDebug/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 be8f9b14d..6bf0e9006 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/devDebug + echo "$GOOGLE_SERVICES_JSON_BASE64" | base64 -d > app/src/devDebug/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/devDebug + echo "$GOOGLE_SERVICES_JSON_BASE64" | base64 -d > app/src/devDebug/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 373f3236e..cceeed318 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/devDebug + echo "$GOOGLE_SERVICES_JSON_BASE64" | base64 -d > app/src/devDebug/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 000000000..5d20590b6 --- /dev/null +++ b/.github/workflows/release-internal.yml @@ -0,0 +1,87 @@ +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: 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 000000000..c938dc84d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,89 @@ +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: 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 000000000..122a29aa4 --- /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 c6da4ed2e..2b5c19bd7 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/devDebug + echo "$GOOGLE_SERVICES_JSON_BASE64" | base64 -d > app/src/devDebug/google-services.json env: GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }} diff --git a/.gitignore b/.gitignore index 1700753e3..68274ce50 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ local.properties !*.local.template* # Secrets google-services.json +!app/src/devDebug/google-services.json +!app/src/mainnetDebug/google-services.json +!app/src/tnetDebug/google-services.json .env *.keystore !debug.keystore diff --git a/README.md b/README.md index 29c01e91d..c8effde6b 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,31 @@ 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` +Debug builds include placeholder Firebase configuration so a fresh clone can compile without private `google-services.json` files. + +Download `google-services.json` from the Firebase Console when you need real Firebase integration for push notifications testing: +- devDebug: Place in `app/src/devDebug/google-services.json` +- tnetDebug: Place in `app/src/tnetDebug/google-services.json` +- mainnetDebug: Place in `app/src/mainnetDebug/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 files above are tracked placeholders. Before replacing a debug placeholder with a real Firebase config, hide that local-only change from Git: + +```sh +git update-index --skip-worktree app/src/devDebug/google-services.json +cp /secure/path/google-services.json app/src/devDebug/google-services.json +``` + +Repeat with the `tnetDebug` or `mainnetDebug` path when testing those debug variants. + +When you are done testing, restore the tracked placeholder: + +```sh +git update-index --no-skip-worktree app/src/devDebug/google-services.json +git restore app/src/devDebug/google-services.json +``` + +> **Note**: Placeholder debug configs are only for local builds. FCM token registration and push notifications require real Firebase configuration. The mainnet release flavor will fail to build without the `mainnetRelease/google-services.json` file. #### 2. GitHub Packages setup @@ -168,6 +188,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/src/devDebug/google-services.json b/app/src/devDebug/google-services.json new file mode 100644 index 000000000..bb4b217cf --- /dev/null +++ b/app/src/devDebug/google-services.json @@ -0,0 +1,29 @@ +{ + "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": [] + } + } + } + ], + "configuration_version": "1" +} diff --git a/app/src/mainnetDebug/google-services.json b/app/src/mainnetDebug/google-services.json new file mode 100644 index 000000000..9ab09a6fb --- /dev/null +++ b/app/src/mainnetDebug/google-services.json @@ -0,0 +1,29 @@ +{ + "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:0000000000000000000001", + "android_client_info": { + "package_name": "to.bitkit" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyDebugPlaceholderKeyForLocalBuilds001" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} diff --git a/app/src/tnetDebug/google-services.json b/app/src/tnetDebug/google-services.json new file mode 100644 index 000000000..63fecea25 --- /dev/null +++ b/app/src/tnetDebug/google-services.json @@ -0,0 +1,29 @@ +{ + "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: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 000000000..549d8a6bd --- /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 000000000..4a1f35ac6 --- /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