Skip to content

ci: build PoxChat for Windows on a runner - #8

Merged
rubinlinux merged 17 commits into
masterfrom
ci/windows-build
Aug 23, 2026
Merged

ci: build PoxChat for Windows on a runner#8
rubinlinux merged 17 commits into
masterfrom
ci/windows-build

Conversation

@rubinlinux

Copy link
Copy Markdown
Member

Builds the GTK4 tree for Windows on a GitHub runner and uploads a portable
x64 zip. The last green run produced PoxChat-3.0.0~alpha2-x64-portable.zip
— 39 MiB compressed, both executables, seven plugin DLLs — and ran the
binaries out of the staged tree to prove it.

What's here

.github/workflows/windows-build.yml plus three PowerShell scripts under
win32/ci/:

build-deps.ps1 The whole dependency stack into C:\gtk-build — gvsbuild for GTK4/OpenSSL/libxml2/sqlite/libcurl/enchant/gettext, static jansson and libwebsockets, prebuilt WinSparkle, CA bundle
check-imports.ps1 Walks the staged tree's PE imports and names every DLL it is missing
make-installer.ps1 Generates poxchat.iss from its template and runs Inno Setup

docs/areas/windows-ci-build.md covers how to work on this without spending a
runner cycle per question.

Tree fixes, not just CI plumbing

Getting to green turned up six real defects that had nothing to do with the
runner:

  • crypt32.lib missing from DepLibs — a static libwebsockets leaves its
    Windows certificate-store imports for whoever links it. This was the last
    thing between the tree and a linking poxchat.exe.
  • fe-text's stubs had drifted from fe.hfe_get_str returned void
    against a void * prototype, and fe_set_batch_mode had no stub at all
    while chathistory.c calls it on every replayed batch.
  • $(DepLibs)' library search path lived only in fe-gtk.vcxproj — the
    other thirteen projects that link the same list could not find jansson or
    libwebsockets, which sit outside the gvsbuild prefix. Moved to the props'
    own Link defaults, beside the list it belongs to.
  • WinSparkle's headers were copied by name, missing the version header
    winsparkle.h includes.
  • sqlite3.dll, psl-5.dll, the ICU chain and libenchant.dll were never
    staged
    sqlite3.lib is gvsbuild's import library, not a static build.
  • The GTK3-era MS-Windows theme was still referenced by the installer and
    the staging project; GTK4 has no theme engine.

Two steps that exist to batch discovery

A warm run is ~15 minutes, so anything that learns one fact per run is the
slow path. The prefix inventory lists what gvsbuild actually installed, because
it does not always spell import libraries the way DepLibs asks for them. The
import check reads PE import tables directly — Windows reports a missing
dependency as STATUS_DLL_NOT_FOUND with no name attached — and follows a
missing DLL into the dependency prefix so a chain like
libcurl.dll -> psl-5.dll -> icuuc78.dll comes back in one pass.

check-imports.ps1 was validated against an independently written reference
implementation over 71 real binaries, 32- and 64-bit, before it was trusted.

Deliberately left out

lua (gvsbuild cannot build LuaJIT — it runs .\msvcbuild through
CreateProcess, which will not launch a .bat), and perl, python3 and
htm, which need matching toolchains on the runner. The installer names those
payloads unconditionally across [Files], [Components], [Registry],
[Run], [Icons] and the Pascal [Code] section, so -f installer=true
fails at Inno Setup until they build. It stays behind its opt-in flag; the
portable zip uploads before that step runs, so nothing else is affected.

Worth knowing

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 this workflow the only thing that notices when
its stubs drift out of step with src/common/fe.h.

The first run on this PR pays for a cold dependency stack — roughly 45–75
minutes — because a pull request run reads caches from its base branch, not
from the topic branch this was iterated on.

🤖 Generated with Claude Code

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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
…er 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.
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.
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.
@rubinlinux
rubinlinux merged commit cc34640 into master Aug 23, 2026
4 checks passed
@rubinlinux
rubinlinux deleted the ci/windows-build branch August 23, 2026 22:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant