name: windows build + sign (gitea self-hosted) # Runs ONLY on Gitea (GitHub ignores .gitea/workflows), in the private Builds # repository. Gitea is master; GitHub is a mirror and a release surface. # # Two jobs on two different machines, and that split is the whole point: # # build -> `deb`, which advertises windows-latest:host because it carries the # MinGW cross-compiler. Produces an UNSIGNED wowlet.exe. # sign -> `desktop-iim2fs2` (windows:host), the only machine with the EV # code-signing token plugged into it. # # The signing host does not sign in this job. The runner there is # NT SERVICE\act_runner, which cannot use the token: SafeNet holds the token # login per user, and the login is established interactively by an operator. # So the job queues the artifact for the signing broker that Fleet installs # (roles/windows_code_signing) and waits for it. CI never sees the key and never # sees the token password, so it can never spend one of the token's three # password attempts. # # Dispatch-only. Both runners are capacity 1 and shared with other lanes. on: [workflow_dispatch] env: HOST: x86_64-w64-mingw32 jobs: build: name: "Windows x86_64 (mingw cross, depends)" runs-on: windows-latest # gitea label windows-latest:host -> deb, host mode steps: - name: verify the cross toolchain is present # Host mode on a shared fleet runner: this job installs nothing and # needs no sudo. Fleet owns the toolchain; if something is missing the # job says exactly what rather than half-building. run: | missing=0 for t in curl bzip2 unzip zip git cmake make ccache python3 perl pkg-config \ x86_64-w64-mingw32-gcc-posix x86_64-w64-mingw32-g++-posix; do command -v "$t" >/dev/null || { echo "MISSING: $t"; missing=1; } done # mingw32.mk needs the posix threading variant (std::thread/mutex/cv). x86_64-w64-mingw32-g++ -v 2>&1 | grep -q 'Thread model: posix' || { echo "MISSING: x86_64-w64-mingw32-g++ is not the posix threading variant"; missing=1; } [ "$missing" = 0 ] || { echo "Fix the build host in such-fleet, not here."; exit 1; } echo "toolchain ok"; nproc; free -g | head -2 - name: configure git run: git config --global --add safe.directory '*' - uses: actions/checkout@v4 with: submodules: recursive # depends is the slow part (static Qt6/boost/openssl/tor from source). # Cache the source tarballs and the compiled packages so only a cold run # pays the ~1h cost. - name: restore depends sources cache id: cache-sources uses: actions/cache/restore@v4 with: path: contrib/depends/sources key: depends-sources-${{ hashFiles('contrib/depends/packages/*') }} restore-keys: | depends-sources- - name: restore depends built cache (mingw) id: cache-built uses: actions/cache/restore@v4 with: path: contrib/depends/built key: depends-built-mingw32-${{ hashFiles('contrib/depends/packages/*', 'contrib/depends/hosts/*', 'contrib/depends/toolchain.cmake.in') }} restore-keys: | depends-built-mingw32- - name: build depends (static Qt6 + tor + boost + openssl for mingw) # -j4, not the -j2 the GitHub lane needs: that limit existed because a # parallel static Qt6 compile OOMs a 16G hosted runner. deb has 30G. run: make -C contrib/depends HOST="$HOST" -j4 # Save with if:always() so an expensive toolchain banks even when the # later wowlet.exe build fails; the automatic post-save is skipped on # job failure, which otherwise re-pays hours every time. - name: save depends sources cache if: always() && steps.cache-sources.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: path: contrib/depends/sources key: depends-sources-${{ hashFiles('contrib/depends/packages/*') }} - name: save depends built cache (mingw) if: always() && steps.cache-built.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: path: contrib/depends/built key: depends-built-mingw32-${{ hashFiles('contrib/depends/packages/*', 'contrib/depends/hosts/*', 'contrib/depends/toolchain.cmake.in') }} - name: fetch wownerod.exe to embed run: | rm -rf /tmp/wownerod-embed && mkdir -p /tmp/wownerod-embed curl -fL -o /tmp/wownero-win.zip \ https://github.com/wownero/wownero-codeberg-mirror/releases/download/v0.11.4.0/wownero-x86_64-w64-mingw32-v0.11.4.0.zip # -j flattens the versioned subdir so wownerod.exe is a DIRECT child # (WownerodQrcGenerator globs ${WOWNEROD_DIR}/* non-recursively). unzip -o -j /tmp/wownero-win.zip 'wownero-x86_64-w64-mingw32-v0.11.4.0/wownerod.exe' -d /tmp/wownerod-embed ls -la /tmp/wownerod-embed - name: build wowlet.exe run: | rm -rf build && mkdir build && cd build # ARCH=x86-64: portable baseline. wownero's CMake defaults ARCH=native, # which under a cross compiler bakes the BUILD HOST's instructions into # the Windows binary -> SIGILL on older CPUs. cmake --toolchain "$GITHUB_WORKSPACE/contrib/depends/$HOST/share/toolchain.cmake" \ -DARCH=x86-64 \ -DWITH_SCANNER=OFF \ -DCHECK_UPDATES=OFF \ -DTOR_INSTALLED=On \ -DFEATHER_TARGET_TRIPLET="$HOST" \ -DWOWNEROD_DIR=/tmp/wownerod-embed \ .. cmake --build . -j6 ls -la bin/ - name: stage the unsigned exe for signing # The signing job needs the bare exe, not the zip: the broker signs one # PE file at a time. The zip is assembled after signing so the published # archive contains the signed binary. run: | mkdir -p stage/tor cp build/bin/feather.exe stage/wowlet.exe cp "contrib/depends/$HOST/Tor/tor.exe" stage/tor/tor.exe (cp LICENSE stage/LICENSE.txt 2>/dev/null || cp COPYING stage/LICENSE.txt 2>/dev/null) || true ls -la stage stage/tor - name: upload unsigned staging tree # v3, not v4: Gitea Actions implements the older artifact protocol and # rejects @actions/artifact v2 clients with # "GHESNotSupportedError ... not currently supported on GHES". uses: actions/upload-artifact@v3 with: name: wowlet-x86_64-windows-unsigned path: stage/ if-no-files-found: error sign: name: "Authenticode sign (hardware token broker)" needs: build runs-on: windows # gitea label windows:host -> desktop-iim2fs2 steps: - name: download unsigned staging tree uses: actions/download-artifact@v3 with: name: wowlet-x86_64-windows-unsigned path: stage - name: keep the unsigned tree for the reproducible archive shell: powershell run: | $ErrorActionPreference = 'Stop' Copy-Item 'stage' 'unsigned' -Recurse -Force - name: queue wowlet.exe for the signing broker and wait shell: powershell run: | $ErrorActionPreference = 'Stop' $queued = "wowlet-${env:GITHUB_RUN_ID}.exe" $inPath = "C:\signing\in\$queued" $outPath = "C:\signing\out\$queued" 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." } Copy-Item 'stage\wowlet.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 'stage\wowlet.exe' -Force Remove-Item "$outPath.done" -Force -ErrorAction SilentlyContinue - name: verify the published binary is signed and timestamped # The broker verifies before releasing; this verifies again on the # artifact that is actually about to ship. A signature or timestamp # failure blocks distribution. shell: powershell run: | $ErrorActionPreference = 'Stop' $sig = Get-AuthenticodeSignature 'stage\wowlet.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 'stage\wowlet.exe' -Algorithm SHA256).Hash)" - name: package both zips # Two archives ship, deliberately. Authenticode mutates the exe, so a # signed binary can never byte-match what someone reproduces from # source. The unsigned archive stays reproducible and is what verifiers # check; the signed one is what a normal user should download. Both # hashes go in the PGP-signed hashes.txt. shell: powershell run: | $ErrorActionPreference = 'Stop' Add-Type -AssemblyName System.IO.Compression.FileSystem # Not Compress-Archive: Windows PowerShell writes the OS separator # into entry names, producing "tor\tor.exe". The ZIP format mandates # forward slashes, and extractors that take the backslash literally # leave a file called "tor\tor.exe" in the root instead of a tor # directory -- at which point the bundled Tor is not where wowlet # looks for it, and this wallet gives its daemon an exclusive # --tx-proxy, so no Tor means no transaction relay at all. # v5.1.0 shipped tor/tor.exe from `zip -X`; keep that shape. function New-Archive([string] $Source, [string] $Destination) { if (Test-Path $Destination) { Remove-Item $Destination -Force } $root = (Resolve-Path $Source).Path.TrimEnd('\') $zip = [System.IO.Compression.ZipFile]::Open($Destination, 'Create') try { foreach ($file in Get-ChildItem $root -Recurse -File | Sort-Object FullName) { $entry = $file.FullName.Substring($root.Length + 1).Replace('\', '/') [void][System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile( $zip, $file.FullName, $entry, 'Optimal') } } finally { $zip.Dispose() } } New-Archive 'unsigned' 'wowlet-x86_64-windows-unsigned.zip' New-Archive 'stage' 'wowlet-x86_64-windows.zip' # Prove the shape rather than trusting it: a backslash in any entry # name fails the build. foreach ($archive in @('wowlet-x86_64-windows.zip', 'wowlet-x86_64-windows-unsigned.zip')) { $zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $archive)) try { $names = $zip.Entries | ForEach-Object { $_.FullName } if ($names -match '\\') { throw "$archive contains a backslash entry name: $($names -join ', ')" } if ($names -notcontains 'tor/tor.exe') { throw "$archive is missing tor/tor.exe (got: $($names -join ', '))" } } finally { $zip.Dispose() } "{0} {1} {2}" -f $archive, (Get-Item $archive).Length, (Get-FileHash $archive -Algorithm SHA256).Hash } - name: upload Windows zips uses: actions/upload-artifact@v3 with: name: wowlet-x86_64-windows path: | wowlet-x86_64-windows.zip wowlet-x86_64-windows-unsigned.zip if-no-files-found: error