Files
hash-wallet/.github/workflows/build-android.yml
T
jwinterm b07adfff5b ci(macos): port the prerequisites instead of guessing them, and stop printing secrets
The first macOS run died after 88 seconds on `cd scripts/monero_c: No such file
or directory`. That was the cheap failure. The workflow had been written fresh
rather than ported, and behind that missing clone it had also skipped the step
that pins the crypto salts. Had it got further it would have shipped a build
that mints random salts, which is the 2026-08-29 corrupted-seeds incident on a
new platform: wallets created on it would not decrypt after the next upgrade.

So the prerequisites are now ported step for step from build-linux.yml, the
desktop build known to pass: torch_dart, reown_flutter, bitbox (the Apple
variant, without the gomobile Android bindings), prepare_moneroc.sh, configure,
secrets generation, salt pinning with the same refusal, Trocador injection,
codegen, localization, and compile_graphics.sh. curl for wget and BSD sed's
-i.bak form, because the runner is a Mac.

Two more things the fresh version got wrong, found while porting:

configure was run without sourcing app_env.sh, and app_config.sh writes
CFBundleName, the bundle id and the version into Info.plist from variables only
app_env.sh sets. The app would have shipped with an empty name. The step now
prints the three values it wrote.

And app_env.sh hardcoded 1.0.0 (1) on macOS, 6.1.0 (79) on Linux and 1.9.0 (29)
in the fish variant, the last two being Cake Wallet's own version numbers. On
macOS PlistBuddy writes that into the bundle, so every macOS build would have
called itself 1.0.0. Linux only escaped because its substitution looks for a
version: 0.0.0 placeholder that pubspec_description.yaml does not contain. All
three now derive from pubspec_description.yaml the way Android already does, and
the two sh variants resolve to 1.0.4 (14). The fish one is unverified: fish is
not installed here and CI does not use it.

Separately, four workflows verified their secret injection by grepping
lib/.secrets.g.dart into the log, which prints the values. Android's printed the
Trocador, WalletConnect, MoonPay and exchange-helper keys. Gitea likely masks
registered secrets, but masking is a best-effort layer and values never belong
in output. Each now reports every key's name and length and nothing else.

Windows is not in this commit. Its run refused correctly, on the guard added in
c11c79b0c: the runner has Flutter 3.38.5, not the pinned 3.32.0. That is fixed on
the runner, not in the workflow.
2026-09-26 11:16:53 -04:00

323 lines
15 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
# 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: hash-wallet-android-apk-${{ github.sha }}
path: build/app/outputs/flutter-apk/app-release.apk
retention-days: 14
- name: Upload AAB artifact (for Play Console)
uses: actions/upload-artifact@v3
with:
name: hash-wallet-android-aab-${{ github.sha }}
path: build/app/outputs/bundle/release/app-release.aab
retention-days: 30