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

652 lines
32 KiB
YAML

name: Hash Bags Windows build
# Phase 1 of Windows CI: produce an unsigned Windows release bundle (.zip of
# the Flutter Release output + MSVC runtime DLLs). Drop the zip on any Win
# 10/11 box, extract, run hash_wallet.exe.
#
# Phase 2 (separate iteration): wire up Inno Setup installer once the
# scripts/windows/build_exe_installer.iss file is rebranded from Cake Wallet
# → Hash Bags.
on:
# Manual, or a release tag. workflow_dispatch runs as the triggering user, so
# secrets are always available (PR triggers from forks would strip them — not
# relevant here since the runner is self-hosted and listening on a
# self-hosted Gitea). A tag builds automatically because a tag is the thing a
# release is cut from, and the signed artifact should come from it rather
# than from whatever happened to be 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.
required: false
default: ''
# Cancel in-flight runs when a newer commit lands on the same branch.
concurrency:
group: windows-${{ github.ref }}
cancel-in-progress: true
defaults:
run:
# Explicit Git Bash path (not `shell: bash`) because Windows resolves
# bare `bash` to C:\Windows\System32\bash.exe (WSL), which refuses to
# run when the act_runner service is hosted under LocalSystem:
# "Running WSL as local system is not supported."
# Git Bash works fine under LocalSystem and matches how GitHub's
# hosted windows-latest runner invokes bash.
shell: '"C:\Program Files\Git\bin\bash.exe" --noprofile --norc -eo pipefail {0}'
jobs:
build:
# Matches the label our act_runner.exe advertised: "windows:host" → executor=host.
runs-on: windows
env:
BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
# Belt-and-suspenders fallback that bypasses Node TLS verification for
# actions/* against the self-hosted runner's internal git host. Same
# fallback as build-ios-sim.yml.
NODE_TLS_REJECT_UNAUTHORIZED: '0'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
# ---- Git config for LocalSystem ---------------------------------------
# The runner service runs as LocalSystem, which has no global git
# identity. scripts/monero_c/apply_patches.sh (and a few other
# submodule operations) do `git commit`-equivalents that need
# user.email + user.name set. Also pre-empt git's "safe.directory"
# check, which fires when the checkout dir owner != runner identity.
- name: Configure git identity + safe.directory
run: |
git config --global --add safe.directory '*'
git config --global user.email "ci@suchsoftware.com"
git config --global user.name "Hash Bags CI"
git config --global --list
# ---- Rust toolchain ---------------------------------------------------
# The dnssec_proof plugin builds its native lib through cargokit
# (windows/CMakeLists.txt -> apply_cargokit), so `flutter build windows`
# genuinely needs cargo/rustc — the prebuilt monero_c bundle doesn't
# cover it.
#
# setup-windows-runner.ps1 installs Rust to C:\Rust and puts
# C:\Rust\cargo\bin on the *Machine* PATH, but the act_runner service
# only picks up Machine PATH changes when it restarts, and a rebuilt or
# re-imaged box loses it entirely. Either way the job died at exit 127
# ("rustc: command not found"). Locate it ourselves, and install it if
# it really isn't there, so CI doesn't depend on the box's env being
# right. No-op when rustc is already on PATH.
- name: Ensure Rust toolchain is on PATH
run: |
set -e
if command -v rustc >/dev/null 2>&1 && command -v cargo >/dev/null 2>&1; then
echo "Rust already on PATH: $(command -v rustc)"
exit 0
fi
# Known install locations, in preference order. First is where
# setup-windows-runner.ps1 puts it; the rest cover a rustup-init
# run under a normal user account.
CANDIDATES=(
"/c/Rust/cargo/bin"
"$USERPROFILE/.cargo/bin"
"/c/Users/Administrator/.cargo/bin"
"/c/Windows/System32/config/systemprofile/.cargo/bin"
)
for d in "${CANDIDATES[@]}"; do
if [[ -x "$d/rustc.exe" ]]; then
echo "Found Rust at $d — adding to PATH for later steps"
export PATH="$d:$PATH"
# $GITHUB_PATH wants a Windows-style path; MSYS converts the
# resulting semicolon PATH back to POSIX form on shell start.
cygpath -w "$d" >> "$GITHUB_PATH"
# rustup's shims resolve toolchains via RUSTUP_HOME; without it
# they look in %USERPROFILE%\.rustup, which is empty for the
# LocalSystem service account.
if [[ -d "$(dirname "$(dirname "$d")")/rustup" ]]; then
RUSTUP_DIR="$(dirname "$(dirname "$d")")/rustup"
# Export for THIS step as well as writing to $GITHUB_ENV.
# $GITHUB_ENV only reaches later steps, and the verification on
# the next line is itself a rustup shim: without the export it
# falls back to %USERPROFILE%\.rustup, which on this runner is
# a file rather than a directory, so it fails with os error 183
# having ignored the toolchain we just found.
RUSTUP_HOME="$(cygpath -w "$RUSTUP_DIR")"
CARGO_HOME="$(cygpath -w "$(dirname "$d")")"
export RUSTUP_HOME CARGO_HOME
echo "RUSTUP_HOME=$RUSTUP_HOME" >> "$GITHUB_ENV"
echo "CARGO_HOME=$CARGO_HOME" >> "$GITHUB_ENV"
fi
rustc --version
exit 0
fi
done
echo "Rust not found in any known location — installing to C:\\Rust"
CARGO_HOME="C:\\Rust\\cargo"
RUSTUP_HOME="C:\\Rust\\rustup"
export CARGO_HOME RUSTUP_HOME
mkdir -p /c/Rust/cargo /c/Rust/rustup
# act_runner sets RUNNER_TEMP, but don't hard-depend on it.
DL_DIR="${RUNNER_TEMP:-/c/Rust}"
curl -fsSL -o "$DL_DIR/rustup-init.exe" https://win.rustup.rs/x86_64
"$DL_DIR/rustup-init.exe" -y \
--default-toolchain stable \
--profile minimal \
--target x86_64-pc-windows-msvc \
--no-modify-path
echo "C:\\Rust\\cargo\\bin" >> "$GITHUB_PATH"
echo "CARGO_HOME=$CARGO_HOME" >> "$GITHUB_ENV"
echo "RUSTUP_HOME=$RUSTUP_HOME" >> "$GITHUB_ENV"
/c/Rust/cargo/bin/rustc --version
# ---- Windows Developer Mode (symlink support) ------------------------
# `flutter build windows` materialises each plugin under
# windows/flutter/ephemeral/.plugin_symlinks/ as a real NTFS symlink, and
# creating one needs SeCreateSymbolicLinkPrivilege. Flutter doesn't test
# the privilege directly — it reads the AppModelUnlock registry key and
# bails with:
# "Building with plugins requires symlink support.
# Please enable Developer Mode in your system settings."
# Setting the key is exactly what the Settings toggle does; HKLM is
# writable by the LocalSystem service account, so no interactive login
# is involved. Idempotent — a no-op once the value is 1.
- name: Enable Developer Mode (symlink support for plugin builds)
run: |
set -e
powershell -NoProfile -Command '
$key = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
if (-not (Test-Path $key)) { New-Item -Path $key -Force | Out-Null }
$cur = (Get-ItemProperty -Path $key -Name AllowDevelopmentWithoutDevLicense -ErrorAction SilentlyContinue).AllowDevelopmentWithoutDevLicense
if ($cur -eq 1) {
Write-Host "Developer Mode already enabled"
} else {
Set-ItemProperty -Path $key -Name AllowDevelopmentWithoutDevLicense -Value 1 -Type DWord
Write-Host "Developer Mode enabled (was: $cur)"
}
'
# ---- Flutter, pinned -------------------------------------------------
# Every other platform builds on 3.32.0: Linux and Android inside the
# cake_wallet debian13-flutter3.32.0 image, iOS by installing it
# explicitly. Windows was the one that took whatever the runner happened
# to have, and on 2026-09-19 that was 3.38.5 / Dart 3.10.4. Dart 3.10
# removed the _macros package, so `flutter pub get` could not solve
# hive_generator against build_resolvers and the build died at exit 69
# long before it compiled anything. A build machine's drifting toolchain
# must not decide which dependencies resolve.
- name: Require Flutter 3.32.0
run: |
set -e
ver="$(flutter --version 2>/dev/null | head -1 || true)"
echo "runner has: ${ver:-<no flutter on PATH>}"
case "$ver" in
*3.32.0*) exit 0 ;;
esac
cat >&2 <<'MSG'
Refusing to build: this runner's Flutter is not the pinned 3.32.0.
Every other platform is pinned, Linux and Android by the
cake_wallet debian13-flutter3.32.0 image and iOS by installing it,
so a drifted Windows SDK breaks this platform alone and does it
during dependency resolution rather than at compile time.
Fix it on the runner rather than here. Installing an SDK per build
would download a gigabyte to paper over a machine that provisioning
is supposed to own, and subosito/flutter-action cannot do it anyway:
its setup script needs jq, which this runner does not have.
powershell -File scripts\windows\setup-windows-runner.ps1
That now checks the version rather than only that flutter.bat
exists, which is why it had been reporting a drifted SDK as done.
MSG
exit 1
# ---- Toolchain sanity ------------------------------------------------
# Our self-hosted runner has Flutter / Rust / VS Build Tools / Inno
# Setup pre-installed (see scripts/windows/setup-windows-runner.ps1).
# This step just prints versions so failures are easy to diagnose.
- name: Show installed toolchain versions
run: |
set -x
# PATH may need refreshing after install — refresh from the registry.
# Git Bash inherits Windows PATH at shell start, so just printing
# `which` is enough.
which flutter dart cargo rustc git 2>/dev/null || true
flutter --version
dart --version
rustc --version
cargo --version
# ---- External path-dep plugins ----------------------------------------
# Several plugins are path-deps in pubspec.yaml. The path directories
# must exist BEFORE `flutter pub get` runs against the regenerated
# pubspec, or pub fails with "could not find package X at <path>".
#
# The Android + iOS + Linux workflows use upstream-published .tar.gz
# tarballs. We don't, because those tarballs contain example/*
# symlinks pointing to absolute /home/runner/.pub-cache/ paths
# (artifacts from the upstream build host) — Linux tar tolerates
# dangling symlinks, Windows tar bails the whole extraction. Clone
# the source repos at the matching tagged versions instead. Same
# pattern as bitbox_flutter further down.
- name: Clone torch_dart at v1.0.17
run: |
set -x -e
pushd scripts
rm -rf torch_dart
git clone --depth 1 --branch v1.0.17 https://github.com/MrCyjaneK/torch_dart
popd
- name: Clone reown_flutter at v0.0.4
run: |
set -x -e
pushd scripts
rm -rf reown_flutter
git clone --depth 1 --branch v0.0.4 https://github.com/cake-tech/reown_flutter
popd
- name: Clone BitBox Flutter
run: |
# Pubspec has bitbox_flutter as a path dep — the directory must
# exist for pub get even if BitBox Windows support isn't
# functional. Same approach as the iOS workflow: clone the repo
# at a pinned commit, don't run any Android-binding build step.
set -x -e
pushd scripts
if [[ ! -d bitbox_flutter ]]; then
git clone https://github.com/konstantinullrich/bitbox_flutter
fi
cd bitbox_flutter
git fetch -a
git reset --hard
git checkout 5a6e6dd388ef64003f86094af80d5453518b601d
git reset --hard
popd
# ---- Native crypto cores (monero_c prebuilt bundle) ------------------
# The same release-bundle.zip that Android + iOS use. Contains
# pre-cross-compiled native libs for many target triples. We'll need
# the x86_64-w64-mingw32 (Windows MinGW) or x86_64-pc-windows-msvc
# entries — inspection step below logs what's actually shipped.
- name: Fetch prebuilt monero_c 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"
curl -fsSL -O 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
echo "=== bundle contents (top level) ==="
ls
popd
- name: Inspect Windows targets in monero_c bundle
run: |
set -x
MONERO_C_TAG=$(cd scripts/monero_c && git describe --tags)
BUNDLE_DIR="scripts/monero_c/release/$MONERO_C_TAG"
echo "=== full mingw32 dir contents ==="
ls -la "$BUNDLE_DIR/x86_64-w64-mingw32" 2>/dev/null || true
# ---- Stage monero_c DLLs into the layout windows/CMakeLists.txt expects
# Upstream Cake's Windows build cross-compiles monero_c on Linux via
# scripts/windows/build_all.sh, which lays files out as:
# scripts/monero_c/release/<coin>/<triple>_<basename>.dll
# The CMakeLists.txt at windows/CMakeLists.txt:84-94 hardcodes those
# paths in install(FILES ...) rules — it copies + RENAMEs them to the
# final names (monero_libwallet2_api_c.dll etc.) in the Release dir.
#
# We're using the prebuilt bundle from MrCyjaneK's release, which has
# a different layout:
# scripts/monero_c/release/<TAG>/<triple>/lib<coin>_wallet2_api_c.dll
# So we restage the bundle's files into the upstream-build-all-style
# layout before flutter build windows runs.
- name: Stage monero_c bundle into upstream cross-compile layout
run: |
set -x -e
MONERO_C_TAG=$(cd scripts/monero_c && git describe --tags)
SRC="scripts/monero_c/release/$MONERO_C_TAG/x86_64-w64-mingw32"
MONERO_DST="scripts/monero_c/release/monero"
WOWNERO_DST="scripts/monero_c/release/wownero"
mkdir -p "$MONERO_DST" "$WOWNERO_DST"
# The wallet C-API DLLs, one per coin
cp -v "$SRC/libmonero_wallet2_api_c.dll" "$MONERO_DST/x86_64-w64-mingw32_libwallet2_api_c.dll"
cp -v "$SRC/libwownero_wallet2_api_c.dll" "$WOWNERO_DST/x86_64-w64-mingw32_libwallet2_api_c.dll"
# MinGW runtime DLLs (libssp-0, libwinpthread-1) — the wallet libs
# are dynamically linked against these. MrCyjaneK's prebuilt
# release-bundle.zip does NOT ship them (only the wallet *.dll +
# *.dll.a import libs), so we source them from Git Bash's bundled
# MinGW-w64 distribution, which ships the same x86_64-w64-mingw32
# ABI used to build the wallet libs.
# ORDERING MATTERS: monero_c's prebuilt wallet DLLs are built against
# recent MinGW-w64 (post-2022 winpthreads ABI with pthread_cond_timedwait64).
# Flutter's bundled mingit ships a 2017-vintage libwinpthread that's
# missing newer symbols and crashes wallet creation at runtime. Prefer
# Git for Windows' mingw64/bin which is updated frequently.
RUNTIME_PATHS=(
"/c/Program Files/Git/mingw64/bin" # Git for Windows — modern MinGW runtime (preferred)
"$SRC" # monero_c bundle (in case it grows DLLs)
"/c/ProgramData/chocolatey/lib/mingw/tools/install/mingw64/bin" # choco install -y mingw
"/c/flutter/bin/mingit/mingw64/bin" # Flutter's bundled mingit — old (2017), use only as last resort
"/mingw64/bin" # MSYS2 fallback
)
for runtime in libssp-0.dll libwinpthread-1.dll; do
src=""
for d in "${RUNTIME_PATHS[@]}"; do
if [[ -f "$d/$runtime" ]]; then
src="$d/$runtime"
break
fi
done
if [[ -z "$src" ]]; then
echo "FATAL: $runtime not found in any of:"
printf ' %s\n' "${RUNTIME_PATHS[@]}"
for d in "${RUNTIME_PATHS[@]}"; do
echo "--- contents of $d (if exists) ---"
ls "$d" 2>/dev/null | head -20 || echo "(missing)"
done
exit 1
fi
cp -v "$src" "$MONERO_DST/x86_64-w64-mingw32_${runtime}"
done
echo "=== final staged layout ==="
ls -la "$MONERO_DST/" "$WOWNERO_DST/"
# ---- Configure: pubspec.yaml + per-coin enablement -------------------
# Mirror of hashwallet.bat — drives tool/configure.dart with the same
# set of coin flags that upstream Cake's Windows build uses.
- name: Configure pubspec + Wallet types
run: |
set -x -e
cp -f pubspec_description.yaml pubspec.yaml
flutter pub get
dart run tool/generate_pubspec.dart
flutter pub get
dart run tool/configure.dart \
--monero --bitcoin --ethereum --polygon --nano --bitcoinCash \
--wownero --dogecoin --base --arbitrum --bsc
# ---- Secrets ---------------------------------------------------------
- name: Generate per-module secrets.g.dart files (empty defaults)
run: 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: |
# Length-only debug visibility — never prints actual secrets.
echo "TROCADOR_API_KEY length: ${#TROCADOR_API_KEY}"
echo "TROCADOR_MONERO_API_KEY length: ${#TROCADOR_MONERO_API_KEY}"
echo "TROCADOR_EXCHANGE_MARKUP length: ${#TROCADOR_EXCHANGE_MARKUP}"
if [[ -z "$TROCADOR_API_KEY" ]]; then
echo "WARN: TROCADOR_API_KEY not reaching runner — build will proceed"
echo " but Trocador exchange features won't work in this binary."
fi
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
# ---- Codegen ---------------------------------------------------------
- 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
# ---- Build the Windows .exe ------------------------------------------
- name: Build Windows release
run: |
set -x -e
flutter config --enable-windows-desktop
flutter build windows --dart-define-from-file=env.json --release --verbose
# ---- Reconcile monero/wownero DLL naming for Dart FFI ----------------
# windows/CMakeLists.txt's install(FILES ... RENAME ...) rules produce
# <coin>_libwallet2_api_c.dll, but package:monero's Dart FFI loader
# (mrcyjanek's package) opens lib<coin>_wallet2_api_c.dll — different
# filename layout, runtime error "Failed to load dynamic library".
# Create the lib-prefixed copies so both name conventions resolve.
- name: Mirror monero/wownero DLLs under lib-prefixed names
run: |
set -x -e
DST="build/windows/x64/runner/Release"
for coin in monero wownero; do
src="$DST/${coin}_libwallet2_api_c.dll"
dst="$DST/lib${coin}_wallet2_api_c.dll"
if [[ -f "$src" ]]; then
cp -v "$src" "$dst"
else
echo "WARN: $src not in Release dir — CMake install may have skipped it"
ls "$DST" | head -20
fi
done
# ---- Bundle MSVC runtime DLLs with the .exe --------------------------
# Standalone Windows builds need msvcp140.dll + vcruntime140.dll +
# vcruntime140_1.dll next to the .exe (or installed via vc_redist on
# the user's machine). Bundling avoids the "VCRUNTIME140.dll not found"
# error on machines without VC++ redistributable installed.
- name: Copy MSVC runtime DLLs next to the .exe
run: |
set -x -e
# Find the redist dir for the currently-installed MSVC version (the
# version number rolls forward with VS updates, so do NOT hardcode).
REDIST_BASE="/c/Program Files (x86)/Microsoft Visual Studio/2022/BuildTools/VC/Redist/MSVC"
REDIST_DIR=$(find "$REDIST_BASE" -type d -name 'Microsoft.VC*.CRT' -path '*/x64/*' 2>/dev/null | sort | tail -1)
if [[ -z "$REDIST_DIR" ]]; then
echo "FATAL: could not find VC redist dir under $REDIST_BASE"
ls "$REDIST_BASE" 2>/dev/null || echo "(redist base dir missing entirely)"
exit 1
fi
echo "Using redist dir: $REDIST_DIR"
cp -v "$REDIST_DIR/msvcp140.dll" "$REDIST_DIR/vcruntime140.dll" "$REDIST_DIR/vcruntime140_1.dll" \
build/windows/x64/runner/Release/
# ---- Authenticode sign the launcher ----------------------------------
# This workflow already runs on the machine with the EV code-signing
# token in it, but the runner (NT SERVICE\act_runner) cannot use the
# token: SafeNet holds the login per user and an operator establishes it
# interactively. So the exe is queued for the Fleet-installed signing
# broker (such-fleet roles/windows_code_signing), which signs it in the
# operator's session. CI never holds the key or the token password, so it
# cannot spend one of the token's three password attempts.
#
# Only HashWallet.exe is signed. The bundled MSVC runtime DLLs are
# already Microsoft-signed and must not be re-signed, and the plugin DLLs
# are loaded by an already-trusted process -- it is the launcher the user
# double-clicks that SmartScreen judges.
- name: Sign HashWallet.exe with the hardware token
shell: powershell
run: |
$ErrorActionPreference = 'Stop'
$release = "build\windows\x64\runner\Release"
$exe = Join-Path $release 'HashWallet.exe'
if (-not (Test-Path $exe)) { throw "no HashWallet.exe at $exe" }
if (Test-Path 'C:\signing\SIGNING_LOCKED') {
Get-Content 'C:\signing\SIGNING_LOCKED'
throw "The signing broker's circuit breaker is tripped. An operator must clear it."
}
$queued = "hashbags-$env:GITHUB_RUN_ID.exe"
$inPath = "C:\signing\in\$queued"
$outPath = "C:\signing\out\$queued"
Copy-Item $exe $inPath -Force
Set-Content -LiteralPath "$inPath.ready" -Value $env:GITHUB_RUN_ID
Write-Host "queued $queued"
$deadline = (Get-Date).AddMinutes(10)
while ((Get-Date) -lt $deadline) {
if (Test-Path "$outPath.done") { break }
if (Test-Path 'C:\signing\SIGNING_LOCKED') {
Get-Content 'C:\signing\SIGNING_LOCKED'
throw "The broker tripped its circuit breaker while signing this artifact."
}
Start-Sleep -Seconds 5
}
if (-not (Test-Path "$outPath.done")) { throw "Timed out waiting for the signing broker." }
Move-Item $outPath $exe -Force
Remove-Item "$outPath.done" -Force -ErrorAction SilentlyContinue
# Verify on the binary that actually ships. A bad signature or a
# missing timestamp blocks the release.
$sig = Get-AuthenticodeSignature $exe
$sig | Format-List Status, StatusMessage,
@{n='Signer';e={$_.SignerCertificate.Subject}},
@{n='TimeStamper';e={$_.TimeStamperCertificate.Subject}}
if ($sig.Status -ne 'Valid') { throw "Authenticode status is $($sig.Status)" }
if (-not $sig.TimeStamperCertificate) { throw "Signature carries no RFC 3161 timestamp" }
if ($sig.SignerCertificate.Subject -notmatch 'Such Software LLC') {
throw "Unexpected signer: $($sig.SignerCertificate.Subject)"
}
Write-Host "SHA256 $((Get-FileHash $exe -Algorithm SHA256).Hash)"
# ---- Package + upload ------------------------------------------------
- name: List built artifacts (for debug visibility)
run: |
ls -la build/windows/x64/runner/Release/
# ---- Package + upload as zip -----------------------------------------
# The Flutter Windows build produces a directory tree (~500 files —
# HashWallet.exe + plugin DLLs + data/ + locales/ + bundled monero_c
# + MinGW runtime DLLs). Ship as a zip; users extract and run.
#
# Tried wrapping with 7-Zip SFX for a single-file "portable" .exe
# earlier but path_provider_windows computes the wallet storage dir
# from the .exe location, and SFX extracts to a different %TEMP%
# subdir on every launch — wallet keys couldn't be found across
# runs. Multi-file zip is what Firefox Portable / VS Code Portable
# / every other Flutter Windows desktop app does too.
- name: Create Hash Bags Windows release zip
run: |
set -x -e
cd build/windows/x64/runner/Release
# PowerShell's Compress-Archive is in PATH; no extra deps needed.
powershell -NoProfile -Command "Compress-Archive -Path .\* -DestinationPath ..\..\..\..\..\HashBags-windows-${{ github.sha }}.zip -Force"
cd "$GITHUB_WORKSPACE"
ls -la HashBags-windows-*.zip
echo "Size: $(du -h HashBags-windows-${{ github.sha }}.zip | cut -f1)"
- name: Upload Hash Bags Windows zip
uses: actions/upload-artifact@v3
with:
name: hash-bags-windows-${{ github.sha }}
path: HashBags-windows-${{ github.sha }}.zip
retention-days: 14
# ---- Publish to the GitHub release ------------------------------------
# PowerShell rather than the bash used elsewhere in this file: this step
# parses JSON, and git-bash here has neither python nor node guaranteed,
# while ConvertFrom-Json is native. Skips quietly without a token.
- 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 signed zip to the GitHub release
if: steps.reltag.outputs.tag != ''
shell: powershell
env:
GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
TAG: ${{ steps.reltag.outputs.tag }}
run: |
$ErrorActionPreference = 'Stop'
if (-not $env:GH_TOKEN) {
Write-Host 'GH_RELEASE_TOKEN not set; skipping the GitHub upload'
exit 0
}
$repo = 'Such-Software/hash-wallet'
$api = "https://api.github.com/repos/$repo"
$hdr = @{ Authorization = "Bearer $env:GH_TOKEN"; 'User-Agent' = 'hash-bags-ci' }
$src = Get-ChildItem -Path . -Filter 'HashBags-windows-*.zip' | Select-Object -First 1
if (-not $src) { throw 'no Windows zip to upload' }
# Named for the tag, not the commit: a release asset called
# HashBags-windows-<40 hex>.zip tells a downloader nothing.
$name = "HashBags-windows-$env:TAG.zip"
try {
$rel = Invoke-RestMethod -Headers $hdr -Uri "$api/releases/tags/$env:TAG"
} catch {
Write-Host "no GitHub release for $env:TAG; skipping"
exit 0
}
# 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.
foreach ($a in @($rel.assets | Where-Object { $_.name -eq $name })) {
Invoke-RestMethod -Headers $hdr -Method Delete -Uri "$api/releases/assets/$($a.id)" | Out-Null
}
$up = "https://uploads.github.com/repos/$repo/releases/$($rel.id)/assets?name=$name"
Invoke-RestMethod -Headers $hdr -Method Post -ContentType 'application/octet-stream' `
-InFile $src.FullName -Uri $up | Out-Null
Write-Host "uploaded $name to the GitHub release $env:TAG"