Files
wowlet/.gitea/workflows/build-windows.yml
T
jwinterm f4bbfd49ad fix(ci): publish the signed Windows zips to the tag's release
The Linux and macOS jobs already attach their tag-build output to the release,
because this Gitea reports zero artifacts for every run through its REST API and
its web download route answers "Not found." under every auth style. The Windows
job was left out, so even a green signed build produced an exe nobody could
fetch back.

The step runs after the Authenticode, RFC 3161 timestamp and signer checks, so
an unsigned or untimestamped binary can never reach a release. Both archives go
up: the signed one users should download, and the unsigned one that stays
byte-reproducible for verifiers.

curl.exe rather than Invoke-WebRequest, because `shell: powershell` is Windows
PowerShell 5.1 and its Invoke-WebRequest has no -Form for multipart uploads.
Fail-safe like the others: no token, no resolvable release or a non-201 upload
reports itself and exits 0 rather than reddening a build that produced a
correctly signed binary.
2026-09-13 18:09:17 -04:00

317 lines
15 KiB
YAML

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"
}