diff --git a/.agents/commands/release.md b/.agents/commands/release.md index 532790c65d..ede096e48f 100644 --- a/.agents/commands/release.md +++ b/.agents/commands/release.md @@ -210,16 +210,20 @@ just release Expected APK path: `app/build/outputs/apk/mainnet/release/bitkit-mainnet-release-{newVersionCode}-universal.apk` Expected AAB path: `app/build/outputs/bundle/mainnetRelease/bitkit-mainnet-release-{newVersionCode}.aab` +Expected native debug symbols path: `app/build/outputs/native-debug-symbols/mainnetRelease/native-debug-symbols.zip` -Verify both files exist. If the build fails, stop and report the error to the user. +Verify all three files exist. The native debug symbols file must be from the same `just release` build as the APK/AAB. Keep the filename `native-debug-symbols.zip`. If Android Gradle Plugin cannot emit a usable archive because native dependency metadata is already stripped, `just release` fails instead of creating a placeholder zip from stripped `.so` files. Stop the release and publish or consume native dependencies with usable debug metadata first. -### 8. Upload APK to Draft Release +### 8. Upload APK and Native Symbols to Draft Release ```bash gh release upload v{newVersionName} \ - app/build/outputs/apk/mainnet/release/bitkit-mainnet-release-{newVersionCode}-universal.apk + app/build/outputs/apk/mainnet/release/bitkit-mainnet-release-{newVersionCode}-universal.apk \ + app/build/outputs/native-debug-symbols/mainnetRelease/native-debug-symbols.zip ``` +For the Play Store release, upload the AAB as usual, then upload `native-debug-symbols.zip` for the exact version/build in Play Console: App bundle explorer → Downloads → Assets. Verify Play lists the native debug symbols after upload. Keep the release-built archive in GitHub releases or internal release storage; Play Console may only show delete/replace controls after upload, which is enough for release verification. + ### 9. Return to Master ```bash @@ -236,6 +240,7 @@ Release branch: release-{newVersionName} Tag: v{newVersionName} Draft release: {release URL} APK uploaded: bitkit-mainnet-release-{newVersionCode}-universal.apk +Native debug symbols uploaded: native-debug-symbols.zip Store release notes: .ai/release-notes-{newVersionName}.md Next steps: diff --git a/Justfile b/Justfile index a0b861ea27..aa85be1fde 100644 --- a/Justfile +++ b/Justfile @@ -137,7 +137,13 @@ build task="assembleDevDebug": {{ gradle }} {{ task }} release: + #!/usr/bin/env sh + set -eu + symbols="app/build/outputs/native-debug-symbols/mainnetRelease/native-debug-symbols.zip" + rm -f "$symbols" {{ gradle }} assembleMainnetRelease bundleMainnetRelease + scripts/create-native-debug-symbols.sh + echo "Attach this exact file to GitHub releases, upload it to Play Console for this release, and verify Play lists it." install: {{ gradle }} installDevDebug diff --git a/README.md b/README.md index 8f1fd45e7a..c45e93ffd1 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,22 @@ To build the mainnet flavor for release run: just release ``` +`just release` builds the mainnet APK, Play Store AAB, and validates the native debug symbols archive. + +Release artifacts: + +- APK: `app/build/outputs/apk/mainnet/release/` +- AAB: `app/build/outputs/bundle/mainnetRelease/` +- Native debug symbols: `app/build/outputs/native-debug-symbols/mainnetRelease/native-debug-symbols.zip` + +The native debug symbols archive must come from the same `just release` build as the APK/AAB being published. Keep the filename `native-debug-symbols.zip`. If Android Gradle Plugin cannot emit a usable archive because native dependency metadata is already stripped, `just release` fails instead of creating a placeholder zip from stripped `.so` files. Stop the release and publish or consume native dependencies with usable debug metadata first. + +For Play Store releases, upload the AAB as usual, then upload `native-debug-symbols.zip` for that exact version/build in Play Console: App bundle explorer → Downloads → Assets. Verify Play lists the native debug symbols after upload. + +Keep the release-built `native-debug-symbols.zip` in GitHub releases or internal release storage. Play Console may only show delete/replace controls after upload, which is enough for release verification. + +For GitHub releases, attach `native-debug-symbols.zip` alongside the APK so native crashes from GitHub-distributed builds can be symbolicated later. + #### Android App Bundle (AAB) `just release` builds both the mainnet APK and Play Store AAB. AAB is generated in `app/build/outputs/bundle/mainnetRelease/`. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5542ddbdee..01771b6db1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -230,6 +230,7 @@ android { ) signingConfig = signingConfigs.getByName("release") ndk { + debugSymbolLevel = "FULL" // noinspection ChromeOsAbiSupport abiFilters += listOf("armeabi-v7a", "arm64-v8a") } diff --git a/app/src/test/java/to/bitkit/build/NativeReleaseConfigTest.kt b/app/src/test/java/to/bitkit/build/NativeReleaseConfigTest.kt new file mode 100644 index 0000000000..089e1bfdb0 --- /dev/null +++ b/app/src/test/java/to/bitkit/build/NativeReleaseConfigTest.kt @@ -0,0 +1,118 @@ +package to.bitkit.build + +import kotlin.io.path.Path +import kotlin.io.path.exists +import kotlin.io.path.readText +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class NativeReleaseConfigTest { + + private val repoRoot = generateSequence( + Path(requireNotNull(System.getProperty("user.dir")) { "user.dir is required" }), + ) { it.parent } + .first { it.resolve("gradle/libs.versions.toml").exists() } + + @Test + fun `release build requests full native debug symbols`() { + val buildFile = repoRoot.resolve("app/build.gradle.kts").readText() + + assertTrue( + buildFile.contains("""debugSymbolLevel = "FULL""""), + "Release builds must request full native debug symbols for Play crash symbolication.", + ) + } + + @Test + fun `release recipe verifies native debug symbols archive`() { + val justfile = repoRoot.resolve("Justfile").readText() + + assertTrue( + justfile.contains( + """rm -f "${'$'}symbols"""", + ), + "Release builds must remove stale native debug symbols before rebuilding.", + ) + assertTrue( + justfile.contains("scripts/create-native-debug-symbols.sh"), + "Release builds must create the native debug symbols archive before publishing.", + ) + assertTrue( + justfile.contains("Attach this exact file to GitHub releases"), + "Release builds must tell the releaser to attach native debug symbols.", + ) + assertTrue( + justfile.contains("upload it to Play Console for this release"), + "Release builds must tell the releaser to upload native debug symbols to Play.", + ) + assertFalse( + justfile.contains("download"), + "Release builds should keep native debug symbols in release storage.", + ) + } + + @Test + fun `release command uploads native debug symbols archive`() { + val releaseCommand = repoRoot.resolve(".agents/commands/release.md").readText() + + assertTrue( + releaseCommand.contains( + "app/build/outputs/native-debug-symbols/mainnetRelease/native-debug-symbols.zip", + ), + "Release command must include the native debug symbols archive path.", + ) + assertTrue( + releaseCommand.contains("Native debug symbols uploaded: native-debug-symbols.zip"), + "Release command summary must report the native debug symbols archive.", + ) + assertFalse( + releaseCommand.contains("Play " + "did not"), + "Release command should use current Play native symbol wording.", + ) + assertTrue( + releaseCommand.contains("fails instead of creating a placeholder zip from stripped `.so` files"), + "Release command must fail instead of publishing fake native debug symbols.", + ) + assertTrue( + releaseCommand.contains("Play Console may only show delete/replace controls"), + "Release command must document the verified Play Console behavior.", + ) + } + + @Test + fun `native debug symbols script rejects stripped release libraries`() { + val symbolsScript = repoRoot.resolve("scripts/create-native-debug-symbols.sh").readText() + + assertTrue( + symbolsScript.contains( + "app/build/outputs/native-debug-symbols/${'$'}variant/native-debug-symbols.zip", + ), + "Native debug symbols script must write the canonical archive path.", + ) + assertTrue( + symbolsScript.contains("arm64-v8a armeabi-v7a"), + "Native debug symbols script must archive Play release ABIs.", + ) + assertTrue( + symbolsScript.contains("zip -qr"), + "Native debug symbols script must create a zip archive.", + ) + assertTrue( + symbolsScript.contains("""required_libs="libbitkitcore.so libldk_node.so libvss_rust_client_ffi.so""""), + "Native debug symbols script must validate Rust native libraries.", + ) + assertTrue( + symbolsScript.contains("""archive_symbol_suffixes=".dbg .sym""""), + "Native debug symbols script must accept AGP native debug symbol entry suffixes.", + ) + assertTrue( + symbolsScript.contains("""grep -Eq '\.(symtab|debug_|gnu_debugdata)'"""), + "Native debug symbols script must validate usable debug metadata before zipping.", + ) + assertTrue( + symbolsScript.contains("Refusing to create '${'$'}output' from stripped native libraries."), + "Native debug symbols script must refuse placeholder archives.", + ) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e8659a9550..f2ec409608 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } -bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.67" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.70" } paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc8" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } @@ -64,7 +64,7 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } -ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.46" } +ldk-node-android = { module = "com.synonym:ldk-node-android", version = "0.7.0-rc.48" } lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } @@ -88,7 +88,7 @@ test-junit-ext = { module = "androidx.test.ext:junit", version = "1.3.0" } test-mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version = "6.2.2" } test-robolectric = { module = "org.robolectric:robolectric", version = "4.16.1" } test-turbine = { group = "app.cash.turbine", name = "turbine", version = "1.2.1" } -vss-client = { module = "com.synonym:vss-client-android", version = "0.5.12" } +vss-client = { module = "com.synonym:vss-client-android", version = "0.5.17" } work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11.0" } zxing = { module = "com.google.zxing:core", version = "3.5.4" } lottie = { module = "com.airbnb.android:lottie-compose", version = "6.7.1" } diff --git a/scripts/create-native-debug-symbols.sh b/scripts/create-native-debug-symbols.sh new file mode 100755 index 0000000000..f1ac99b2d8 --- /dev/null +++ b/scripts/create-native-debug-symbols.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env sh +set -eu + +script_dir=$(cd "$(dirname "$0")" && pwd) +repo_root=$(cd "$script_dir/.." && pwd) +cd "$repo_root" + +variant="mainnetRelease" +output="app/build/outputs/native-debug-symbols/$variant/native-debug-symbols.zip" +output_dir=$(dirname "$output") +required_libs="libbitkitcore.so libldk_node.so libvss_rust_client_ffi.so" +archive_symbol_suffixes=".dbg .sym" + +tmp_dirs="" +cleanup() { + for dir in $tmp_dirs; do + rm -rf "$dir" + done +} +trap cleanup EXIT + +make_tmp_dir() { + dir=$(mktemp -d) + tmp_dirs="$tmp_dirs $dir" + echo "$dir" +} + +find_readelf() { + for sdk_dir in "${ANDROID_NDK_HOME:-}" "${ANDROID_HOME:-}/ndk" "${ANDROID_SDK_ROOT:-}/ndk"; do + if [ -z "$sdk_dir" ]; then + continue + fi + + for candidate in \ + "$sdk_dir"/toolchains/llvm/prebuilt/*/bin/llvm-readelf \ + "$sdk_dir"/*/toolchains/llvm/prebuilt/*/bin/llvm-readelf; do + if [ -x "$candidate" ]; then + echo "$candidate" + return + fi + done + done + + if command -v llvm-readelf >/dev/null 2>&1; then + command -v llvm-readelf + return + fi + + if command -v readelf >/dev/null 2>&1; then + command -v readelf + return + fi + + echo "llvm-readelf or readelf is required to validate native debug symbols." >&2 + exit 1 +} + +readelf_bin=$(find_readelf) + +has_debug_metadata() { + "$readelf_bin" -S "$1" | grep -Eq '\.(symtab|debug_|gnu_debugdata)' +} + +validate_symbol_tree() { + root="$1" + + for abi in arm64-v8a armeabi-v7a; do + for lib_name in $required_libs; do + lib="$root/$abi/$lib_name" + if [ ! -f "$lib" ]; then + echo "Missing required native symbol library '$abi/$lib_name'." >&2 + exit 1 + fi + + if ! has_debug_metadata "$lib"; then + echo "Native debug symbols unavailable: '$abi/$lib_name' has no .symtab, .debug_*, or .gnu_debugdata sections." >&2 + echo "Refusing to create '$output' from stripped native libraries." >&2 + echo "Publish or consume native dependencies with usable debug metadata before releasing." >&2 + exit 1 + fi + done + done +} + +extract_archive_lib() { + archive="$1" + tmp_dir="$2" + abi="$3" + lib_name="$4" + + entry="$abi/$lib_name" + if unzip -q "$archive" "$entry" -d "$tmp_dir" 2>/dev/null; then + return + fi + + for suffix in $archive_symbol_suffixes; do + entry="$abi/$lib_name$suffix" + if unzip -q "$archive" "$entry" -d "$tmp_dir" 2>/dev/null; then + mv "$tmp_dir/$entry" "$tmp_dir/$abi/$lib_name" + return + fi + done + + echo "Native debug symbols archive is missing '$abi/$lib_name' or accepted AGP variants '$abi/$lib_name.dbg' / '$abi/$lib_name.sym'." >&2 + exit 1 +} + +validate_output_zip() { + archive="$1" + zip -T "$archive" >/dev/null + + tmp_dir=$(make_tmp_dir) + for abi in arm64-v8a armeabi-v7a; do + for lib_name in $required_libs; do + extract_archive_lib "$archive" "$tmp_dir" "$abi" "$lib_name" + done + done + + validate_symbol_tree "$tmp_dir" +} + +if [ -f "$output" ]; then + validate_output_zip "$output" + echo "Native debug symbols: $output" + ls -lh "$output" + exit 0 +fi + +native_lib_dir="" +for candidate in "app/build/intermediates/merged_native_libs/$variant"/*/out/lib; do + if [ -d "$candidate" ]; then + native_lib_dir="$candidate" + break + fi +done + +if [ -z "$native_lib_dir" ]; then + echo "No merged native libraries found for '$variant'." >&2 + exit 1 +fi + +tmp_dir=$(make_tmp_dir) + +for abi in arm64-v8a armeabi-v7a; do + source_dir="$native_lib_dir/$abi" + if [ ! -d "$source_dir" ]; then + echo "Missing native libraries for '$abi' in '$native_lib_dir'." >&2 + exit 1 + fi + + mkdir -p "$tmp_dir/$abi" + found_lib=false + for lib in "$source_dir"/*.so; do + if [ -f "$lib" ]; then + cp "$lib" "$tmp_dir/$abi/" + found_lib=true + fi + done + + if [ "$found_lib" = false ]; then + echo "No native libraries found for '$abi' in '$source_dir'." >&2 + exit 1 + fi +done + +validate_symbol_tree "$tmp_dir" + +mkdir -p "$output_dir" +rm -f "$output" + +( + cd "$tmp_dir" + zip -qr "$repo_root/$output" arm64-v8a armeabi-v7a +) + +zip -T "$output" >/dev/null +echo "Native debug symbols: $output" +ls -lh "$output"