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: | # This job and the Linux one land on the same host in host mode, and a # fixed /tmp path let each wipe the other's binary mid-build. Scope the # extract to the run. EMBED="/tmp/wowlet-${GITHUB_RUN_ID:-local}" rm -rf "$EMBED/wownerod-embed" && mkdir -p "$EMBED/wownerod-embed" curl -fL -o "$EMBED/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 "$EMBED/wownero-win.zip" 'wownero-x86_64-w64-mingw32-v0.11.4.0/wownerod.exe' -d "$EMBED/wownerod-embed" ls -la "$EMBED/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/wowlet-${GITHUB_RUN_ID:-local}/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/wowlet.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 - name: publish the Windows zips to the tag's release # Same reason as the Linux and macOS jobs: artifacts upload fine, but # this Gitea's REST listing reports zero for every run, so the signed exe # cannot be fetched back through the API. Release assets can be. # # This runs after the Authenticode, timestamp and signer checks above, so # an unsigned or untimestamped binary never reaches a release. curl.exe, # not Invoke-WebRequest: `shell: powershell` is Windows PowerShell 5.1, # whose Invoke-WebRequest has no -Form for multipart uploads. # # Fail-safe: a build that produced a correctly signed binary must not go # red because publishing failed, so every bad path reports and exits 0. if: startsWith(github.ref, 'refs/tags/') shell: powershell env: RELEASE_TOKEN: ${{ github.token }} run: | $ErrorActionPreference = 'Continue' if (-not $env:RELEASE_TOKEN) { Write-Host "no token available to this runner, skipping the release upload" exit 0 } $tag = $env:GITHUB_REF -replace '^refs/tags/', '' $server = if ($env:GITHUB_SERVER_URL) { $env:GITHUB_SERVER_URL } else { 'https://git.such.software' } $api = "$server/api/v1/repos/$env:GITHUB_REPOSITORY" $auth = "Authorization: token $env:RELEASE_TOKEN" $rid = '' try { $rid = ((curl.exe -sS -H $auth "$api/releases/tags/$tag") | ConvertFrom-Json).id } catch { $rid = '' } if (-not $rid) { try { $body = "{""tag_name"":""$tag"",""name"":""wowlet $tag""}" $rid = ((curl.exe -sS -H $auth -H "Content-Type: application/json" -X POST -d $body "$api/releases") | ConvertFrom-Json).id } catch { $rid = '' } } if (-not $rid) { Write-Host "could not resolve or create a release for $tag, skipping the upload" exit 0 } foreach ($f in @('wowlet-x86_64-windows.zip', 'wowlet-x86_64-windows-unsigned.zip')) { if (-not (Test-Path $f)) { Write-Host "missing $f, nothing to publish for it"; continue } $code = curl.exe -sS -o NUL -w '%{http_code}' -H $auth -F "attachment=@$f" "$api/releases/$rid/assets?name=$f" Write-Host "release asset upload for $f returned HTTP $code" }