Files
hash-wallet/.github/workflows/build-linux.yml
T
jwinterm c1f618176c ci(desktop): build macOS, and publish every desktop artifact to the release page
Three desktop targets, three different states, one destination.

macOS did not build at all. There was no workflow, only Cake's from-source
scripts and an Xcode project. It turns out most of the work was already done:
monero_c ships aarch64-apple-darwin and x86_64-apple-darwin, and
Runner.xcodeproj already references both dylibs with CodeSignOnCopy. So this
fetches the same prebuilt bundle the iOS and Android legs use, lipos the two
slices into universal libraries, and builds. One download serves Apple Silicon
and Intel, because a single-arch build looks correct on the machine that made
it and fails on half of everyone else's.

It signs with a Developer ID Application certificate, which is NOT the Apple
Distribution certificate the TestFlight workflow uses. They are not
interchangeable: an App Store cert on a directly downloaded app is refused by
Gatekeeper. Signing is inside-out by hand rather than --deep, which Apple
deprecated and which skips nested code it does not recognise. Then notarytool
with the App Store Connect key this repository already has, then stapler, so
Gatekeeper clears the app without a network round trip.

Every credential is optional and the preflight says, before spending forty
minutes, exactly which stages the run can reach and what the user will see if
it cannot reach them. Absent secrets downgrade the build loudly; they do not
fail it and they do not pass silently.

Windows and Linux already built. Windows already Authenticode-signs through the
Fleet broker. What both lacked was a way to get the artifact to anybody:
upload-artifact is the only publication and Gitea Actions artifacts here upload
and then list as zero, so the signed Windows zip has never been reachable. Both
now publish to the GitHub release, on the smirk pattern: tag-triggered, the
release must already exist so a half-populated one never goes public, and a
same-named asset from an earlier attempt is replaced rather than duplicated.
Windows does its API work in PowerShell because git-bash there has neither
python nor node guaranteed and ConvertFrom-Json is native.

Tags now build automatically on all three. A tag is the thing a release is cut
from, and the artifact that gets signed should come from it rather than from
whatever was dispatched by hand.

scripts/release/sign-release.sh detach-signs the collected artifacts and signs
SHA256SUMS, which is the signature that matters when a download reaches someone
from a mirror without its .asc. It verifies even immediately after signing: a
signature written but never checked invites trust it has not earned.

Not done here, and both need a human: GH_RELEASE_TOKEN does not exist in this
repository's Gitea secrets, and the release key's user ID still says Smirk.
2026-09-23 08:13:10 -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
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"