mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-17 05:41:29 +02:00
Flutter writes app-release.{apk,aab} for every build, so each release's
artifact unzips to the same filename. A stale 1.0.3 app-release.aab in
Downloads was uploaded to the 1.0.4 Play release and refused for reusing
versionCode 13. Copy the outputs to hash-bags-<version>-build<N>.* from
pubspec_description.yaml and upload those, refusing if the version line
does not parse.
344 lines
16 KiB
YAML
344 lines
16 KiB
YAML
name: Hash Bags Android build
|
|
|
|
# Triggers:
|
|
# - push to dev/main (validate every commit)
|
|
# - PRs targeting dev/main (gate merges)
|
|
# - manual via workflow_dispatch
|
|
on:
|
|
# Manual-only for now. Trigger via Actions → "Hash Bags Android build"
|
|
# → Run workflow when you want a build. workflow_dispatch runs as the
|
|
# triggering user, so secrets are always available (unlike PR triggers).
|
|
workflow_dispatch:
|
|
|
|
concurrency:
|
|
group: android-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
defaults:
|
|
run:
|
|
shell: bash
|
|
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
container:
|
|
# Same Cake-Labs prebuilt image used for Linux. Bundles Flutter 3.32.0,
|
|
# Android NDK r28, Go 1.24.1, Android SDK, JDK 17, gradle, and the
|
|
# native build deps (boost, openssl, libsodium, libzmq, unbound, icu).
|
|
image: ghcr.io/cake-tech/cake_wallet:debian13-flutter3.32.0-ndkr28-go1.24.1-ruststablenightly
|
|
env:
|
|
BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
|
|
APP_ANDROID_TYPE: cakewallet
|
|
|
|
steps:
|
|
- name: Fix Actions HOME (writeable for ~/.cache + gradle)
|
|
run: echo "HOME=/root" >> $GITHUB_ENV
|
|
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 1
|
|
|
|
- name: Configure git inside the container
|
|
run: |
|
|
git config --global --add safe.directory '*'
|
|
git config --global user.email "ci@suchsoftware.com"
|
|
git config --global user.name "Hash Bags CI"
|
|
|
|
# ---- External prebuilt deps (same approach as Linux workflow) --------
|
|
- name: Fetch prebuilt torch_dart
|
|
run: |
|
|
set -x -e
|
|
pushd scripts
|
|
rm -rf torch_dart torch_dart.tar.gz
|
|
wget -q https://github.com/MrCyjaneK/torch_dart/releases/download/v1.0.17/torch_dart-v1.0.17.tar.gz -O torch_dart.tar.gz
|
|
mkdir torch_dart
|
|
tar -xzf torch_dart.tar.gz -C torch_dart
|
|
rm torch_dart.tar.gz
|
|
popd
|
|
|
|
- name: Fetch prebuilt reown_flutter
|
|
run: |
|
|
set -x -e
|
|
pushd scripts
|
|
rm -rf reown_flutter reown_flutter.tar.gz
|
|
wget -q https://github.com/cake-tech/reown_flutter/releases/download/v0.0.4/reown_flutter-v0.0.4.tar.gz -O reown_flutter.tar.gz
|
|
mkdir reown_flutter
|
|
tar -xzf reown_flutter.tar.gz -C reown_flutter
|
|
rm reown_flutter.tar.gz
|
|
popd
|
|
|
|
- name: Clone BitBox Flutter
|
|
run: |
|
|
set -x -e
|
|
pushd scripts
|
|
./build_bitbox_flutter.sh
|
|
popd
|
|
|
|
# ---- Native crypto cores (monero_c prebuilt bundle) ------------------
|
|
# Same release-bundle.zip as Linux. Contains pre-cross-compiled .so
|
|
# files for monero/wownero/zano across all 4 Android ABIs. We only ship
|
|
# monero + wownero — zano libs in the bundle are ignored.
|
|
- name: Fetch prebuilt monero_c .so bundle
|
|
run: |
|
|
set -x -e
|
|
./scripts/prepare_moneroc.sh
|
|
MONERO_C_TAG=$(cd scripts/monero_c && git describe --tags)
|
|
echo "monero_c TAG: $MONERO_C_TAG"
|
|
mkdir -p "scripts/monero_c/release/$MONERO_C_TAG"
|
|
pushd "scripts/monero_c/release/$MONERO_C_TAG"
|
|
wget -q https://github.com/MrCyjaneK/monero_c/releases/download/v0.18.4.6-RC1/release-bundle.zip
|
|
unzip -q release-bundle.zip
|
|
rm release-bundle.zip
|
|
ls
|
|
popd
|
|
|
|
# ---- Stage native libs into android/app/src/main/jniLibs/<ABI>/ -----
|
|
# Flutter Android packaging auto-includes everything under jniLibs.
|
|
- name: Place Android .so files into jniLibs
|
|
run: |
|
|
set -x -e
|
|
MONERO_C_TAG=$(cd scripts/monero_c && git describe --tags)
|
|
BUNDLE_DIR="scripts/monero_c/release/$MONERO_C_TAG"
|
|
|
|
# monero_c target dir → Android ABI dir. i686 (32-bit x86) is
|
|
# essentially dead on real Android devices; the monero_c bundle
|
|
# doesn't ship it. Missing target = log + skip, not fatal. The
|
|
# required modern ABIs (arm64-v8a, x86_64) MUST exist or we fail.
|
|
declare -A ABI_MAP=(
|
|
[aarch64-linux-android]=arm64-v8a
|
|
[armv7a-linux-androideabi]=armeabi-v7a
|
|
[i686-linux-android]=x86
|
|
[x86_64-linux-android]=x86_64
|
|
)
|
|
REQUIRED_ABIS=(arm64-v8a x86_64)
|
|
|
|
declare -A FOUND_ABI
|
|
for target in "${!ABI_MAP[@]}"; do
|
|
abi="${ABI_MAP[$target]}"
|
|
if [[ ! -d "$BUNDLE_DIR/$target" ]]; then
|
|
echo "SKIP: $target not in bundle (Android ABI $abi will not be shipped)"
|
|
continue
|
|
fi
|
|
mkdir -p "android/app/src/main/jniLibs/$abi"
|
|
ok=true
|
|
for coin in monero wownero; do
|
|
src="$BUNDLE_DIR/$target/lib${coin}_wallet2_api_c.so"
|
|
if [[ -f "$src" ]]; then
|
|
cp -v "$src" "android/app/src/main/jniLibs/$abi/"
|
|
else
|
|
echo "SKIP: $src missing (skipping $abi)"
|
|
ok=false
|
|
break
|
|
fi
|
|
done
|
|
if $ok; then
|
|
FOUND_ABI[$abi]=1
|
|
else
|
|
rm -rf "android/app/src/main/jniLibs/$abi"
|
|
fi
|
|
done
|
|
|
|
# Fail loudly only if a required ABI is missing.
|
|
for required in "${REQUIRED_ABIS[@]}"; do
|
|
if [[ -z "${FOUND_ABI[$required]:-}" ]]; then
|
|
echo "FATAL: required ABI $required is missing — monero_c bundle layout has changed"
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
echo "=== jniLibs contents ==="
|
|
find android/app/src/main/jniLibs/ -type f -name '*.so' | sort
|
|
|
|
# mwebd (Litecoin MWEB) is NOT built here. LTC works without MWEB —
|
|
# users just don't get the privacy MWEB feature. Add a build_mwebd
|
|
# step if/when we want MWEB on Android.
|
|
|
|
# ---- Configure: pubspec.yaml, AndroidManifest.xml, app_properties ----
|
|
- name: Run Android configure (cakewallet profile)
|
|
run: |
|
|
set -x -e
|
|
pushd scripts/android
|
|
source ./app_env.sh cakewallet
|
|
./app_config.sh
|
|
popd
|
|
|
|
# ---- Secrets ---------------------------------------------------------
|
|
- name: Generate per-module secrets.g.dart files (all empty defaults)
|
|
run: |
|
|
# tool/generate_new_secrets.dart imports app deps (encrypt/convert/intl),
|
|
# so the app's packages must be resolved first. The configure step runs
|
|
# `flutter pub get` inside pubspec_gen.sh WITHOUT `set -e`, so any hiccup
|
|
# there fails silently and surfaces here as "Couldn't resolve the package".
|
|
# Resolve explicitly (and loudly) before running the tool.
|
|
set -e
|
|
flutter pub get
|
|
dart run tool/generate_new_secrets.dart
|
|
|
|
|
|
- name: Pin crypto secrets (never ship random salts)
|
|
env:
|
|
CRYPTO_SALT: ${{ secrets.CRYPTO_SALT }}
|
|
CRYPTO_KEYCHAIN_SALT: ${{ secrets.CRYPTO_KEYCHAIN_SALT }}
|
|
CRYPTO_KEY: ${{ secrets.CRYPTO_KEY }}
|
|
CRYPTO_WALLET_SALT: ${{ secrets.CRYPTO_WALLET_SALT }}
|
|
CRYPTO_SHORT_KEY: ${{ secrets.CRYPTO_SHORT_KEY }}
|
|
CRYPTO_BACKUP_SALT: ${{ secrets.CRYPTO_BACKUP_SALT }}
|
|
CRYPTO_BACKUP_KEYCHAIN_SALT: ${{ secrets.CRYPTO_BACKUP_KEYCHAIN_SALT }}
|
|
CRYPTO_WALLET_GROUP_SALT: ${{ secrets.CRYPTO_WALLET_GROUP_SALT }}
|
|
run: |
|
|
set -e
|
|
# generate_new_secrets.dart mints RANDOM salts each run. Shipping
|
|
# those breaks decryption of everything the previous build stored
|
|
# (2026-08-29 "Corrupted seeds" incident). Overwrite with the pinned
|
|
# values and refuse to build if any is missing.
|
|
for v in CRYPTO_SALT CRYPTO_KEYCHAIN_SALT CRYPTO_KEY CRYPTO_WALLET_SALT CRYPTO_SHORT_KEY CRYPTO_BACKUP_SALT CRYPTO_BACKUP_KEYCHAIN_SALT CRYPTO_WALLET_GROUP_SALT; do
|
|
eval "val=\${$v:-}"
|
|
[ -n "$val" ] || { echo "FATAL: $v is empty — refusing to ship random crypto secrets"; exit 1; }
|
|
done
|
|
sed -i.bak \
|
|
-e "s|const salt = '.*';|const salt = '${CRYPTO_SALT}';|" \
|
|
-e "s|const keychainSalt = '.*';|const keychainSalt = '${CRYPTO_KEYCHAIN_SALT}';|" \
|
|
-e "s|const key = '.*';|const key = '${CRYPTO_KEY}';|" \
|
|
-e "s|const walletSalt = '.*';|const walletSalt = '${CRYPTO_WALLET_SALT}';|" \
|
|
-e "s|const shortKey = '.*';|const shortKey = '${CRYPTO_SHORT_KEY}';|" \
|
|
-e "s|const backupSalt = '.*';|const backupSalt = '${CRYPTO_BACKUP_SALT}';|" \
|
|
-e "s|const backupKeychainSalt = '.*';|const backupKeychainSalt = '${CRYPTO_BACKUP_KEYCHAIN_SALT}';|" \
|
|
-e "s|const walletGroupSalt = '.*';|const walletGroupSalt = '${CRYPTO_WALLET_GROUP_SALT}';|" \
|
|
lib/.secrets.g.dart
|
|
rm -f lib/.secrets.g.dart.bak
|
|
for c in salt keychainSalt key walletSalt shortKey backupSalt backupKeychainSalt walletGroupSalt; do
|
|
grep -qE "^const $c = '[0-9a-f]{8,}';" lib/.secrets.g.dart || { echo "FATAL: $c not pinned"; exit 1; }
|
|
done
|
|
echo "crypto secrets pinned (values not printed)"
|
|
- name: Inject Trocador + WalletConnect + MoonPay secrets into lib/.secrets.g.dart
|
|
env:
|
|
TROCADOR_API_KEY: ${{ secrets.TROCADOR_API_KEY }}
|
|
TROCADOR_MONERO_API_KEY: ${{ secrets.TROCADOR_MONERO_API_KEY }}
|
|
TROCADOR_EXCHANGE_MARKUP: ${{ secrets.TROCADOR_EXCHANGE_MARKUP }}
|
|
WALLETCONNECT_PROJECT_ID: ${{ secrets.WALLETCONNECT_PROJECT_ID }}
|
|
# MoonPay: publishable key only. NEVER inject moonPaySecretKey (server-side).
|
|
MOONPAY_API_KEY: ${{ secrets.MOONPAY_API_KEY }}
|
|
EXCHANGE_HELPER_API_KEY: ${{ secrets.EXCHANGE_HELPER_API_KEY }}
|
|
run: |
|
|
sed -i \
|
|
-e "s|const trocadorApiKey = '';|const trocadorApiKey = '${TROCADOR_API_KEY}';|" \
|
|
-e "s|const trocadorMoneroApiKey = '';|const trocadorMoneroApiKey = '${TROCADOR_MONERO_API_KEY}';|" \
|
|
-e "s|const trocadorExchangeMarkup = '';|const trocadorExchangeMarkup = '${TROCADOR_EXCHANGE_MARKUP:-1}';|" \
|
|
-e "s|const walletConnectProjectId = '';|const walletConnectProjectId = '${WALLETCONNECT_PROJECT_ID}';|" \
|
|
-e "s|const moonPayApiKey = '';|const moonPayApiKey = '${MOONPAY_API_KEY}';|" \
|
|
-e "s|const exchangeHelperApiKey = '';|const exchangeHelperApiKey = '${EXCHANGE_HELPER_API_KEY}';|" \
|
|
lib/.secrets.g.dart
|
|
awk -F"'" '/^const (trocador[A-Za-z]*|walletConnectProjectId|moonPayApiKey|exchangeHelperApiKey) = / { split($1, a, " "); v = (length($2) ? "set (" length($2) " chars)" : "EMPTY"); print a[2] ": " v }' lib/.secrets.g.dart # lengths only; values never reach the log
|
|
|
|
# ---- Signing: decode upload keystore + write key.properties ---------
|
|
- name: Decode Android upload keystore + write key.properties
|
|
env:
|
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
|
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
|
run: |
|
|
set -e
|
|
# Debug visibility — prints lengths only, never values. If a secret
|
|
# length is 0, Gitea Actions is not passing it to this run (most
|
|
# commonly because the run was triggered by a from-fork PR).
|
|
echo "ANDROID_KEYSTORE_BASE64 length: ${#ANDROID_KEYSTORE_BASE64}"
|
|
echo "ANDROID_KEYSTORE_PASSWORD length: ${#ANDROID_KEYSTORE_PASSWORD}"
|
|
echo "ANDROID_KEY_ALIAS length: ${#ANDROID_KEY_ALIAS}"
|
|
echo "ANDROID_KEY_PASSWORD length: ${#ANDROID_KEY_PASSWORD}"
|
|
if [[ -z "$ANDROID_KEYSTORE_BASE64" ]]; then
|
|
echo "FATAL: ANDROID_KEYSTORE_BASE64 not reaching the runner."
|
|
echo "Check: (1) secret is in repo Settings → Actions → Secrets;"
|
|
echo " (2) workflow was triggered by 'push' or 'workflow_dispatch'"
|
|
echo " (PR triggers from a fork strip secrets);"
|
|
echo " (3) Gitea's runner is configured to pass secrets."
|
|
exit 1
|
|
fi
|
|
# Write decoded keystore next to build.gradle (storeFile path
|
|
# in key.properties is resolved relative to android/app/).
|
|
echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks
|
|
ls -la android/app/upload-keystore.jks
|
|
# key.properties consumed by build.gradle's signingConfigs.release.
|
|
cat > android/key.properties <<EOF
|
|
storePassword=$ANDROID_KEYSTORE_PASSWORD
|
|
keyPassword=$ANDROID_KEY_PASSWORD
|
|
keyAlias=$ANDROID_KEY_ALIAS
|
|
storeFile=upload-keystore.jks
|
|
EOF
|
|
chmod 600 android/key.properties
|
|
|
|
# ---- Flutter SDK init + codegen --------------------------------------
|
|
- name: Initialize Flutter SDK (Android precache)
|
|
run: |
|
|
flutter --version
|
|
flutter precache --android --no-linux --no-ios --no-macos --no-windows --no-fuchsia --no-web || true
|
|
|
|
- name: Build generated code (mobx + hive adapters)
|
|
run: bash model_generator.sh
|
|
|
|
- name: Generate localization
|
|
run: dart run tool/generate_localization.dart
|
|
|
|
- name: Compile SVG assets (res/pictures/*.svg → assets/new-ui/*.svg.vec)
|
|
run: ./compile_graphics.sh
|
|
|
|
# ---- Compile + sign --------------------------------------------------
|
|
# Universal APK (single binary covers all 4 ABIs, easy to sideload).
|
|
- name: Build release APK (universal, all ABIs)
|
|
run: flutter build apk --dart-define-from-file=env.json --release
|
|
|
|
# AAB for Play Console upload (Google generates per-ABI APKs server-side
|
|
# via Play App Signing). --verbose surfaces the full gradle stacktrace
|
|
# when FinalizeBundleTask / signReleaseBundle fails — without it the
|
|
# error is opaque ('FinalizeBundleTask$BundleToolRunnable failed').
|
|
- name: Build release AAB
|
|
run: flutter build appbundle --dart-define-from-file=env.json --release --verbose
|
|
|
|
- name: Sanity-check signature on APK
|
|
run: |
|
|
set -x
|
|
ls -la build/app/outputs/flutter-apk/
|
|
# apksigner is in build-tools; locate dynamically.
|
|
APKSIGNER=$(find $ANDROID_HOME/build-tools -name apksigner -type f 2>/dev/null | sort -V | tail -1 || which apksigner)
|
|
if [[ -n "$APKSIGNER" ]]; then
|
|
"$APKSIGNER" verify --print-certs build/app/outputs/flutter-apk/app-release.apk || true
|
|
else
|
|
echo "apksigner not on PATH; skipping sig print"
|
|
fi
|
|
|
|
# Flutter names every build app-release.{apk,aab}, so two releases unzip
|
|
# to identical filenames and an old one in ~/Downloads looks exactly like
|
|
# the new one. That is how 1.0.3's AAB got dropped into the 1.0.4 Play
|
|
# release. Name the files after the version they carry.
|
|
- name: Name the outputs after their version
|
|
id: names
|
|
run: |
|
|
set -euo pipefail
|
|
FULL=$(awk '/^version:/ {print $2; exit}' pubspec_description.yaml)
|
|
VER="${FULL%%+*}"; BUILD="${FULL#*+}"
|
|
if [ -z "$VER" ] || [ "$BUILD" = "$FULL" ]; then
|
|
echo "pubspec_description.yaml version '$FULL' is not <name>+<build>; refusing to guess a name" >&2
|
|
exit 1
|
|
fi
|
|
BASE="hash-bags-${VER}-build${BUILD}"
|
|
mkdir -p dist
|
|
cp build/app/outputs/flutter-apk/app-release.apk "dist/${BASE}.apk"
|
|
cp build/app/outputs/bundle/release/app-release.aab "dist/${BASE}.aab"
|
|
echo "base=$BASE" >> "$GITHUB_OUTPUT"
|
|
ls -la dist
|
|
|
|
# actions/upload-artifact@v4 doesn't work on Gitea; pin to v3.
|
|
- name: Upload APK artifact (sideload-able)
|
|
uses: actions/upload-artifact@v3
|
|
with:
|
|
name: ${{ steps.names.outputs.base }}-apk-${{ github.sha }}
|
|
path: dist/${{ steps.names.outputs.base }}.apk
|
|
retention-days: 14
|
|
|
|
- name: Upload AAB artifact (for Play Console)
|
|
uses: actions/upload-artifact@v3
|
|
with:
|
|
name: ${{ steps.names.outputs.base }}-aab-${{ github.sha }}
|
|
path: dist/${{ steps.names.outputs.base }}.aab
|
|
retention-days: 30
|