Files
hash-wallet/.github/workflows/build-linux.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

374 lines
18 KiB
YAML

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
awk -F"'" '/^const (trocador[A-Za-z]*) = / { 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
# 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"