From 9a7535ebcd191612c40684bf40daf58ef5bd4225 Mon Sep 17 00:00:00 2001 From: Alex Schumann Date: Sat, 22 Aug 2026 21:12:08 -0700 Subject: [PATCH 01/17] ci: build Windows installer and portable artifacts on a runner The VS solution already knows how to produce both: win32/copy stages a self-contained tree (which is the portable layout, marker file and all) and win32/installer templates poxchat.iss and runs Inno Setup over it. What was missing was a machine to do it on and a way to get the dependencies there. build-deps.ps1 provisions the stack poxchat.props expects: gvsbuild for GTK4, OpenSSL, libxml2, sqlite, luajit, libcurl, enchant and gettext (win32/nls needs its msgfmt), plus jansson and libwebsockets, which gvsbuild has no projects for and which have to be static since DepLibs names the .libs and copy.vcxproj ships no DLLs for either. A cold run compiles GTK for an hour or two; the workflow caches the install prefixes so that happens once per version. CI overrides the props UserMacros from the command line rather than editing them, keeping that file environment-specific as intended. The build is split into named steps so a failure names the piece that broke, and perl, python3 and htm are left out until the rest is green. Nothing here has run yet: MSVC has likely not seen this tree since the GTK4 port. --- .github/workflows/windows-build.yml | 162 +++++++++++++++++ .github/workflows/windows-build.yml.disabled | 79 -------- win32/ci/build-deps.ps1 | 179 +++++++++++++++++++ win32/ci/make-installer.ps1 | 83 +++++++++ 4 files changed, 424 insertions(+), 79 deletions(-) create mode 100644 .github/workflows/windows-build.yml delete mode 100644 .github/workflows/windows-build.yml.disabled create mode 100644 win32/ci/build-deps.ps1 create mode 100644 win32/ci/make-installer.ps1 diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml new file mode 100644 index 00000000..6d625ba6 --- /dev/null +++ b/.github/workflows/windows-build.yml @@ -0,0 +1,162 @@ +name: Windows Build + +on: + push: + # TEMPORARY: ci/** is here so this can be iterated on without merging to + # master first. Drop it once the build is green. + branches: [master, 'ci/**'] + pull_request: + branches: [master] + workflow_dispatch: + inputs: + installer: + description: Also build the Inno Setup installer + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: windows-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: pwsh + +env: + # Bump to invalidate the dependency cache without editing build-deps.ps1 -- + # e.g. when a new gvsbuild release should be picked up. + DEPS_CACHE_EPOCH: '1' + BUILD_DIR: ${{ github.workspace }}\poxchat-build + + # poxchat.props keeps its UserMacros environment-specific on purpose, so CI + # overrides them from the command line rather than editing the file. + # PoxChatBuild moves the output tree inside the workspace; its default puts it + # a level above the checkout. + MSBUILD_PROPS: >- + /nologo /m /v:minimal + /p:Configuration=Release + /p:Platform=x64 + /p:PoxChatBuild=${{ github.workspace }}\poxchat-build + /p:YourDepsPath=C:\gtk-build\gtk + /p:YourOpenSSLPath=C:\gtk-build\gtk\x64\release + /p:YourLibCurlPath=C:\gtk-build\gtk\x64\release + /p:YourJanssonPath=C:\gtk-build\jansson + /p:YourLibWebSocketsPath=C:\gtk-build\libwebsockets + /p:YourWinSparklePath=C:\gtk-build\WinSparkle + /p:YourCACertPath=C:\gtk-build\cert\cacert.pem + +jobs: + build: + runs-on: windows-2022 + timeout-minutes: 240 + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + # A cold build of the GTK stack takes 1-2 hours; a warm one restores in + # minutes. Only the install prefixes are cached, not gvsbuild's sources + # and intermediates, which are far larger and of no use to us. + - name: Restore dependency stack + id: deps-cache + uses: actions/cache@v4 + with: + path: | + C:\gtk-build\gtk\x64\release + C:\gtk-build\jansson + C:\gtk-build\libwebsockets + C:\gtk-build\WinSparkle + C:\gtk-build\cert + key: win-deps-${{ env.DEPS_CACHE_EPOCH }}-${{ hashFiles('win32/ci/build-deps.ps1') }} + + - name: Build dependency stack + if: steps.deps-cache.outputs.cache-hit != 'true' + run: .\win32\ci\build-deps.ps1 + + - uses: microsoft/setup-msbuild@v2 + + - name: Read version + id: version + run: | + $line = Select-String -Path meson.build -Pattern "^\s+version: '([^']+)'," | Select-Object -First 1 + if (-not $line) { throw 'no version in meson.build' } + "version=$($line.Matches[0].Groups[1].Value)" >> $env:GITHUB_OUTPUT + + # Split by target so a failure names the piece that broke. perl, python3 + # and htm (the C# theme manager) are left out until the rest is green: + # they need a matching Strawberry Perl / CPython / .NET on the runner. + - name: Build core + run: msbuild win32\poxchat.sln ${{ env.MSBUILD_PROPS }} /t:"poxchat\common;poxchat\fe-gtk;poxchat\fe-text" + + - name: Build plugins + run: msbuild win32\poxchat.sln ${{ env.MSBUILD_PROPS }} /t:"plugins\checksum;plugins\exec;plugins\fishlim;plugins\sysinfo;plugins\winamp;plugins\upd;plugins\notifications-winrt;scripting\lua;external\libenchant_win8" + + - name: Stage the distributable tree + run: msbuild win32\poxchat.sln ${{ env.MSBUILD_PROPS }} /t:"release\nls;release\copy" + + # The staged tree is what ships, so run the binaries from it: a missing + # DLL kills the process immediately with STATUS_DLL_NOT_FOUND (0xC0000135) + # rather than showing up later as a user unzipping a broken build. + # + # poxchat-text.exe --version prints and exits. poxchat.exe --version puts + # the string in a modal dialog on Windows, so surviving past gtk_init is + # the pass condition there, and we kill it afterwards. + - name: Smoke test the staged tree + run: | + $rel = "${{ env.BUILD_DIR }}\x64\rel" + + $text = Join-Path $rel 'poxchat-text.exe' + if (-not (Test-Path $text)) { throw 'poxchat-text.exe missing from the staged tree' } + & $text --version + if ($LASTEXITCODE -ne 0) { + throw ("poxchat-text.exe --version exited with 0x{0:X8}" -f $LASTEXITCODE) + } + + $gui = Join-Path $rel 'poxchat.exe' + if (-not (Test-Path $gui)) { throw 'poxchat.exe missing from the staged tree' } + $proc = Start-Process -FilePath $gui -ArgumentList '--version' -PassThru + if ($proc.WaitForExit(30000)) { + if ($proc.ExitCode -ne 0) { + throw ("poxchat.exe --version exited with 0x{0:X8}" -f $proc.ExitCode) + } + } else { + Write-Host 'poxchat.exe reached its version dialog; killing it' + $proc.Kill() + } + + # copy.vcxproj already drops a portable-mode marker in the tree, which is + # what makes it keep its config beside the binary. + - name: Package portable zip + id: portable + run: | + $rel = "${{ env.BUILD_DIR }}\x64\rel" + $name = "PoxChat-${{ steps.version.outputs.version }}-x64-portable.zip" + $out = Join-Path "${{ github.workspace }}" $name + 7z a -tzip -mx=7 "$out" "$rel\*" | Out-Null + if ($LASTEXITCODE -ne 0) { throw "7z failed with exit code $LASTEXITCODE" } + "path=$out" >> $env:GITHUB_OUTPUT + + - name: Build installer + if: inputs.installer + run: .\win32\ci\make-installer.ps1 -BuildDir "${{ env.BUILD_DIR }}" -Provision + + - name: Upload portable build + uses: actions/upload-artifact@v4 + with: + name: poxchat-x64-portable + path: ${{ steps.portable.outputs.path }} + + - name: Upload installer + if: inputs.installer + uses: actions/upload-artifact@v4 + with: + name: poxchat-x64-installer + path: ${{ env.BUILD_DIR }}\x64\*.exe diff --git a/.github/workflows/windows-build.yml.disabled b/.github/workflows/windows-build.yml.disabled deleted file mode 100644 index b50c5c9f..00000000 --- a/.github/workflows/windows-build.yml.disabled +++ /dev/null @@ -1,79 +0,0 @@ -# Disabled during PoxChat rename / GTK4 migration. Re-enable once builds work. -# -# name: Windows Build -# on: -# push: -# branches: -# - master -# pull_request: -# branches: -# - master -# -# jobs: -# windows_build: -# runs-on: windows-2019 -# strategy: -# matrix: -# platform: [x64, win32] -# arch: [x64, x86] -# exclude: -# - platform: x64 -# arch: x86 -# - platform: win32 -# arch: x64 -# fail-fast: false -# -# steps: -# - uses: actions/checkout@v4 -# with: -# submodules: recursive -# -# - name: Install Dependencies -# run: | -# New-Item -Name "deps" -ItemType "Directory" -# -# Invoke-WebRequest http://files.jrsoftware.org/is/5/innosetup-5.5.9-unicode.exe -OutFile deps\innosetup-unicode.exe -# & deps\innosetup-unicode.exe /VERYSILENT | Out-Null -# -# Invoke-WebRequest https://github.com/hexchat/gvsbuild/releases/download/hexchat-2.16.2/idpsetup-1.5.1.exe -OutFile deps\idpsetup.exe -# & deps\idpsetup.exe /VERYSILENT -# -# Invoke-WebRequest https://github.com/hexchat/gvsbuild/releases/download/hexchat-2.16.2/gtk-${{ matrix.platform }}-2018-08-29-openssl1.1.7z -OutFile deps\gtk-${{ matrix.arch }}.7z -# & 7z.exe x deps\gtk-${{ matrix.arch }}.7z -oC:\gtk-build\gtk -# -# Invoke-WebRequest https://github.com/hexchat/gvsbuild/releases/download/hexchat-2.16.2/gendef-20111031.7z -OutFile deps\gendef.7z -# & 7z.exe x deps\gendef.7z -oC:\gtk-build -# -# Invoke-WebRequest https://github.com/hexchat/gvsbuild/releases/download/hexchat-2.16.2/WinSparkle-20151011.7z -OutFile deps\WinSparkle.7z -# & 7z.exe x deps\WinSparkle.7z -oC:\gtk-build\WinSparkle -# -# Invoke-WebRequest https://github.com/hexchat/gvsbuild/releases/download/hexchat-2.16.2/perl-5.20.0-${{ matrix.arch }}.7z -OutFile deps\perl-${{ matrix.arch }}.7z -# & 7z.exe x deps\perl-${{ matrix.arch }}.7z -oC:\gtk-build\perl-5.20\${{ matrix.platform }} -# -# New-Item -Path "c:\gtk-build" -Name "python-3.8" -ItemType "Directory" -# New-Item -Path "c:\gtk-build\python-3.8" -Name "${{ matrix.platform }}" -ItemType "SymbolicLink" -Value "C:/hostedtoolcache/windows/Python/3.8.10/${{ matrix.arch }}" -# -# C:/hostedtoolcache/windows/Python/3.8.10/${{ matrix.arch }}/python.exe -m pip install cffi -# shell: powershell -# -# - name: Build -# run: | -# call "C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\Common7\Tools\VsDevCmd.bat" -# msbuild win32\poxchat.sln /m /verbosity:minimal /p:Configuration=Release /p:Platform=${{ matrix.platform }} -# shell: cmd -# -# - name: Preparing Artifacts -# run: | -# move ..\poxchat-build\${{ matrix.platform }}\PoxChat*.exe .\ -# move ..\poxchat-build .\ -# shell: cmd -# -# - uses: actions/upload-artifact@v2 -# with: -# name: Installer ${{ matrix.arch }} -# path: PoxChat*.exe -# -# - uses: actions/upload-artifact@v2 -# with: -# name: Build Files ${{ matrix.arch }} -# path: poxchat-build diff --git a/win32/ci/build-deps.ps1 b/win32/ci/build-deps.ps1 new file mode 100644 index 00000000..5390ae13 --- /dev/null +++ b/win32/ci/build-deps.ps1 @@ -0,0 +1,179 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Build the Windows dependency stack that win32/poxchat.props expects. + +.DESCRIPTION + Everything lands under -BuildRoot (default C:\gtk-build), in the layout the + solution's UserMacros point at: + + gtk\\release gvsbuild prefix: GTK4, OpenSSL, libxml2, sqlite, + luajit, libcurl, enchant, and gettext (win32\nls + compiles the .po catalogues with its msgfmt.exe) + jansson\ static jansson -- DepLibs wants jansson.lib and + copy.vcxproj ships no jansson dll + libwebsockets\ static libwebsockets, same reasoning + WinSparkle\\ prebuilt release, for the upd plugin + cert\cacert.pem CA bundle, shipped into the tree as cert.pem + + Nothing outside -BuildRoot is written, so this is safe to run on a developer + box as well as in CI. It is not incremental beyond gvsbuild's own caching: + CI restores the whole tree from actions/cache instead. +#> + +[CmdletBinding()] +param ( + [string] $BuildRoot = 'C:\gtk-build', + [ValidateSet('x64')] + [string] $Platform = 'x64', + # Empty means "latest on PyPI". Pin it when you want cache determinism. + [string] $GvsbuildVersion = '', + [string] $JanssonTag = 'v2.15.1', + [string] $LibWebSocketsTag = 'v4.5.8', + [string] $WinSparkleVersion = '0.9.4', + [string] $CMakeGenerator = 'Visual Studio 17 2022' +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' # Invoke-WebRequest crawls with the progress bar on + +# gvsbuild project names, not pkg-config names. --enable-gi plus lgi give us +# the typelibs and girepository dll the lua plugin and the installer expect. +$GvsbuildProjects = @( + 'gtk4', + 'openssl', + 'libxml2', + 'sqlite', + 'luajit', + 'libcurl', + 'enchant', + 'gettext', + 'lgi' +) + +$prefix = Join-Path $BuildRoot "gtk\$Platform\release" +$srcRoot = Join-Path $BuildRoot 'ci-src' + +function Write-Step ([string] $Name) { + Write-Host '' + Write-Host "=== $Name ===" -ForegroundColor Cyan +} + +function Invoke-Checked ([string] $What, [scriptblock] $Body) { + & $Body + if ($LASTEXITCODE -ne 0) { + throw "$What failed with exit code $LASTEXITCODE" + } +} + +function Get-SourceTree ([string] $Url, [string] $Name) { + New-Item -ItemType Directory -Force -Path $srcRoot | Out-Null + $zip = Join-Path $srcRoot "$Name.zip" + $out = Join-Path $srcRoot $Name + + if (-not (Test-Path $zip)) { + Write-Host "downloading $Url" + Invoke-WebRequest -Uri $Url -OutFile $zip -UseBasicParsing + } + if (-not (Test-Path $out)) { + Expand-Archive -Path $zip -DestinationPath $out -Force + } + + # GitHub source zips nest everything one directory deep. + $children = @(Get-ChildItem -Path $out -Directory) + if ($children.Count -eq 1) { return $children[0].FullName } + return $out +} + +Write-Step 'gvsbuild: GTK4 and friends' +$package = if ($GvsbuildVersion) { "gvsbuild==$GvsbuildVersion" } else { 'gvsbuild' } +Invoke-Checked "pip install $package" { python -m pip install --upgrade --disable-pip-version-check $package } +# --configuration release matters: gvsbuild defaults to debug-optimized, which +# would land the prefix in ...\gtk\x64\debug-optimized instead of ...\release. +Invoke-Checked 'gvsbuild build' { + gvsbuild build ` + --build-dir $BuildRoot ` + --platform $Platform ` + --configuration release ` + --enable-gi ` + @GvsbuildProjects +} + +Write-Step 'jansson (static)' +$janssonSrc = Get-SourceTree "https://github.com/akheron/jansson/archive/refs/tags/$JanssonTag.zip" "jansson-$JanssonTag" +$janssonBuild = Join-Path $janssonSrc 'build-ci' +$janssonPrefix = Join-Path $BuildRoot 'jansson' +Invoke-Checked 'jansson configure' { + cmake -S $janssonSrc -B $janssonBuild -G $CMakeGenerator -A $Platform ` + -DCMAKE_INSTALL_PREFIX="$janssonPrefix" ` + -DJANSSON_BUILD_SHARED_LIBS=OFF ` + -DJANSSON_BUILD_DOCS=OFF ` + -DJANSSON_EXAMPLES=OFF ` + -DJANSSON_WITHOUT_TESTS=ON +} +Invoke-Checked 'jansson build' { cmake --build $janssonBuild --config Release --target install } + +Write-Step 'libwebsockets (static, against gvsbuild OpenSSL)' +$lwsSrc = Get-SourceTree "https://github.com/warmcat/libwebsockets/archive/refs/tags/$LibWebSocketsTag.zip" "libwebsockets-$LibWebSocketsTag" +$lwsBuild = Join-Path $lwsSrc 'build-ci' +$lwsPrefix = Join-Path $BuildRoot 'libwebsockets' +Invoke-Checked 'libwebsockets configure' { + cmake -S $lwsSrc -B $lwsBuild -G $CMakeGenerator -A $Platform ` + -DCMAKE_INSTALL_PREFIX="$lwsPrefix" ` + -DCMAKE_PREFIX_PATH="$prefix" ` + -DLWS_WITH_STATIC=ON ` + -DLWS_WITH_SHARED=OFF ` + -DLWS_WITH_SSL=ON ` + -DLWS_OPENSSL_INCLUDE_DIRS="$prefix\include" ` + -DLWS_OPENSSL_LIBRARIES="$prefix\lib\libssl.lib;$prefix\lib\libcrypto.lib" ` + -DLWS_WITHOUT_TESTAPPS=ON ` + -DLWS_WITHOUT_TEST_SERVER=ON ` + -DLWS_WITHOUT_TEST_SERVER_EXTPOLL=ON ` + -DLWS_WITHOUT_TEST_PING=ON ` + -DLWS_WITHOUT_TEST_CLIENT=ON ` + -DLWS_WITH_MINIMAL_EXAMPLES=OFF +} +Invoke-Checked 'libwebsockets build' { cmake --build $lwsBuild --config Release --target install } + +# The static build installs websockets_static.lib; DepLibs asks for +# websockets.lib, which is what a shared build would have produced. +$lwsStatic = Join-Path $lwsPrefix 'lib\websockets_static.lib' +if (Test-Path $lwsStatic) { + Copy-Item $lwsStatic (Join-Path $lwsPrefix 'lib\websockets.lib') -Force +} + +Write-Step 'WinSparkle' +$wsPrefix = Join-Path $BuildRoot "WinSparkle\$Platform" +$wsSrc = Get-SourceTree "https://github.com/vslavik/winsparkle/releases/download/v$WinSparkleVersion/WinSparkle-$WinSparkleVersion.zip" "WinSparkle-$WinSparkleVersion" +New-Item -ItemType Directory -Force -Path $wsPrefix | Out-Null + +# The release zip carries every architecture; pick ours by path rather than by +# guessing at the layout, which has moved between releases. +$archPattern = if ($Platform -eq 'x64') { '(?i)(x64|amd64)' } else { '(?i)win32|x86' } +foreach ($file in 'WinSparkle.dll', 'WinSparkle.lib') { + $found = Get-ChildItem -Path $wsSrc -Recurse -Filter $file | + Where-Object { $_.FullName -match $archPattern } | + Select-Object -First 1 + if (-not $found) { + throw "no $Platform $file in the WinSparkle $WinSparkleVersion zip" + } + Copy-Item $found.FullName $wsPrefix -Force +} +foreach ($file in 'winsparkle.h', 'COPYING') { + $found = Get-ChildItem -Path $wsSrc -Recurse -Filter $file | Select-Object -First 1 + if (-not $found) { + throw "no $file in the WinSparkle $WinSparkleVersion zip" + } + Copy-Item $found.FullName $wsPrefix -Force +} + +Write-Step 'CA bundle' +$certDir = Join-Path $BuildRoot 'cert' +New-Item -ItemType Directory -Force -Path $certDir | Out-Null +Invoke-WebRequest -Uri 'https://curl.se/ca/cacert.pem' -OutFile (Join-Path $certDir 'cacert.pem') -UseBasicParsing + +Write-Step 'done' +Write-Host "gvsbuild prefix: $prefix" +Write-Host "jansson: $janssonPrefix" +Write-Host "libwebsockets: $lwsPrefix" +Write-Host "WinSparkle: $wsPrefix" diff --git a/win32/ci/make-installer.ps1 b/win32/ci/make-installer.ps1 new file mode 100644 index 00000000..0a50f7e3 --- /dev/null +++ b/win32/ci/make-installer.ps1 @@ -0,0 +1,83 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Generate poxchat.iss from its template and compile it with Inno Setup. + +.DESCRIPTION + win32\installer\installer.vcxproj does this as a pre-build event; doing it + here instead keeps the quoting sane and lets CI build the installer as a + separate, skippable step. + + poxchat.iss.tt targets Inno Setup 5 and #includes idp.iss from the Inno + Download Plugin (which fetches vcredist/perl/python at install time). The + hosted runners ship Inno Setup 6 and no idp, so -Provision installs both: + IS5 from jrsoftware, idp from the mirror hexchat kept on its gvsbuild + releases, the upstream host having gone away years ago. + + The compiled installer lands next to the staged tree, in \x64\, + per the iss file's own OutputDir. + +.NOTES + Inno Setup's /d switch cannot take a quoted value ending in a backslash, so + -RepoRoot must not contain spaces. +#> + +[CmdletBinding()] +param ( + [string] $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path, + [Parameter(Mandatory = $true)] + [string] $BuildDir, + [string] $Platform = 'x64', + [switch] $Provision, + [string] $InnoSetupUrl = 'https://files.jrsoftware.org/is/5/innosetup-5.6.1-unicode.exe', + [string] $IdpUrl = 'https://github.com/hexchat/gvsbuild/releases/download/hexchat-2.16.2/idpsetup-1.5.1.exe' +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +$iscc = Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 5\ISCC.exe' + +if ($Provision -and -not (Test-Path $iscc)) { + $tmpBase = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { $env:TEMP } + $tmp = Join-Path $tmpBase 'inno' + New-Item -ItemType Directory -Force -Path $tmp | Out-Null + + Write-Host "installing Inno Setup 5 from $InnoSetupUrl" + $setup = Join-Path $tmp 'innosetup.exe' + Invoke-WebRequest -Uri $InnoSetupUrl -OutFile $setup -UseBasicParsing + Start-Process -FilePath $setup -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -Wait + + Write-Host "installing Inno Download Plugin from $IdpUrl" + $idp = Join-Path $tmp 'idpsetup.exe' + Invoke-WebRequest -Uri $IdpUrl -OutFile $idp -UseBasicParsing + Start-Process -FilePath $idp -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -Wait +} + +if (-not (Test-Path $iscc)) { + throw "Inno Setup 5 not found at $iscc (re-run with -Provision to install it)" +} + +$binDir = Join-Path $BuildDir "$Platform\bin" +$relDir = Join-Path $BuildDir "$Platform\rel" +if (-not (Test-Path $relDir)) { + throw "no staged tree at $relDir -- build the 'copy' project first" +} + +# version-template.ps1 reads the version out of ${SOLUTIONDIR}meson.build, so +# SOLUTIONDIR is the repo root *with* a trailing separator. +$env:SOLUTIONDIR = $RepoRoot.TrimEnd('\') + '\' +$template = Join-Path $RepoRoot 'win32\installer\poxchat.iss.tt' +$iss = Join-Path $binDir 'poxchat.iss' +New-Item -ItemType Directory -Force -Path $binDir | Out-Null +& (Join-Path $RepoRoot 'win32\version-template.ps1') $template $iss + +$projectDir = (Join-Path $RepoRoot 'win32\installer').TrimEnd('\') + '\' +& $iscc /dPROJECTDIR=$projectDir /dAPPARCH=$Platform $iss +if ($LASTEXITCODE -ne 0) { + throw "ISCC failed with exit code $LASTEXITCODE" +} + +Get-ChildItem -Path (Join-Path $BuildDir $Platform) -Filter '*.exe' | ForEach-Object { + Write-Host "built $($_.FullName)" +} From 9d30347694aaeecfc00d82d86fa2cfb6a3633070 Mon Sep 17 00:00:00 2001 From: Alex Schumann Date: Sat, 22 Aug 2026 21:17:27 -0700 Subject: [PATCH 02/17] ci: retry the gvsbuild step past an unreachable upstream host The first run died two minutes in: pixman comes from cairographics.org, a single machine that refused the connection. With one upstream host per project there is always a chance one of them is down, and losing a cold GTK build to that is not worth it. Retries use --fast-build so they resume rather than start over. --- win32/ci/build-deps.ps1 | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/win32/ci/build-deps.ps1 b/win32/ci/build-deps.ps1 index 5390ae13..729c9c6b 100644 --- a/win32/ci/build-deps.ps1 +++ b/win32/ci/build-deps.ps1 @@ -90,13 +90,28 @@ $package = if ($GvsbuildVersion) { "gvsbuild==$GvsbuildVersion" } else { 'gvsbui Invoke-Checked "pip install $package" { python -m pip install --upgrade --disable-pip-version-check $package } # --configuration release matters: gvsbuild defaults to debug-optimized, which # would land the prefix in ...\gtk\x64\debug-optimized instead of ...\release. -Invoke-Checked 'gvsbuild build' { +# +# The tarballs come from as many upstream hosts as there are projects, several +# of them small volunteer servers -- cairographics.org, which serves pixman, is +# a single box that times out regularly. One unreachable host shouldn't cost a +# whole GTK build, so retry; --fast-build makes the retries skip everything that +# already succeeded, and the archives already fetched are kept either way. +$attempts = 3 +for ($attempt = 1; $attempt -le $attempts; $attempt++) { + $extra = if ($attempt -gt 1) { @('--fast-build') } else { @() } gvsbuild build ` --build-dir $BuildRoot ` --platform $Platform ` --configuration release ` --enable-gi ` + @extra ` @GvsbuildProjects + if ($LASTEXITCODE -eq 0) { break } + if ($attempt -eq $attempts) { + throw "gvsbuild build failed with exit code $LASTEXITCODE after $attempts attempts" + } + Write-Warning "gvsbuild attempt $attempt failed with exit code $LASTEXITCODE; retrying" + Start-Sleep -Seconds 30 } Write-Step 'jansson (static)' From b5f06fdd065a305426f826b8959d933490c86574 Mon Sep 17 00:00:00 2001 From: Alex Schumann Date: Sat, 22 Aug 2026 21:24:36 -0700 Subject: [PATCH 03/17] ci: seed pixman from a mirror and fix the gvsbuild retry Two things went wrong on the last run. cairographics.org, which serves pixman, redirects to an HTTPS endpoint that is down -- the runner timed out following the redirect, and so does every other network we tried, so the retry added in the last commit was never going to help. Seed the tarball from Debian instead, whose orig file for pixman is the upstream one byte for byte; the sha256 checked here is the hash gvsbuild itself records, so a substituted file fails loudly. gvsbuild is pinned now so that seeded version cannot drift out from under it. The retry itself was broken: splatting two arrays across backtick continuations fed gvsbuild a bare '-', and it exited on the usage error rather than the download. One flat argument array instead. --- win32/ci/build-deps.ps1 | 68 +++++++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 16 deletions(-) diff --git a/win32/ci/build-deps.ps1 b/win32/ci/build-deps.ps1 index 729c9c6b..0716a2f1 100644 --- a/win32/ci/build-deps.ps1 +++ b/win32/ci/build-deps.ps1 @@ -26,8 +26,10 @@ param ( [string] $BuildRoot = 'C:\gtk-build', [ValidateSet('x64')] [string] $Platform = 'x64', - # Empty means "latest on PyPI". Pin it when you want cache determinism. - [string] $GvsbuildVersion = '', + # Pinned rather than floating: $SeededArchives below names an exact pixman + # tarball, and that has to match the version this gvsbuild asks for. Bump + # them together. + [string] $GvsbuildVersion = '2026.8.0', [string] $JanssonTag = 'v2.15.1', [string] $LibWebSocketsTag = 'v4.5.8', [string] $WinSparkleVersion = '0.9.4', @@ -51,6 +53,22 @@ $GvsbuildProjects = @( 'lgi' ) +# gvsbuild pulls each project's tarball from that project's own upstream, and +# pixman's -- cairographics.org -- redirects to an HTTPS endpoint that is simply +# down: following the redirect times out from the runners and from everywhere +# else we tried. No amount of retrying fixes an endpoint that is off, so seed +# the archive instead. Debian's orig tarball for pixman is the upstream file +# byte for byte -- verified against the hash gvsbuild itself records -- and we +# check the digest here so a substituted file fails loudly rather than quietly +# building something nobody vetted. +$SeededArchives = @( + @{ + Name = 'pixman-0.46.4.tar.gz' + Url = 'http://deb.debian.org/debian/pool/main/p/pixman/pixman_0.46.4.orig.tar.gz' + Sha256 = 'd09c44ebc3bd5bee7021c79f922fe8fb2fb57f7320f55e97ff9914d2346a591c' + } +) + $prefix = Join-Path $BuildRoot "gtk\$Platform\release" $srcRoot = Join-Path $BuildRoot 'ci-src' @@ -90,22 +108,40 @@ $package = if ($GvsbuildVersion) { "gvsbuild==$GvsbuildVersion" } else { 'gvsbui Invoke-Checked "pip install $package" { python -m pip install --upgrade --disable-pip-version-check $package } # --configuration release matters: gvsbuild defaults to debug-optimized, which # would land the prefix in ...\gtk\x64\debug-optimized instead of ...\release. -# -# The tarballs come from as many upstream hosts as there are projects, several -# of them small volunteer servers -- cairographics.org, which serves pixman, is -# a single box that times out regularly. One unreachable host shouldn't cost a -# whole GTK build, so retry; --fast-build makes the retries skip everything that -# already succeeded, and the archives already fetched are kept either way. +$gvsSrc = Join-Path $BuildRoot 'src' +New-Item -ItemType Directory -Force -Path $gvsSrc | Out-Null +foreach ($archive in $SeededArchives) { + $dest = Join-Path $gvsSrc $archive.Name + if (Test-Path $dest) { + Write-Host "$($archive.Name) already seeded" + continue + } + Write-Host "seeding $($archive.Name) from $($archive.Url)" + Invoke-WebRequest -Uri $archive.Url -OutFile $dest -UseBasicParsing + $digest = (Get-FileHash -Path $dest -Algorithm SHA256).Hash + if ($digest -ne $archive.Sha256.ToUpper()) { + Remove-Item $dest -Force + throw "$($archive.Name) from $($archive.Url) hashed $digest, expected $($archive.Sha256)" + } +} + +# The remaining hosts are healthy but numerous, and a build this long shouldn't +# die on one of them blinking. --fast-build lets a retry resume rather than +# start the whole stack again; fetched archives are kept either way. +$gvsArgs = @( + 'build', + '--build-dir', $BuildRoot, + '--platform', $Platform, + '--configuration', 'release', + '--enable-gi' +) $attempts = 3 for ($attempt = 1; $attempt -le $attempts; $attempt++) { - $extra = if ($attempt -gt 1) { @('--fast-build') } else { @() } - gvsbuild build ` - --build-dir $BuildRoot ` - --platform $Platform ` - --configuration release ` - --enable-gi ` - @extra ` - @GvsbuildProjects + $attemptArgs = @($gvsArgs) + if ($attempt -gt 1) { $attemptArgs += '--fast-build' } + $attemptArgs += $GvsbuildProjects + + & gvsbuild @attemptArgs if ($LASTEXITCODE -eq 0) { break } if ($attempt -eq $attempts) { throw "gvsbuild build failed with exit code $LASTEXITCODE after $attempts attempts" From a08bbd404e1a3f93a8d9bffef870aee45e29e7e0 Mon Sep 17 00:00:00 2001 From: Alex Schumann Date: Sat, 22 Aug 2026 21:32:41 -0700 Subject: [PATCH 04/17] ci: seed cairo and hicolor-icon-theme too Same story as pixman, same two dead hosts. cairographics.org serves cairo as well, and icon-theme.freedesktop.org is down the same way, so seed all three from Debian rather than discovering them one failed run at a time. Checked every archive_url in the pinned gvsbuild for hosts that do not answer; these are the ones in our dependency closure. Each hash is what gvsbuild records and what Debian serves. --- win32/ci/build-deps.ps1 | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/win32/ci/build-deps.ps1 b/win32/ci/build-deps.ps1 index 0716a2f1..de079b7e 100644 --- a/win32/ci/build-deps.ps1 +++ b/win32/ci/build-deps.ps1 @@ -54,18 +54,33 @@ $GvsbuildProjects = @( ) # gvsbuild pulls each project's tarball from that project's own upstream, and -# pixman's -- cairographics.org -- redirects to an HTTPS endpoint that is simply -# down: following the redirect times out from the runners and from everywhere -# else we tried. No amount of retrying fixes an endpoint that is off, so seed -# the archive instead. Debian's orig tarball for pixman is the upstream file -# byte for byte -- verified against the hash gvsbuild itself records -- and we -# check the digest here so a substituted file fails loudly rather than quietly -# building something nobody vetted. +# two of those upstreams are simply down: cairographics.org (cairo, pixman) and +# icon-theme.freedesktop.org (hicolor-icon-theme) both time out on connect, from +# the runners and from every other network tried. Retrying cannot revive a host +# that is off, so seed those archives instead. +# +# Debian's orig tarballs are the upstream files byte for byte: every hash below +# is the one gvsbuild itself records for that version, checked against what +# Debian serves. We verify the digest after download too, so a substituted file +# fails loudly rather than quietly building something nobody vetted. +# +# These pin exact versions, which is why $GvsbuildVersion is pinned as well -- +# bump them together, and drop an entry once its upstream comes back. $SeededArchives = @( @{ Name = 'pixman-0.46.4.tar.gz' Url = 'http://deb.debian.org/debian/pool/main/p/pixman/pixman_0.46.4.orig.tar.gz' Sha256 = 'd09c44ebc3bd5bee7021c79f922fe8fb2fb57f7320f55e97ff9914d2346a591c' + }, + @{ + Name = 'cairo-1.18.4.tar.xz' + Url = 'http://deb.debian.org/debian/pool/main/c/cairo/cairo_1.18.4.orig.tar.xz' + Sha256 = '445ed8208a6e4823de1226a74ca319d3600e83f6369f99b14265006599c32ccb' + }, + @{ + Name = 'hicolor-icon-theme-0.18.tar.xz' + Url = 'http://deb.debian.org/debian/pool/main/h/hicolor-icon-theme/hicolor-icon-theme_0.18.orig.tar.xz' + Sha256 = 'db0e50a80aa3bf64bb45cbca5cf9f75efd9348cf2ac690b907435238c3cf81d7' } ) From 8db46bfdc88fa708fd0f1d79e9ec0b60ea10c560 Mon Sep 17 00:00:00 2001 From: Alex Schumann Date: Sat, 22 Aug 2026 22:25:35 -0700 Subject: [PATCH 05/17] ci: drop luajit from the dependency stack, keep partial progress gvsbuild builds luajit by handing '.\msvcbuild' to CreateProcess, which cannot launch a .bat file and finds nothing under that name without the extension. It failed identically on all three attempts, 45 minutes into a cold stack, right after OpenSSL finished. Nothing on our side can fix that, so luajit is out, and with it lgi (which depends on it), --enable-gi (which exists for lgi's typelibs), and the lua plugin. Re-adding means building LuaJIT here as jansson and libwebsockets already are, and settling what the installer script does about the lua and lgi files it names unconditionally. Meanwhile every failure was costing the next run the three quarters of an hour it had already spent, because the cache only saves on success. Roll it instead: save the whole build tree on any outcome under a per-run key, restore the newest previous one, and always run the dependency step -- gvsbuild's --fast-build skips whatever is already marked built, so a complete stack is a no-op and a partial one resumes. --- .github/workflows/windows-build.yml | 44 +++++++++++++++++++---------- win32/ci/build-deps.ps1 | 23 ++++++++++----- 2 files changed, 45 insertions(+), 22 deletions(-) diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index 6d625ba6..e2f4b1b9 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -62,25 +62,37 @@ jobs: with: python-version: '3.12' - # A cold build of the GTK stack takes 1-2 hours; a warm one restores in - # minutes. Only the install prefixes are cached, not gvsbuild's sources - # and intermediates, which are far larger and of no use to us. + # TEMPORARY, like the ci/** trigger: a rolling cache of the whole + # C:\gtk-build tree -- install prefix, intermediates and archives -- keyed + # per run and restored from the newest previous one. A cold GTK stack is + # three quarters of an hour, and without this every failure part way + # through pays that again from scratch. The intermediates are in here + # because gvsbuild's --fast-build decides what to skip by reading + # .wingtk-built markers out of the build tree, not the prefix. The key + # deliberately omits the script hash, so editing build-deps.ps1 still + # resumes; bump DEPS_CACHE_EPOCH for a clean slate. Once the build is + # green this becomes a plain actions/cache keyed on the script hash. - name: Restore dependency stack - id: deps-cache - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: - path: | - C:\gtk-build\gtk\x64\release - C:\gtk-build\jansson - C:\gtk-build\libwebsockets - C:\gtk-build\WinSparkle - C:\gtk-build\cert - key: win-deps-${{ env.DEPS_CACHE_EPOCH }}-${{ hashFiles('win32/ci/build-deps.ps1') }} - + path: C:\gtk-build + key: win-deps-${{ env.DEPS_CACHE_EPOCH }}-${{ github.run_id }} + restore-keys: | + win-deps-${{ env.DEPS_CACHE_EPOCH }}- + + # Always run, never skipped on a cache hit: with a complete stack restored + # this is a quick no-op, and it is the only thing that can tell a complete + # stack from a half-built one. - name: Build dependency stack - if: steps.deps-cache.outputs.cache-hit != 'true' run: .\win32\ci\build-deps.ps1 + - name: Save dependency stack + if: always() + uses: actions/cache/save@v4 + with: + path: C:\gtk-build + key: win-deps-${{ env.DEPS_CACHE_EPOCH }}-${{ github.run_id }} + - uses: microsoft/setup-msbuild@v2 - name: Read version @@ -93,11 +105,13 @@ jobs: # Split by target so a failure names the piece that broke. perl, python3 # and htm (the C# theme manager) are left out until the rest is green: # they need a matching Strawberry Perl / CPython / .NET on the runner. + # lua is out too, for want of a LuaJIT gvsbuild can build -- see + # build-deps.ps1. - name: Build core run: msbuild win32\poxchat.sln ${{ env.MSBUILD_PROPS }} /t:"poxchat\common;poxchat\fe-gtk;poxchat\fe-text" - name: Build plugins - run: msbuild win32\poxchat.sln ${{ env.MSBUILD_PROPS }} /t:"plugins\checksum;plugins\exec;plugins\fishlim;plugins\sysinfo;plugins\winamp;plugins\upd;plugins\notifications-winrt;scripting\lua;external\libenchant_win8" + run: msbuild win32\poxchat.sln ${{ env.MSBUILD_PROPS }} /t:"plugins\checksum;plugins\exec;plugins\fishlim;plugins\sysinfo;plugins\winamp;plugins\upd;plugins\notifications-winrt;external\libenchant_win8" - name: Stage the distributable tree run: msbuild win32\poxchat.sln ${{ env.MSBUILD_PROPS }} /t:"release\nls;release\copy" diff --git a/win32/ci/build-deps.ps1 b/win32/ci/build-deps.ps1 index de079b7e..a2b8e699 100644 --- a/win32/ci/build-deps.ps1 +++ b/win32/ci/build-deps.ps1 @@ -39,18 +39,24 @@ param ( $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' # Invoke-WebRequest crawls with the progress bar on -# gvsbuild project names, not pkg-config names. --enable-gi plus lgi give us -# the typelibs and girepository dll the lua plugin and the installer expect. +# gvsbuild project names, not pkg-config names. gettext is here for msgfmt.exe, +# which win32\nls compiles the .po catalogues with. +# +# luajit is absent, and with it lgi (which depends on it) and --enable-gi (which +# exists to produce lgi's typelibs). gvsbuild builds luajit by running +# '.\msvcbuild' through CreateProcess, which cannot launch a .bat file and finds +# nothing without the extension -- it fails identically every time, so the lua +# plugin is out of the first build. Re-adding it means building LuaJIT here the +# way jansson and libwebsockets are built below; the installer script also names +# lua and lgi files unconditionally, so that has to be settled at the same time. $GvsbuildProjects = @( 'gtk4', 'openssl', 'libxml2', 'sqlite', - 'luajit', 'libcurl', 'enchant', - 'gettext', - 'lgi' + 'gettext' ) # gvsbuild pulls each project's tarball from that project's own upstream, and @@ -143,17 +149,20 @@ foreach ($archive in $SeededArchives) { # The remaining hosts are healthy but numerous, and a build this long shouldn't # die on one of them blinking. --fast-build lets a retry resume rather than # start the whole stack again; fetched archives are kept either way. +# --fast-build from the outset: it skips any project whose .wingtk-built marker +# is already in the build tree, so a restored cache resumes the stack instead of +# recompiling it. Its documented caveat -- stale results if the patches or the +# build script change underneath it -- is covered by pinning $GvsbuildVersion. $gvsArgs = @( 'build', '--build-dir', $BuildRoot, '--platform', $Platform, '--configuration', 'release', - '--enable-gi' + '--fast-build' ) $attempts = 3 for ($attempt = 1; $attempt -le $attempts; $attempt++) { $attemptArgs = @($gvsArgs) - if ($attempt -gt 1) { $attemptArgs += '--fast-build' } $attemptArgs += $GvsbuildProjects & gvsbuild @attemptArgs From ad5b1cadf02a469a7adbaca4b2c55ece566b720d Mon Sep 17 00:00:00 2001 From: Alex Schumann Date: Sat, 22 Aug 2026 23:31:26 -0700 Subject: [PATCH 06/17] ci: point the build at the runner's python common's pre-build event runs make-te.py, glib-genmarshal and the config.h template through $(Python3Path), which poxchat.props sets to C:\Python314 -- one more environment-specific path that only exists on a developer's box. The runner's interpreter comes from setup-python and its location is not known until that step has run, so the msbuild steps pass it rather than the workflow-level property block. Nothing else in the targets we build needs it: copy.vcxproj's use is a wildcard that tolerates absence, and the perl and python3 plugins are not in this build. --- .github/workflows/windows-build.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index e2f4b1b9..5c7592be 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -33,6 +33,9 @@ env: # poxchat.props keeps its UserMacros environment-specific on purpose, so CI # overrides them from the command line rather than editing the file. + # YourPython3Path is not here: common's pre-build event runs make-te.py and + # glib-genmarshal through it, and the interpreter's path is only known once + # setup-python has run, so each msbuild step passes it itself. # PoxChatBuild moves the output tree inside the workspace; its default puts it # a level above the checkout. MSBUILD_PROPS: >- @@ -108,13 +111,13 @@ jobs: # lua is out too, for want of a LuaJIT gvsbuild can build -- see # build-deps.ps1. - name: Build core - run: msbuild win32\poxchat.sln ${{ env.MSBUILD_PROPS }} /t:"poxchat\common;poxchat\fe-gtk;poxchat\fe-text" + run: msbuild win32\poxchat.sln ${{ env.MSBUILD_PROPS }} /p:YourPython3Path=$env:pythonLocation /t:"poxchat\common;poxchat\fe-gtk;poxchat\fe-text" - name: Build plugins - run: msbuild win32\poxchat.sln ${{ env.MSBUILD_PROPS }} /t:"plugins\checksum;plugins\exec;plugins\fishlim;plugins\sysinfo;plugins\winamp;plugins\upd;plugins\notifications-winrt;external\libenchant_win8" + run: msbuild win32\poxchat.sln ${{ env.MSBUILD_PROPS }} /p:YourPython3Path=$env:pythonLocation /t:"plugins\checksum;plugins\exec;plugins\fishlim;plugins\sysinfo;plugins\winamp;plugins\upd;plugins\notifications-winrt;external\libenchant_win8" - name: Stage the distributable tree - run: msbuild win32\poxchat.sln ${{ env.MSBUILD_PROPS }} /t:"release\nls;release\copy" + run: msbuild win32\poxchat.sln ${{ env.MSBUILD_PROPS }} /p:YourPython3Path=$env:pythonLocation /t:"release\nls;release\copy" # The staged tree is what ships, so run the binaries from it: a missing # DLL kills the process immediately with STATUS_DLL_NOT_FOUND (0xC0000135) From 7134c8ee13867b56b4b59eff58b3aafe1e955fa9 Mon Sep 17 00:00:00 2001 From: Alex Schumann Date: Sat, 22 Aug 2026 23:48:01 -0700 Subject: [PATCH 07/17] ci: give libcurl the name poxchat.props links against fe-gtk got all the way to LINK before failing on libcurl.lib: gvsbuild builds libcurl with CMake, which produces libcurl_imp.lib, and it patches libcurl.pc to say -llibcurl_imp for the same reason. DepLibs asks for libcurl.lib, the name curl's own Windows builds use, so copy it to that name the way websockets_static.lib is already handled. That the link reached libcurl at all means jansson.lib and websockets.lib resolved, so the static builds of both are good. Adding an inventory of the prefix so the remaining DepLibs entries can be checked against reality in one run rather than one failed link at a time. --- .github/workflows/windows-build.yml | 12 ++++++++++++ win32/ci/build-deps.ps1 | 13 +++++++++++++ 2 files changed, 25 insertions(+) diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index 5c7592be..7820d957 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -96,6 +96,18 @@ jobs: path: C:\gtk-build key: win-deps-${{ env.DEPS_CACHE_EPOCH }}-${{ github.run_id }} + # poxchat.props names the import libraries it wants in DepLibs, and + # gvsbuild does not always spell them the same way -- listing what is + # actually in the prefix turns a one-name-per-run guessing game into a + # single diff against that list. + - name: Inventory the dependency prefix + run: | + $prefix = 'C:\gtk-build\gtk\x64\release' + Write-Host '--- lib ---' + (Get-ChildItem "$prefix\lib\*.lib" | ForEach-Object Name) -join ' ' + Write-Host '--- bin ---' + (Get-ChildItem "$prefix\bin\*.dll" | ForEach-Object Name) -join ' ' + - uses: microsoft/setup-msbuild@v2 - name: Read version diff --git a/win32/ci/build-deps.ps1 b/win32/ci/build-deps.ps1 index a2b8e699..58c6cd95 100644 --- a/win32/ci/build-deps.ps1 +++ b/win32/ci/build-deps.ps1 @@ -174,6 +174,19 @@ for ($attempt = 1; $attempt -le $attempts; $attempt++) { Start-Sleep -Seconds 30 } +# gvsbuild builds libcurl with CMake, which names the import library +# libcurl_imp.lib -- gvsbuild patches libcurl.pc for exactly this reason. +# DepLibs in poxchat.props asks for libcurl.lib, the name curl's own Windows +# builds use, so give it that name; same fixup as websockets_static.lib below. +$curlImp = Join-Path $prefix 'lib\libcurl_imp.lib' +$curlLib = Join-Path $prefix 'lib\libcurl.lib' +if (-not (Test-Path $curlLib)) { + if (-not (Test-Path $curlImp)) { + throw "neither libcurl.lib nor libcurl_imp.lib in $prefix\lib" + } + Copy-Item $curlImp $curlLib -Force +} + Write-Step 'jansson (static)' $janssonSrc = Get-SourceTree "https://github.com/akheron/jansson/archive/refs/tags/$JanssonTag.zip" "jansson-$JanssonTag" $janssonBuild = Join-Path $janssonSrc 'build-ci' From f4d503256f84c1eee0eb12679842a12ff83997b5 Mon Sep 17 00:00:00 2001 From: Alex Schumann Date: Sun, 23 Aug 2026 09:29:57 -0700 Subject: [PATCH 08/17] win32: link crypt32 for libwebsockets' certificate-store imports fe-gtk reached LINK for the first time and stopped on seven unresolved Cert* symbols out of websockets.lib. libwebsockets reads the Windows system certificate store from windows-sockets.c, and a static build leaves those imports for whoever links it -- crypt32.lib is theirs, not ours, but it has to be on our link line. --- win32/poxchat.props | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/win32/poxchat.props b/win32/poxchat.props index 30fc0330..8ae0483c 100644 --- a/win32/poxchat.props +++ b/win32/poxchat.props @@ -54,7 +54,10 @@ $(DepsRoot)\include\gtk-4.0;$(DepsRoot)\lib\gtk-4.0\include;$(DepsRoot)\include\cairo;$(DepsRoot)\include\harfbuzz;$(DepsRoot)\include\pango-1.0;$(DepsRoot)\include\gdk-pixbuf-2.0;$(DepsRoot)\include\graphene-1.0;$(DepsRoot)\lib\graphene-1.0\include - gtk-4.lib;glib-2.0.lib;gio-2.0.lib;gdk_pixbuf-2.0.lib;pangowin32-1.0.lib;pangocairo-1.0.lib;pango-1.0.lib;cairo.lib;gobject-2.0.lib;gmodule-2.0.lib;glib-2.0.lib;intl.lib;xml2.lib;libcrypto.lib;libssl.lib;graphene-1.0.lib;jansson.lib;websockets.lib;libcurl.lib;sqlite3.lib;wininet.lib;winmm.lib;ws2_32.lib + + gtk-4.lib;glib-2.0.lib;gio-2.0.lib;gdk_pixbuf-2.0.lib;pangowin32-1.0.lib;pangocairo-1.0.lib;pango-1.0.lib;cairo.lib;gobject-2.0.lib;gmodule-2.0.lib;glib-2.0.lib;intl.lib;xml2.lib;libcrypto.lib;libssl.lib;graphene-1.0.lib;jansson.lib;websockets.lib;libcurl.lib;sqlite3.lib;wininet.lib;winmm.lib;ws2_32.lib;crypt32.lib $(SolutionDir)..\data\\ $(SolutionDir)..\..\poxchat-build-gtk4 $(PoxChatBuild)\$(PlatformName)\bin\ From eda73720f4aaf45174d8da19f1fb19bf8a4132fa Mon Sep 17 00:00:00 2001 From: Alex Schumann Date: Sun, 23 Aug 2026 09:30:19 -0700 Subject: [PATCH 09/17] ci: name every DLL the staged tree is missing, in one run The smoke test proves the staged tree runs, but a missing dependency shows up as STATUS_DLL_NOT_FOUND with no name attached, so finding the set costs a runner cycle per file. check-imports.ps1 reads the import and delay-import tables out of the PE headers directly -- no dumpbin, no VC environment -- and reports everything unresolved at once, saying where in the dependency prefix each one can be found. Three gaps the run 7 inventory already showed, fixed here rather than waiting for the checker to rediscover them: sqlite3.dll DepLibs links gvsbuild's import library, not a static sqlite, so the scrollback store needs the DLL psl-5.dll gvsbuild builds libcurl against libpsl libenchant.dll gvsbuild installs it under that name; the entry here asks for libenchant-2.dll and silently skips The prefix inventory lists .exe as well now: copy.vcxproj stages the gspawn helpers unconditionally, so their names matter the same way. --- .github/workflows/windows-build.yml | 12 +- win32/ci/check-imports.ps1 | 203 ++++++++++++++++++++++++++++ win32/copy/copy.vcxproj | 6 + 3 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 win32/ci/check-imports.ps1 diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index 7820d957..e416ddb5 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -106,7 +106,7 @@ jobs: Write-Host '--- lib ---' (Get-ChildItem "$prefix\lib\*.lib" | ForEach-Object Name) -join ' ' Write-Host '--- bin ---' - (Get-ChildItem "$prefix\bin\*.dll" | ForEach-Object Name) -join ' ' + (Get-ChildItem "$prefix\bin\*.dll", "$prefix\bin\*.exe" | ForEach-Object Name) -join ' ' - uses: microsoft/setup-msbuild@v2 @@ -131,6 +131,16 @@ jobs: - name: Stage the distributable tree run: msbuild win32\poxchat.sln ${{ env.MSBUILD_PROPS }} /p:YourPython3Path=$env:pythonLocation /t:"release\nls;release\copy" + # The smoke test below proves the tree runs, but STATUS_DLL_NOT_FOUND does + # not say which DLL is missing, so name the whole set first -- same + # reasoning as the inventory step: one runner cycle per missing file is + # the slow way to find them. + - name: Check the staged tree's imports + run: >- + .\win32\ci\check-imports.ps1 + -Root "${{ env.BUILD_DIR }}\x64\rel" + -Prefix C:\gtk-build\gtk\x64\release\bin,C:\gtk-build\WinSparkle\x64 + # The staged tree is what ships, so run the binaries from it: a missing # DLL kills the process immediately with STATUS_DLL_NOT_FOUND (0xC0000135) # rather than showing up later as a user unzipping a broken build. diff --git a/win32/ci/check-imports.ps1 b/win32/ci/check-imports.ps1 new file mode 100644 index 00000000..01b26a22 --- /dev/null +++ b/win32/ci/check-imports.ps1 @@ -0,0 +1,203 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Name every DLL the staged tree imports but does not carry. + +.DESCRIPTION + Windows has no ldd, and a staged tree missing one dependency reports it as + STATUS_DLL_NOT_FOUND (0xC0000135) the moment the process starts, with no + name attached. The smoke test catches that a build is broken; it cannot say + what is missing, so finding the set costs one runner cycle per file. + + This reads the import and delay-import tables out of the PE headers itself + -- no dumpbin, no VC environment -- and reports everything unresolved at + once, saying where in -Prefix each missing file can be found. + + Resolution follows the loader for the process directory: an import is + satisfied if it sits beside poxchat.exe in -Root, is an API set, or is a + system DLL. DLLs in subdirectories of the tree (plugins, lib\enchant-2) + are scanned too -- their imports resolve against the process directory, not + their own -- so they are checked the same way. +#> + +[CmdletBinding()] +param ( + # The staged tree, i.e. the directory poxchat.exe runs from. + [Parameter(Mandatory = $true)] + [string] $Root, + + # Directories to search when reporting where a missing DLL could come from. + [string[]] $Prefix = @() +) + +$ErrorActionPreference = 'Stop' + +# --- PE reading ------------------------------------------------------------- + +function Get-PeString ($bytes, [int] $offset) +{ + if ($offset -lt 0 -or $offset -ge $bytes.Length) { return $null } + $end = $offset + while ($end -lt $bytes.Length -and $bytes[$end] -ne 0) { $end++ } + return [System.Text.Encoding]::ASCII.GetString($bytes, $offset, $end - $offset) +} + +function Get-PeImports ([string] $Path) +{ + $bytes = [System.IO.File]::ReadAllBytes($Path) + if ($bytes.Length -lt 0x40) { return @() } + if ($bytes[0] -ne 0x4D -or $bytes[1] -ne 0x5A) { return @() } # MZ + + $pe = [BitConverter]::ToInt32($bytes, 0x3C) + if ($pe -le 0 -or $pe + 24 -gt $bytes.Length) { return @() } + if ([BitConverter]::ToUInt32($bytes, $pe) -ne 0x00004550) { return @() } # PE\0\0 + + $sectionCount = [BitConverter]::ToUInt16($bytes, $pe + 6) + $optSize = [BitConverter]::ToUInt16($bytes, $pe + 20) + $opt = $pe + 24 + $magic = [BitConverter]::ToUInt16($bytes, $opt) + + # PE32+ widens ImageBase to 8 bytes and pushes the data directory along + # with it. + if ($magic -eq 0x20B) { + $dirs = $opt + 112 + } elseif ($magic -eq 0x10B) { + $dirs = $opt + 96 + } else { + return @() + } + $dirCount = [BitConverter]::ToUInt32($bytes, $dirs - 4) + + # The section table follows the optional header and is what turns an RVA + # into a file offset. + $sections = @() + $s = $opt + $optSize + for ($i = 0; $i -lt $sectionCount; $i++, $s += 40) { + if ($s + 40 -gt $bytes.Length) { break } + $virtual = [BitConverter]::ToUInt32($bytes, $s + 8) + $raw = [BitConverter]::ToUInt32($bytes, $s + 16) + $sections += [pscustomobject]@{ + Rva = [BitConverter]::ToUInt32($bytes, $s + 12) + Span = [Math]::Max($virtual, $raw) + File = [BitConverter]::ToUInt32($bytes, $s + 20) + } + } + + $toOffset = { + param([uint32] $rva) + foreach ($sec in $sections) { + if ($rva -ge $sec.Rva -and $rva -lt $sec.Rva + $sec.Span) { + return [int]($sec.File + ($rva - $sec.Rva)) + } + } + return -1 + } + + $names = @() + + # Import directory: 20-byte descriptors, DLL name RVA at +12, terminated by + # an all-zero descriptor. + if ($dirCount -gt 1) { + $rva = [BitConverter]::ToUInt32($bytes, $dirs + 8) + if ($rva -ne 0) { + $at = & $toOffset $rva + while ($at -ge 0 -and $at + 20 -le $bytes.Length) { + $nameRva = [BitConverter]::ToUInt32($bytes, $at + 12) + if ($nameRva -eq 0) { break } + $name = Get-PeString $bytes (& $toOffset $nameRva) + if ($name) { $names += $name } + $at += 20 + } + } + } + + # Delay-import directory: 32-byte descriptors, name at +4. Attribute bit 0 + # says the fields are RVAs; the original 1990s format stored virtual + # addresses instead, and nothing that can build this tree emits it, so that + # form is left alone rather than guessed at. + if ($dirCount -gt 13) { + $rva = [BitConverter]::ToUInt32($bytes, $dirs + 13 * 8) + if ($rva -ne 0) { + $at = & $toOffset $rva + while ($at -ge 0 -and $at + 32 -le $bytes.Length) { + $attrs = [BitConverter]::ToUInt32($bytes, $at) + $nameRva = [BitConverter]::ToUInt32($bytes, $at + 4) + if ($nameRva -eq 0) { break } + if (-not ($attrs -band 1)) { + Write-Warning "$Path uses address-based delay imports; not walking them" + break + } + $name = Get-PeString $bytes (& $toOffset $nameRva) + if ($name) { $names += $name } + $at += 32 + } + } + } + + return $names +} + +# --- what counts as resolved ------------------------------------------------ + +$Root = (Resolve-Path -LiteralPath $Root).Path + +$beside = @{} +foreach ($file in Get-ChildItem -LiteralPath $Root -File) { + $beside[$file.Name.ToLowerInvariant()] = $true +} + +# System32 covers the Windows SDK import libraries the tree links against; +# api-ms-win-* / ext-ms-win-* are handled separately below, since the loader +# resolves those from a schema rather than from files on disk. +$system = @{} +foreach ($dir in @("$env:SystemRoot\System32", "$env:SystemRoot\SysWOW64")) { + if (-not (Test-Path -LiteralPath $dir)) { continue } + foreach ($file in Get-ChildItem -LiteralPath $dir -Filter *.dll -File -ErrorAction SilentlyContinue) { + $system[$file.Name.ToLowerInvariant()] = $true + } +} + +# Where a missing DLL could be picked up from, for the report. +$available = @{} +foreach ($dir in $Prefix) { + if (-not (Test-Path -LiteralPath $dir)) { continue } + foreach ($file in Get-ChildItem -LiteralPath $dir -File -ErrorAction SilentlyContinue) { + $key = $file.Name.ToLowerInvariant() + if (-not $available.ContainsKey($key)) { $available[$key] = $file.FullName } + } +} + +# --- walk ------------------------------------------------------------------- + +$binaries = Get-ChildItem -LiteralPath $Root -File -Recurse | + Where-Object { $_.Extension -in '.exe', '.dll', '.pyd' } + +$missing = @{} +foreach ($binary in $binaries) { + foreach ($import in Get-PeImports $binary.FullName) { + $key = $import.ToLowerInvariant() + if ($beside.ContainsKey($key)) { continue } + if ($system.ContainsKey($key)) { continue } + if ($key -match '^(api|ext)-ms-win-') { continue } + if (-not $missing.ContainsKey($key)) { $missing[$key] = @() } + $missing[$key] += $binary.FullName.Substring($Root.Length).TrimStart('\') + } +} + +Write-Host "scanned $($binaries.Count) binaries under $Root" + +if ($missing.Count -eq 0) { + Write-Host 'every import resolves inside the staged tree' + exit 0 +} + +Write-Host '' +Write-Host "$($missing.Count) unresolved:" +foreach ($key in ($missing.Keys | Sort-Object)) { + $from = if ($available.ContainsKey($key)) { $available[$key] } else { 'NOT IN THE DEPENDENCY PREFIX EITHER' } + Write-Host " $key" + Write-Host " wanted by: $(($missing[$key] | Sort-Object -Unique) -join ', ')" + Write-Host " found at: $from" +} + +throw "$($missing.Count) DLL(s) missing from the staged tree" diff --git a/win32/copy/copy.vcxproj b/win32/copy/copy.vcxproj index e056ebb0..4c3d3d8d 100644 --- a/win32/copy/copy.vcxproj +++ b/win32/copy/copy.vcxproj @@ -51,6 +51,9 @@ + + @@ -101,9 +104,12 @@ + + + From 30150098606f656088dd3accb72ef60702a058b1 Mon Sep 17 00:00:00 2001 From: Alex Schumann Date: Sun, 23 Aug 2026 09:52:02 -0700 Subject: [PATCH 10/17] fe-text: catch the stubs up with fe.h MSVC stopped Build core on fe_get_str: fe.h returns void *, because fe-gtk hands maingui.c back the dialog widget so it can close the prompt itself, and the fe-text stub still returned void. fe_set_batch_mode had no stub at all, and chathistory.c calls it on every batch it replays. Neither shows up on Linux: text-frontend defaults to false in meson_options.txt, so nothing compiles this file there. The Windows solution builds it unconditionally. --- src/fe-text/fe-text.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/fe-text/fe-text.c b/src/fe-text/fe-text.c index c74479de..aacf4442 100644 --- a/src/fe-text/fe-text.c +++ b/src/fe-text/fe-text.c @@ -855,9 +855,10 @@ void fe_get_bool (char *title, char *prompt, void *callback, void *userdata) { } -void +void * fe_get_str (char *prompt, char *def, void *callback, void *ud) { + return NULL; } void fe_get_int (char *prompt, int def, void *callback, void *ud) @@ -975,5 +976,6 @@ void fe_scrollback_set_virtual (struct session *sess, void *db, const char *chan int total_entries, gint64 max_rowid) {} void fe_set_pending_db_rowid (struct session *sess, gint64 rowid) {} void fe_resolve_pending_dup (struct session *sess, gint64 old_rowid, gint64 new_rowid) {} +void fe_set_batch_mode (struct session *sess, gboolean on) {} void fe_begin_multiline_group (struct session *sess) {} void fe_end_multiline_group (struct session *sess) {} From 34edd16bd714603b5888251b841ff0e2232dc424 Mon Sep 17 00:00:00 2001 From: Alex Schumann Date: Sun, 23 Aug 2026 10:08:14 -0700 Subject: [PATCH 11/17] win32: put the DepLibs search path where DepLibs is fe-text stopped at LINK on jansson.lib. Every project links the same $(DepLibs), but only fe-gtk listed the prefixes that list names -- the rest search $(DepsRoot)\lib alone, which is the gvsbuild prefix and has neither jansson nor libwebsockets in it. fe-gtk linked in run 8 for that reason and nothing else. So the directories belong beside the library list, in the props' own Link defaults, where every project picks them up. Projects keep their own AdditionalLibraryDirectories, which expand ahead of these. This is also waiting for the plugin step: checksum, sysinfo and upd all link $(DepLibs) with the same lone search path fe-text had. --- win32/poxchat.props | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/win32/poxchat.props b/win32/poxchat.props index 8ae0483c..f1d87549 100644 --- a/win32/poxchat.props +++ b/win32/poxchat.props @@ -106,8 +106,12 @@ true true UseLinkTimeCodeGeneration - - $(OpenSSLLib);%(AdditionalLibraryDirectories) + + $(OpenSSLLib);$(JanssonLib);$(LibWebSocketsLib);$(LibCurlLib);%(AdditionalLibraryDirectories) From 36b1399b15d9c267f609b0ac7525d35c7dc57a7a Mon Sep 17 00:00:00 2001 From: Alex Schumann Date: Sun, 23 Aug 2026 10:30:04 -0700 Subject: [PATCH 12/17] ci: take WinSparkle's whole include directory upd.vcxproj stopped on winsparkle-version.h, which winsparkle.h includes and the header copy did not name. Copy the directory winsparkle.h sits in instead: the plugin compiles against whatever the release ships, not against a list of filenames kept here. The rest of the plugin step got through -- checksum, exec, fishlim, sysinfo and winamp all produced DLLs. --- win32/ci/build-deps.ps1 | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/win32/ci/build-deps.ps1 b/win32/ci/build-deps.ps1 index 58c6cd95..c04a3ec7 100644 --- a/win32/ci/build-deps.ps1 +++ b/win32/ci/build-deps.ps1 @@ -247,13 +247,20 @@ foreach ($file in 'WinSparkle.dll', 'WinSparkle.lib') { } Copy-Item $found.FullName $wsPrefix -Force } -foreach ($file in 'winsparkle.h', 'COPYING') { - $found = Get-ChildItem -Path $wsSrc -Recurse -Filter $file | Select-Object -First 1 - if (-not $found) { - throw "no $file in the WinSparkle $WinSparkleVersion zip" - } - Copy-Item $found.FullName $wsPrefix -Force +# winsparkle.h includes winsparkle-version.h, so take the whole include +# directory rather than naming the headers -- upd.vcxproj compiles against +# whatever this release ships, not a list written here. +$header = Get-ChildItem -Path $wsSrc -Recurse -Filter 'winsparkle.h' | Select-Object -First 1 +if (-not $header) { + throw "no winsparkle.h in the WinSparkle $WinSparkleVersion zip" +} +Copy-Item (Join-Path $header.Directory.FullName '*.h') $wsPrefix -Force + +$copying = Get-ChildItem -Path $wsSrc -Recurse -Filter 'COPYING' | Select-Object -First 1 +if (-not $copying) { + throw "no COPYING in the WinSparkle $WinSparkleVersion zip" } +Copy-Item $copying.FullName $wsPrefix -Force Write-Step 'CA bundle' $certDir = Join-Path $BuildRoot 'cert' From 87e76c8524c773577ab4e74a5e9de38a0670bae2 Mon Sep 17 00:00:00 2001 From: Alex Schumann Date: Sun, 23 Aug 2026 10:50:26 -0700 Subject: [PATCH 13/17] ci: fix the section-table loop in check-imports PowerShell's for statement takes one repeat expression, not C's comma list, so "$i++, $s += 40" was a parse error and the step never ran. Index off the loop counter instead of carrying a cursor. Verified this time rather than guessed at: pwsh in a container parses both CI scripts clean, and the walker now agrees name for name with an independently written reference implementation across 71 real binaries -- 55 PE32+, 11 PE32, and 5 files that are not PEs at all and are correctly skipped. The all-resolved path exits 0. --- win32/ci/check-imports.ps1 | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/win32/ci/check-imports.ps1 b/win32/ci/check-imports.ps1 index 01b26a22..158d76b8 100644 --- a/win32/ci/check-imports.ps1 +++ b/win32/ci/check-imports.ps1 @@ -71,15 +71,15 @@ function Get-PeImports ([string] $Path) # The section table follows the optional header and is what turns an RVA # into a file offset. $sections = @() - $s = $opt + $optSize - for ($i = 0; $i -lt $sectionCount; $i++, $s += 40) { - if ($s + 40 -gt $bytes.Length) { break } - $virtual = [BitConverter]::ToUInt32($bytes, $s + 8) - $raw = [BitConverter]::ToUInt32($bytes, $s + 16) + for ($i = 0; $i -lt $sectionCount; $i++) { + $at = $opt + $optSize + $i * 40 + if ($at + 40 -gt $bytes.Length) { break } + $virtual = [BitConverter]::ToUInt32($bytes, $at + 8) + $raw = [BitConverter]::ToUInt32($bytes, $at + 16) $sections += [pscustomobject]@{ - Rva = [BitConverter]::ToUInt32($bytes, $s + 12) + Rva = [BitConverter]::ToUInt32($bytes, $at + 12) Span = [Math]::Max($virtual, $raw) - File = [BitConverter]::ToUInt32($bytes, $s + 20) + File = [BitConverter]::ToUInt32($bytes, $at + 20) } } From bb597eaf76a4cffcce5ce45a83bd9bd6f8bf760f Mon Sep 17 00:00:00 2001 From: Alex Schumann Date: Sun, 23 Aug 2026 11:18:12 -0700 Subject: [PATCH 14/17] ci: follow missing DLLs into the prefix; stage the ICU chain The checker's first real pass found two things, one of them its own bug. winspool.drv is a System32 file that gtk-4-1.dll binds to, and the system scan was filtered to *.dll -- .drv, .cpl and .ocx are just as bindable, so drop the filter. The other was real: psl-5.dll wants icuuc78.dll. Staging that alone would only have raised whatever ICU pulls in next, one runner cycle later, which is the trap this script exists to avoid -- so a missing DLL found in the prefix now gets walked too, and the report names the whole chain at once. libcurl -> libpsl -> ICU is a heavy chain for what it does; the ICU DLLs are staged by wildcard because their names carry a major version that a literal would silently stop matching. Checked against pwsh in a container before pushing: transitive results agree name for name with an independent reference over a fixture whose dependencies sit two levels deep, and the flat corpus is unchanged. --- win32/ci/check-imports.ps1 | 32 +++++++++++++++++++++++++------- win32/copy/copy.vcxproj | 7 ++++++- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/win32/ci/check-imports.ps1 b/win32/ci/check-imports.ps1 index 158d76b8..4dc02edd 100644 --- a/win32/ci/check-imports.ps1 +++ b/win32/ci/check-imports.ps1 @@ -146,13 +146,14 @@ foreach ($file in Get-ChildItem -LiteralPath $Root -File) { $beside[$file.Name.ToLowerInvariant()] = $true } -# System32 covers the Windows SDK import libraries the tree links against; -# api-ms-win-* / ext-ms-win-* are handled separately below, since the loader -# resolves those from a schema rather than from files on disk. +# System32 covers the Windows SDK import libraries the tree links against. +# Not filtered to *.dll: gtk-4-1.dll binds to winspool.drv, and .cpl and .ocx +# are equally bindable. api-ms-win-* / ext-ms-win-* are handled separately +# below, since the loader resolves those from a schema rather than from files. $system = @{} foreach ($dir in @("$env:SystemRoot\System32", "$env:SystemRoot\SysWOW64")) { if (-not (Test-Path -LiteralPath $dir)) { continue } - foreach ($file in Get-ChildItem -LiteralPath $dir -Filter *.dll -File -ErrorAction SilentlyContinue) { + foreach ($file in Get-ChildItem -LiteralPath $dir -File -ErrorAction SilentlyContinue) { $system[$file.Name.ToLowerInvariant()] = $true } } @@ -172,15 +173,32 @@ foreach ($dir in $Prefix) { $binaries = Get-ChildItem -LiteralPath $Root -File -Recurse | Where-Object { $_.Extension -in '.exe', '.dll', '.pyd' } -$missing = @{} +# A missing DLL has dependencies of its own, and staging it only to find out +# next run what it drags in is the same one-cycle-per-file trap. So when one +# turns up in the prefix, walk it too: the report names the whole chain. +$queue = [System.Collections.Generic.Queue[object]]::new() foreach ($binary in $binaries) { - foreach ($import in Get-PeImports $binary.FullName) { + $queue.Enqueue([pscustomobject]@{ + Path = $binary.FullName + Label = $binary.FullName.Substring($Root.Length).TrimStart('\') + }) +} + +$missing = @{} +$followed = @{} +while ($queue.Count -gt 0) { + $item = $queue.Dequeue() + foreach ($import in Get-PeImports $item.Path) { $key = $import.ToLowerInvariant() if ($beside.ContainsKey($key)) { continue } if ($system.ContainsKey($key)) { continue } if ($key -match '^(api|ext)-ms-win-') { continue } if (-not $missing.ContainsKey($key)) { $missing[$key] = @() } - $missing[$key] += $binary.FullName.Substring($Root.Length).TrimStart('\') + $missing[$key] += $item.Label + if ($available.ContainsKey($key) -and -not $followed.ContainsKey($key)) { + $followed[$key] = $true + $queue.Enqueue([pscustomobject]@{ Path = $available[$key]; Label = $import }) + } } } diff --git a/win32/copy/copy.vcxproj b/win32/copy/copy.vcxproj index 4c3d3d8d..44ba3419 100644 --- a/win32/copy/copy.vcxproj +++ b/win32/copy/copy.vcxproj @@ -108,8 +108,13 @@ - + + + From 3cdb3682606b12e3f48800332b514e623c431c2f Mon Sep 17 00:00:00 2001 From: Alex Schumann Date: Sun, 23 Aug 2026 11:59:13 -0700 Subject: [PATCH 15/17] ci: drop the GTK3-era theme payload; keep the portable zip on installer failure The installer stopped on share\themes\MS-Windows\*, which nothing stages: GTK4 has no theme engine, as the template itself notes twelve lines further down, and gvsbuild's GTK4 ships no such directory. Out of both the installer template and copy.vcxproj. libenchant.dll joins the -2 spelling for the same reason it did in copy.vcxproj -- gvsbuild installs it under the short name, and the installer was shipping without it while the portable zip had it. Also reorder the upload: run 14 built a good portable zip and then threw it away, because the installer step sits between packaging and upload and a failing step skips what follows. --- .github/workflows/windows-build.yml | 8 ++++---- win32/copy/copy.vcxproj | 2 -- win32/installer/poxchat.iss.tt | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index e416ddb5..95436d9d 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -183,16 +183,16 @@ jobs: if ($LASTEXITCODE -ne 0) { throw "7z failed with exit code $LASTEXITCODE" } "path=$out" >> $env:GITHUB_OUTPUT - - name: Build installer - if: inputs.installer - run: .\win32\ci\make-installer.ps1 -BuildDir "${{ env.BUILD_DIR }}" -Provision - - name: Upload portable build uses: actions/upload-artifact@v4 with: name: poxchat-x64-portable path: ${{ steps.portable.outputs.path }} + - name: Build installer + if: inputs.installer + run: .\win32\ci\make-installer.ps1 -BuildDir "${{ env.BUILD_DIR }}" -Provision + - name: Upload installer if: inputs.installer uses: actions/upload-artifact@v4 diff --git a/win32/copy/copy.vcxproj b/win32/copy/copy.vcxproj index 44ba3419..24a04056 100644 --- a/win32/copy/copy.vcxproj +++ b/win32/copy/copy.vcxproj @@ -79,7 +79,6 @@ - @@ -131,7 +130,6 @@ - diff --git a/win32/installer/poxchat.iss.tt b/win32/installer/poxchat.iss.tt index 32a32730..f07a958b 100644 --- a/win32/installer/poxchat.iss.tt +++ b/win32/installer/poxchat.iss.tt @@ -113,7 +113,6 @@ Source: "changelog.url"; DestDir: "{app}"; Flags: ignoreversion; Components: lib Source: "readme.url"; DestDir: "{app}"; Flags: ignoreversion; Components: libs Source: "share\xml\*"; DestDir: "{app}\share\xml"; Flags: ignoreversion createallsubdirs recursesubdirs; Components: libs Source: "share\doc\*"; DestDir: "{app}\share\doc"; Flags: ignoreversion createallsubdirs recursesubdirs; Components: libs -Source: "share\themes\MS-Windows\*"; DestDir: "{app}\share\themes\MS-Windows"; Flags: ignoreversion createallsubdirs recursesubdirs; Components: libs Source: "share\locale\*"; DestDir: "{app}\share\locale"; Flags: ignoreversion createallsubdirs recursesubdirs; Components: translations Source: "etc\fonts\*"; DestDir: "{app}\etc\fonts"; Flags: ignoreversion createallsubdirs recursesubdirs; Components: libs @@ -161,6 +160,7 @@ Source: "plugins\hcnotifications-winrt.dll"; DestDir: "{app}\plugins"; Flags: ig ; Enchant spell checking (optional - may not be installed) Source: "libenchant-2.dll"; DestDir: "{app}"; Flags: ignoreversion skipifsourcedoesntexist; Components: libs +Source: "libenchant.dll"; DestDir: "{app}"; Flags: ignoreversion skipifsourcedoesntexist; Components: libs Source: "lib\enchant-2\*"; DestDir: "{app}\lib\enchant-2"; Flags: ignoreversion skipifsourcedoesntexist; Components: libs ; GTK4 uses CSS theming only - no theme engine DLLs needed From cdb9bd4315da82c984f3c90cfd2601db76e54076 Mon Sep 17 00:00:00 2001 From: Alex Schumann Date: Sun, 23 Aug 2026 12:30:20 -0700 Subject: [PATCH 16/17] docs: how to work on the Windows CI build Records the two batch-discovery steps and why they exist, how to run the PowerShell against real PEs on Linux before pushing, what the build leaves out and why, and the traps that cost a runner cycle each the first time -- fe-text having no Linux coverage, DepLibs' search path, the CRLF files, and the ICU chain's size. --- docs/areas/windows-ci-build.md | 96 ++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 docs/areas/windows-ci-build.md diff --git a/docs/areas/windows-ci-build.md b/docs/areas/windows-ci-build.md new file mode 100644 index 00000000..f3157fdf --- /dev/null +++ b/docs/areas/windows-ci-build.md @@ -0,0 +1,96 @@ +# Windows CI build + +How `.github/workflows/windows-build.yml` and `win32/ci/` fit together, and +how to iterate on them without spending a runner cycle per question. Useful +when the Windows build breaks, when a dependency version moves under it, or +when re-enabling one of the targets it currently leaves out. + +## Shape + +| Piece | Does | +|-------|------| +| `win32/ci/build-deps.ps1` | Builds the whole dependency stack into `C:\gtk-build` — gvsbuild for GTK4/OpenSSL/libxml2/sqlite/libcurl/enchant/gettext, then static jansson and libwebsockets, prebuilt WinSparkle, and the CA bundle | +| `win32/ci/check-imports.ps1` | Walks PE imports of the staged tree and names every DLL it is missing | +| `win32/ci/make-installer.ps1` | Generates `poxchat.iss` from its template and runs Inno Setup 5 | + +A cold dependency stack is about three quarters of an hour; warm, a run is +roughly 15 minutes. That ratio is why the cache key is deliberately per-run +with a `restore-keys` prefix, so a failure part way through still resumes. + +## The rule that matters: never learn one fact per run + +At 15 minutes a cycle, anything that discovers problems one at a time is the +slow path. Two steps exist purely to batch that discovery, and both were added +after paying the toll: + +- **Inventory the dependency prefix** lists every `.lib` and `.exe`/`.dll` in + the gvsbuild prefix. `poxchat.props`'s `DepLibs` names import libraries, and + gvsbuild does not always spell them the same way — `libcurl_imp.lib` vs + `libcurl.lib`, `libenchant.dll` vs `libenchant-2.dll`. Diff the list against + what the build asks for instead of guessing one name per run. +- **Check the staged tree's imports** reads the PE import and delay-import + tables directly. Windows reports a missing dependency as + `STATUS_DLL_NOT_FOUND` (0xC0000135) at process start with no name attached, + so the smoke test can only say *broken*. When a missing DLL turns up in the + dependency prefix the checker follows it and walks its imports too, so a + chain like `libcurl.dll -> psl-5.dll -> icuuc78.dll` is reported in one pass. + +The same instinct applies off the runner. Every `Source:` line in +`win32/installer/poxchat.iss.tt` can be checked against a downloaded portable +zip locally; Inno Setup aborts on the first one that matches nothing, so +auditing all 72 at once is the difference between one cycle and fourteen. + +## Validating the PowerShell without Windows + +Both CI scripts can be exercised on Linux, which is worth doing — a bare +syntax error in a late step costs a full run: + +```sh +# parse check +docker run --rm -v "$PWD/win32/ci:/ci:ro" mcr.microsoft.com/powershell \ + pwsh -NoProfile -Command '$e=$null;$t=$null + [System.Management.Automation.Language.Parser]::ParseFile("/ci/check-imports.ps1",[ref]$t,[ref]$e)|Out-Null + if($e){$e|%{"{0}: {1}" -f $_.Extent.StartLineNumber,$_.Message}}else{"ok"}' + +# behaviour check -- point it at any directory of real PE files +docker run --rm -v "$PWD/win32/ci:/ci:ro" -v /some/dlls:/tree:ro \ + mcr.microsoft.com/powershell pwsh -NoProfile -File /ci/check-imports.ps1 -Root /tree +``` + +`check-imports.ps1` runs fine on Linux: `$env:SystemRoot` is empty there, so +the System32 scan is skipped and every system DLL reports as missing, which is +harmless when what you are checking is the set of imports it discovers. A wine +prefix is a convenient corpus of real 32- and 64-bit PEs to test against. + +Watch for PowerShell-vs-C reflexes. `for ($i = 0; $i -lt $n; $i++, $p += 40)` +is a parse error: the repeat clause takes one expression, not a comma list. + +## Currently left out + +- **lua** — gvsbuild builds LuaJIT by running `.\msvcbuild` through + `CreateProcess`, which cannot launch a `.bat` and finds nothing without the + extension. Taking lua back means building LuaJIT here the way jansson and + libwebsockets are built, and lgi and `--enable-gi` come with it. +- **perl, python3, htm** — need a matching Strawberry Perl / CPython / .NET on + the runner. +- **The installer.** `poxchat.iss.tt` names the lua, perl, python and + thememan payloads unconditionally, across `[Files]`, `[Components]`, + `[Registry]`, `[Run]`, `[Icons]` and the Pascal `[Code]` section. Until + those targets build, `-f installer=true` fails at Inno Setup. The portable + zip uploads before the installer step runs, so this costs nothing else. + +## Traps worth remembering + +- `meson_options.txt` defaults `text-frontend` to **false**, so nothing on + Linux compiles `src/fe-text/fe-text.c`. The Windows solution builds it + unconditionally, which makes a Windows CI run the only thing that notices + when its stubs drift out of step with `src/common/fe.h`. +- Every project links the same `$(DepLibs)`, so the search path for it belongs + in the props' own `Link` defaults, not per project. jansson and libwebsockets + live outside the gvsbuild prefix that `$(DepsRoot)\lib` points at. +- `win32/poxchat.props` and `win32/copy/copy.vcxproj` are CRLF. Edit them as + bytes, or a tool that rewrites line endings turns a two-line change into a + whole-file diff. +- ICU is 35 MB of the staged tree, reached only as + `libcurl -> libpsl -> ICU`, and libpsl is there for public-suffix checks on + cookies. Dropping it means building libcurl without libpsl in gvsbuild. From c5d95709aa2161aa08bc65c47ea5f2d4df776658 Mon Sep 17 00:00:00 2001 From: Alex Schumann Date: Sun, 23 Aug 2026 12:37:21 -0700 Subject: [PATCH 17/17] ci: drop the temporary trigger and the rolling cache The build is green, so the scaffolding that let it be iterated on comes out. Push and pull_request are both master-only now, and the ci/** branch trigger is gone. The dependency cache goes back to what the comment always said it would become: keyed on build-deps.ps1's hash rather than per run, restored and saved by one actions/cache step instead of a restore/save pair that existed to keep partial progress across a failing run. restore-keys stays, so editing the script resumes from the previous stack rather than paying three quarters of an hour for a cold one. --- .github/workflows/windows-build.yml | 38 +++++++++++------------------ docs/areas/windows-ci-build.md | 10 ++++++-- 2 files changed, 22 insertions(+), 26 deletions(-) diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index 95436d9d..a8a0696d 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -2,9 +2,7 @@ name: Windows Build on: push: - # TEMPORARY: ci/** is here so this can be iterated on without merging to - # master first. Drop it once the build is green. - branches: [master, 'ci/**'] + branches: [master] pull_request: branches: [master] workflow_dispatch: @@ -65,37 +63,29 @@ jobs: with: python-version: '3.12' - # TEMPORARY, like the ci/** trigger: a rolling cache of the whole - # C:\gtk-build tree -- install prefix, intermediates and archives -- keyed - # per run and restored from the newest previous one. A cold GTK stack is - # three quarters of an hour, and without this every failure part way - # through pays that again from scratch. The intermediates are in here - # because gvsbuild's --fast-build decides what to skip by reading - # .wingtk-built markers out of the build tree, not the prefix. The key - # deliberately omits the script hash, so editing build-deps.ps1 still - # resumes; bump DEPS_CACHE_EPOCH for a clean slate. Once the build is - # green this becomes a plain actions/cache keyed on the script hash. - - name: Restore dependency stack - uses: actions/cache/restore@v4 + # The whole C:\gtk-build tree: install prefix, intermediates and source + # archives. The intermediates belong in here because gvsbuild's + # --fast-build decides what to skip by reading .wingtk-built markers out + # of the build tree, not out of the prefix. + # + # A cold stack is three quarters of an hour, so restore-keys lets an + # edited build-deps.ps1 resume from the previous one rather than pay that + # again; bump DEPS_CACHE_EPOCH for a deliberate clean slate. + - name: Dependency stack + uses: actions/cache@v4 with: path: C:\gtk-build - key: win-deps-${{ env.DEPS_CACHE_EPOCH }}-${{ github.run_id }} + key: win-deps-${{ env.DEPS_CACHE_EPOCH }}-${{ hashFiles('win32/ci/build-deps.ps1') }} restore-keys: | win-deps-${{ env.DEPS_CACHE_EPOCH }}- # Always run, never skipped on a cache hit: with a complete stack restored # this is a quick no-op, and it is the only thing that can tell a complete - # stack from a half-built one. + # stack from a half-built one -- a partial hit through restore-keys is + # exactly that. - name: Build dependency stack run: .\win32\ci\build-deps.ps1 - - name: Save dependency stack - if: always() - uses: actions/cache/save@v4 - with: - path: C:\gtk-build - key: win-deps-${{ env.DEPS_CACHE_EPOCH }}-${{ github.run_id }} - # poxchat.props names the import libraries it wants in DepLibs, and # gvsbuild does not always spell them the same way -- listing what is # actually in the prefix turns a one-name-per-run guessing game into a diff --git a/docs/areas/windows-ci-build.md b/docs/areas/windows-ci-build.md index f3157fdf..c1098f68 100644 --- a/docs/areas/windows-ci-build.md +++ b/docs/areas/windows-ci-build.md @@ -14,8 +14,14 @@ when re-enabling one of the targets it currently leaves out. | `win32/ci/make-installer.ps1` | Generates `poxchat.iss` from its template and runs Inno Setup 5 | A cold dependency stack is about three quarters of an hour; warm, a run is -roughly 15 minutes. That ratio is why the cache key is deliberately per-run -with a `restore-keys` prefix, so a failure part way through still resumes. +roughly 15 minutes. That ratio is why `C:\gtk-build` is cached whole and keyed +on `build-deps.ps1`'s hash, with a `restore-keys` prefix so that editing the +script resumes from the previous stack instead of rebuilding it. Bump +`DEPS_CACHE_EPOCH` when you want a deliberate clean slate. + +Cache scope is worth knowing: a pull request run can read caches from its base +branch, not from the topic branch the work was done on, so the first PR run +after a branch has been iterating in isolation pays for a cold stack. ## The rule that matters: never learn one fact per run