name: Hash Bags Linux build # Triggers: # - on push to dev/main (validates the foundation each time we land work) # - on PRs targeting dev/main (gates merges) # - manual via workflow_dispatch ("Run workflow" button in the UI) on: # Manual-only for pushes — auto-run on every push was too noisy. Trigger # via Actions → "Hash Bags Linux build" → Run workflow when you want a # build. Add `push: branches:` back here if/when we want pre-merge validation. # # Tags DO build automatically: a tag is a deliberate act and it is the thing # a release is cut from, so the artifact that gets signed and published should # come from the tag rather than from whatever was dispatched by hand. push: tags: - 'hash-bags-*' workflow_dispatch: inputs: release_tag: description: >- Existing release tag to publish this build to (e.g. hash-bags-1.0.3). Leave empty to build without publishing. The release must already exist: this never creates one, because a release created here would go public half-populated while the other platforms are still building. required: false default: '' concurrency: group: linux-${{ github.ref }} cancel-in-progress: true defaults: run: shell: bash jobs: build: # Both your Gitea linux-beast runner and GitHub-hosted ubuntu runners # carry the `ubuntu-latest` label, so this works on either platform. runs-on: ubuntu-latest container: # Cake Labs' prebuilt build image with Flutter 3.32.0, Go 1.24.1, NDK r28, # Rust nightly, and all the C++ build deps (boost, openssl, libsodium, # libzmq, unbound, icu) preinstalled. Saves us from doing the simplybs # source-bootstrap dance that ate 9GB on jw-laptop. 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 }} # cake_wallet build profile name, retained from upstream — internal # identifier only, doesn't appear in the binary or UI. APP_LINUX_TYPE: cakewallet steps: - name: Fix Actions HOME pointing at /github/home (breaks ~/.cache writes) 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" # ---- Secrets --------------------------------------------------------- # IMPORTANT: don't pre-write any of the tool/.*secrets-config.json # files. The generator at tool/generate_secrets_config.dart has an # early-return if tool/.secrets-config.json already exists (lines # 57-63), which then ALSO skips creating the per-module configs # (evm, nano, bitcoin) — every cw_* module then fails to compile # with "Undefined name secrets.xxx" for dozens of keys. # # Instead: run the generator first so it creates all configs from the # full SecretKey list (with empty defaults), then sed-inject Trocador # values into the freshly-generated lib/.secrets.g.dart. # ---- External clones (use prebuilt tarballs, NOT prepare_*.sh) ------- # The prepare_torch.sh / prepare_reown.sh scripts do dev-time clones # that take forever; CI uses the pre-tagged release tarballs instead. - 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 (small, builds fast) run: | set -x -e pushd scripts ./build_bitbox_flutter.sh popd # ---- Native crypto cores (monero_c prebuilt bundle) ------------------ # Skips the full simplybs toolchain bootstrap. The bundle has libmonero, # libwownero, AND libzano .so files; CMake only installs the ones we # ship (monero + wownero) — libzano is dropped at install time even # though monero_c builds it. - 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 x86_64-linux-gnu/ popd # ---- Configure: pubspec.yaml, AndroidManifest.xml, env vars ---------- - name: Run configure (cakewallet build profile) run: | pushd scripts/linux source ./app_env.sh cakewallet ./app_config.sh popd # ---- Codegen: mobx, hive adapters, FFI bindings ---------------------- # model_generator.sh is already trimmed to our 8 enabled chains. # Use `async` flag for parallel build_runner across modules. - 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. pubspec_gen.sh runs # `flutter pub get` WITHOUT `set -e`, so any hiccup 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 affiliate 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 }} run: | # Replace empty default '' with real values for the three Trocador keys. # Other ~80 secrets stay as empty strings — code that uses them just # fails-soft at runtime (no transaction history without Etherscan key, # no Cake Pay without API key, etc.). Add more sed lines here as we # acquire other affiliate keys. 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}';|" \ lib/.secrets.g.dart grep '^const trocador' lib/.secrets.g.dart # verify the substitutions # Initialize Flutter SDK once before parallel-ish work happens. flutter # precache for Flutter 3.32.0 hits a 404 on flutter_gpu.zip (asset not # actually published at the path Flutter constructs); the `|| true` # absorbs that — desktop Linux build doesn't need flutter_gpu. - name: Initialize Flutter SDK run: | flutter --version flutter precache --linux --no-android --no-ios --no-macos --no-windows --no-fuchsia --no-web || true # Sequential — async flag races on Flutter's startup lock and corrupts # the SDK state, causing the "doesn't support null safety" cascade. - 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 + package ---------------------------------------------- - name: Build Linux app run: flutter build linux --dart-define-from-file=env.json --release # ---- Package as AppImage -------------------------------------------- # Self-contained .AppImage is the user-facing Linux deliverable: single # file, double-click to run, no install, works on any glibc Linux from # the last few years. appimagetool is run with --appimage-extract-and-run # so it works inside the container without FUSE. - name: Stage AppDir run: | set -e -x REL=build/linux/x64/release APPDIR=$REL/Hash_Bags.AppDir rm -rf "$APPDIR" mkdir -p "$APPDIR/usr/bin" \ "$APPDIR/usr/share/applications" \ "$APPDIR/usr/share/icons/hicolor/256x256/apps" # The whole Flutter Linux bundle (binary + data/ + lib/) lives # under usr/bin/. The Flutter binary has RPATH=$ORIGIN/lib, so # plugin/native libs in usr/bin/lib/ resolve naturally. cp -r "$REL/bundle"/* "$APPDIR/usr/bin/" # Icon: scale the iOS marketing icon to 256 if convert is around, # else copy as-is (appimagetool only requires that AppDir root and # the hicolor path contain a same-named PNG). ICON_SRC=assets/images/ios_icons/hashwallet_ios_icons/Icon-App-1024x1024@1x.png ICON_DEST="$APPDIR/usr/share/icons/hicolor/256x256/apps/hash_bags.png" if command -v convert >/dev/null 2>&1; then convert "$ICON_SRC" -resize 256x256 "$ICON_DEST" else cp "$ICON_SRC" "$ICON_DEST" fi cp "$ICON_DEST" "$APPDIR/hash_bags.png" # .desktop file (at both canonical locations; appimagetool checks both) cat > "$APPDIR/hash_bags.desktop" <<'DESKTOP' [Desktop Entry] Name=Hash Bags Comment=Non-custodial multi-chain crypto wallet Exec=hash_wallet Icon=hash_bags Type=Application Categories=Office;Finance; Terminal=false DESKTOP # Strip the leading whitespace from the heredoc sed -i 's/^ //' "$APPDIR/hash_bags.desktop" cp "$APPDIR/hash_bags.desktop" "$APPDIR/usr/share/applications/" # AppRun: the entry point AppImage executes. Adds the bundled lib/ # dir to LD_LIBRARY_PATH and exec's the Flutter binary. cat > "$APPDIR/AppRun" <<'APPRUN' #!/bin/bash HERE="$(dirname "$(readlink -f "${0}")")" export LD_LIBRARY_PATH="${HERE}/usr/bin/lib:${LD_LIBRARY_PATH}" exec "${HERE}/usr/bin/hash_wallet" "$@" APPRUN sed -i 's/^ //' "$APPDIR/AppRun" chmod +x "$APPDIR/AppRun" ls -la "$APPDIR" - name: Build AppImage run: | set -e -x REL=build/linux/x64/release wget -q https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage -O /tmp/appimagetool chmod +x /tmp/appimagetool # --appimage-extract-and-run avoids the FUSE requirement (no fusermount # in the build container). ARCH= tells appimagetool which arch tag to # embed in the filename. ARCH=x86_64 /tmp/appimagetool --appimage-extract-and-run \ "$REL/Hash_Bags.AppDir" \ "$REL/Hash_Bags-x86_64.AppImage" ls -la "$REL"/*.AppImage # actions/upload-artifact@v4 uses an HTTP API that Gitea Actions does # not implement (only the v1/v3 wire format). Pinned to @v3 for Gitea # compatibility — also works on GitHub Actions. - name: Upload AppImage artifact uses: actions/upload-artifact@v3 with: name: hash-wallet-linux-appimage-${{ github.sha }} path: build/linux/x64/release/Hash_Bags-x86_64.AppImage retention-days: 14 # ---- Publish to the release pages ------------------------------------- # Artifact retrieval through Gitea Actions is unreliable here: uploads # succeed and then list as zero, so a build nobody can download is a build # that did not happen. Release assets are the path that actually returns # bytes, and GitHub is where users are told to look. # # Both steps skip quietly without a token, so a fork or a local runner # does not fail a build over a credential it was never given. - name: Resolve the release tag id: reltag run: | set -euo pipefail if [ "${GITHUB_REF:-}" != "${GITHUB_REF#refs/tags/}" ]; then TAG="${GITHUB_REF#refs/tags/}" else TAG="${{ github.event.inputs.release_tag }}" fi echo "tag=$TAG" >> "$GITHUB_OUTPUT" if [ -n "$TAG" ]; then echo "publishing to $TAG"; else echo "no tag; build only"; fi - name: Publish the AppImage to the GitHub release if: steps.reltag.outputs.tag != '' env: GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }} TAG: ${{ steps.reltag.outputs.tag }} run: | set -euo pipefail if [ -z "${GH_TOKEN:-}" ]; then echo "GH_RELEASE_TOKEN not set; skipping the GitHub upload" exit 0 fi REPO="Such-Software/hash-wallet" API="https://api.github.com/repos/$REPO" AUTH="Authorization: Bearer $GH_TOKEN" FILE="build/linux/x64/release/Hash_Bags-x86_64.AppImage" [ -f "$FILE" ] || { echo "no AppImage to upload"; exit 1; } NAME="$(basename "$FILE")" code=$(curl -sS -o rel-gh.json -w '%{http_code}' -H "$AUTH" "$API/releases/tags/$TAG" || true) if [ "$code" != "200" ]; then echo "no GitHub release for $TAG (HTTP $code); skipping" exit 0 fi # Fail rather than skip if there is no JSON parser. An upload that # quietly did not happen leaves a release page that looks finished # and a user with nothing to download. PY="" for c in python3 python; do command -v "$c" >/dev/null && { PY="$c"; break; }; done [ -n "$PY" ] || { echo "no python available to parse the release JSON"; exit 1; } ID=$($PY -c 'import json;print(json.load(open("rel-gh.json"))["id"])') # Replace a same-named asset from an earlier attempt, so a re-run does # not leave two that differ only by which build produced them. for oid in $($PY -c 'import json,sys;d=json.load(open("rel-gh.json"));print(" ".join(str(a["id"]) for a in d.get("assets",[]) if a["name"]==sys.argv[1]))' "$NAME"); do curl -sS -o /dev/null -X DELETE -H "$AUTH" "$API/releases/assets/$oid" || true done curl -sS --fail-with-body -o /dev/null -X POST -H "$AUTH" \ -H "Content-Type: application/octet-stream" \ --data-binary "@$FILE" \ "https://uploads.github.com/repos/$REPO/releases/$ID/assets?name=$NAME" echo "uploaded $NAME to the GitHub release $TAG"