Skip to content

Commit 22e5d56

Browse files
donislawdevclaude
andauthored
gui: a window the driver refused opens with a software renderer shipped beside it on Windows (#109)
* gui: the OpenGL binding is carried as a copy that imports no opengl32.dll at load time The published github.com/go-gl/gl links -lopengl32 and calls wglGetProcAddress as an imported symbol, so the window binary names opengl32.dll in its import table and the loader maps the system's copy before a line of our code runs. Windows hands a library already mapped under a name to every later request for that name, which means a software renderer loaded by path afterwards can never be the opengl32.dll the toolkit finds when it creates its context. Measured on 2026-09-17 with the process's own module list, on a machine with a driver and on a guest with none. go.mod now replaces the module with a copy under third_party/go-gl-gl, reduced to the two packages the window imports, with one change: the Windows branch of GlowGetProcAddress looks wglGetProcAddress up at run time after loading the library by name, and both cgo LDFLAGS lines naming opengl32 are gone. PATCH.md beside the copy says what changed and why, and carries the published version's module sum, because go.sum stops carrying the sum of a replaced module. The CI module list is unchanged - go list reports a replaced module under its own path - and the comment above it says so. Three guards. One downloads the pinned version and holds every file of the copy to the published bytes plus exactly the patch, and the sum in PATCH.md to the sum the toolchain computes. One builds the window binary with cgo on Windows and reads its import table through debug/pe - no opengl32.dll, with gdi32.dll as the canary that the table was read. That one is proven by a probe rather than a mutation entry: broken by hand it went red naming the import, resolved through the -lopengl32 the windowing library links, and the run took 3 min 56 s of recompiling everything above the binding, which the mutation runner's ceiling would cap. Measured before the guard was written and worth stating: a guard reading linker flags would have refused a binary that was already right, because the windowing library links -lopengl32 too and the binary still imports nothing from it. The third guard asks that the walk behind fourteen shape guards leaves a nested module alone, which it now does by the toolchain's own rule - a directory with its own go.mod is outside "./..." - rather than by a name on a list. The registry entry for the module gains a note and THIRD-PARTY-NOTICES.md a paragraph, since the copy is what ships. The blank line that had split the window's module table in two since #103 is closed. sortedKeys in the telemetry guard sorts, which its name has promised since it was written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * gui: a window the driver refused is tried again with the software renderer shipped beside it On a machine whose graphics driver offers no OpenGL 2.1 the window binary refused, since #108, with a sentence and exit code 1. It now tries once more before refusing: the first process starts this program again with the same arguments plus --software-gl, waits, and answers with that process's exit code. The second process, before the toolkit exists, names the driver in its environment and loads Mesa's llvmpipe from opengl/ beside the executable - renderer first, loader second, both by absolute path - so the toolkit's request for OpenGL by name finds it already mapped. Measured on 2026-09-17 on a machine with a driver: started with the flag and no variable in the environment, the window is drawn by the two files beside it and nothing else answers to that name, twice out of twice. Without the files beside it the window opens on the driver and says which file was missing. Without the flag nothing changes. The seam gains the second attempt: OpenOrRefuse asks for one when the window has no native window, and refuses with the reason only when there was none. The decision lives in gui.SecondAttempt with every piece that touches the world as a field, so a guard presses all nine states without a window, a renderer or a process: the second process never starts a third, nothing is tried where nothing ships, the arguments are handed on whole, and the refusal says what became of the renderer - which file is missing, that starting again failed, or that it was tried and did not help. On Linux and macOS it says nothing, by the owner's decision, since nothing ships there. Said out loud, rule 6: the first process writes one line before it starts the second, the second says what it draws with, and the About screen carries the same sentence for as long as the window is open. The Host gains SoftwareRendering for that, and a guard reads the screen both ways. The flag is public and takes the renderer on any machine. The first process hands the driver variable over as well, and the second sets it for itself, because a person asking by hand has no first process. Two guards learn the new file shapes with their reasons: the hardening guard, for a load by a path under the executable's own directory, and the telemetry guard, for the one spawn in this tree - this program, by the path os.Executable answers. That guard also gains os in its list of low level packages: os.StartProcess walked past it until now, found by asking what else the tree could have used. The canary carries the case, and a staleness half now covers the two file keyed registries. Its findings name the file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * legal: the registry gains a third class, for what ships beside a binary rather than in it A module is reported by the build and a font by the compiler that embeds it. The software renderer in the Windows archive is neither: two files the release workflow downloads and puts next to tfg-gui.exe, loaded at run time, which nothing in a binary can report. So the registry gains a Companion: what it is, which binary and which archive carry it, the files in the order the program loads them with their sizes and sums, the licence expression of everything compiled into them, and exactly where the bytes come from - the project, its release, the archive and that archive's sum. Everything in the entry was read from the files of the pinned versions. The archive itself carries no licence file - one readme pointing at a web page - so docs/license.rst and licenses/ of mesa-26.2.0.tar.xz, the build list of pal1000/mesa-dist-win at its 26.2.0 tag, llvm/LICENSE.TXT at llvmorg-22.1.8 and the DirectX-Headers licence at v1.619.5 were read instead, and the renderer's own bytes were asked what they hold: Mesa 26.2.0, LLVM 22.1.8, the llvmpipe, d3d12, zink and softpipe drivers, and no zlib or zstd, whatever stands in the build environment. The GPL marked files in the Mesa tree are Linux kernel headers and two drivers' headers, none compiled on Windows. The expression is MIT AND Apache-2.0 WITH LLVM-exception AND BSL-1.0. Four consumers. The notices gain a section naming the files, their sums, the archive and its sum, and reproduce the two licence texts they did not carry - the LLVM exception and the Boost licence - from the files they came from. The window's About screen names it under a third heading, on every system, because the notices say the same everywhere; the stored picture of that screen is regenerated, one of twenty-six. The command line's list never names it, since nothing ships beside that binary. The bill of materials ships it beside the window as DEPENDS_ON, with the archive's name and sum, and beside nothing else. One guard for each consumer, and one holding the registry's file list to gui.SoftwareFiles - the same files in the same order, or the archive would be packed with something other than what the program looks for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ci: the release puts the software renderer beside the window, signs it, and checks it on the page Two files of Mesa's llvmpipe reach the Windows archive of the window through a download the workflow makes. The release of pal1000/mesa-dist-win to take them from, the SHA-256 of that release's archive and the two files with their own sums stand in .github/mesa-dist-win, and nowhere else the workflow can read. .github/scripts/fetch_software_renderer.sh reads that file, downloads the archive from the project's own releases, checks its sum BEFORE unpacking anything, takes exactly the two files out of x64/, checks each file's sum, and refuses anything but two files under opengl/. Run on this machine: 18 s, every sum agreeing with the registry. Run with a wrong archive sum and with a wrong file sum: refused, exit 1, nothing unpacked in the first case. Both workflows that build the window call it, on Windows only, between the build and the packing - the one that builds from a branch as well, by the owner's decision, so a build from a branch is the build a guest without a driver can be handed. The signing script used to put a subdirectory back EMPTY. It repacked from os.listdir, which names a directory and none of its contents, and zipfile writes a directory entry for a directory and nothing more - measured on an archive shaped like the window's: opengl/ came out with nothing under it, a valid archive, no error. Nothing shipped in a subdirectory until now, so nothing had noticed. It walks every directory now, counts the repacked files against what it unpacked, and signs the libraries beside the program as well as the program, verifying each one's certificate against the pin. The workflow that checks a published release reads the renderer's files out of the registry, asks the window's Windows archive to carry them, and asks every program and every library in every Windows archive who signed it - on the bytes a person downloads. The release notes say the renderer is in the archive, where, and when it is used. Six guards: the pin agrees with the registry, the fetch script checks before it unpacks and carries no copy of the pin, every window build fetches between building and packing, the signing repacks whole and signs the libraries, and the published archive is checked for the renderer. The guard over links leaving this project learns that a workflow's download from somebody else's release is not a place a person is sent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * git: a Python bytecode cache reached a commit, and none may again The signing script was imported by a probe to exercise its repack, which left a cache file beside it that the next commit swept up. Removed, and __pycache__ ignored from here on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * guard: the window build's fetch of the renderer is asked for as a call, not as text anywhere in the file The mutation runner answered the first version with the call commented out: the text was still in the file, the guard was green, and the archive would have shipped without the renderer. It asks for the call at the start of a line now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: the readme, the changelog and the security policy say the Windows window carries a software renderer One paragraph each. The readme says what the folder next to the program is, when the window uses it, what happens without it, and that nothing of the kind ships for Linux or macOS. The changelog entry says the same for a person deciding whether to update, with the version of Mesa and the fact that both files are signed, named in the notices with the sums they were reviewed at, and in the bill of materials. The security policy names the one thing in the Windows archive this project did not write, how the release workflow pins and checks it, and that the program loads it only by absolute path under its own directory and only after the driver refused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * gui: the flag on a system the renderer does not ship for says so, rather than naming the system as an error Found in the review before the pull request: --software-gl on Linux or macOS printed "could not be loaded: linux", the reason type's words in a sentence meant for a file that did not load, and if the driver then refused the refusal would have added that sentence too - on the systems where the owner decided nothing is said about the renderer. A window asked for the renderer now says one of three things about the loading, through one function: that it draws with the renderer, that none ships for this system, or what stood in the way. The refusal on such a system stays as it was. The download address in the registry is registered with the guard over addresses in shipped code: the release workflow fetches from it and the bill of materials names it, the program never does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ci: the dependency gate is told about the copy of the OpenGL binding GitHub's dependency graph reported the directory behind go.mod's replace directive as an added dependency with no licence, and the gate blocked - correctly, for a name nobody had looked at. This one has been: the licence is the copied module's own MIT, carried in the directory, and the bytes are the published module plus one patch, which a guard holds by downloading the pinned version and comparing. The exception names that one directory. A second directory nobody looked at still blocks, and the guard over the gate asks both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * gui: three things the first CI run found that no local subset reads staticcheck on Linux called loadFailed unused, because the one file that raises it is built on Windows alone - the type now lives in that file. gosec and semgrep both flagged the one spawn in this tree, a command built from variables. It is settled where they read it, with the reason: the program is os.Executable and the arguments are this process's own plus one flag. The guard on flag spelling below the surfaces found the flag's name in the registry's note, which names it in words now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * gui: eleven things an outside review of the pull request named, each measured before it was changed The signing script interpolated a file's path into PowerShell text, and a name with a quote in it ran as PowerShell - measured before the change on a copy named "a'; throw 'injected'; #.dll": the throw ran. The path goes through an environment variable now, and a guard refuses the old shape. A file of the renderer that cannot be read was reported as missing, with the advice for a missing file. The look beside the program tells the two apart now, the refusal has a sentence for each, and the look is a function with the question to the file system handed in, so a guard can answer it with a permission error no test can arrange on every disk the same way. Two allowances forgave more than they were written for: the hardening guard let a registered file grow any number of computed loads, and the telemetry guard dropped every spawn and every computed load in a registered file. Each forgives one now, a second is a finding, and a canary holds it. The link guard's exemption for a release download applied to every file, SECURITY.md included - it belongs to the fetch script alone now, and a canary puts the same address into a human facing file and asks for the refusal. The release guards read active lines, with comments taken out, so an operation commented out is an operation gone. The walk behind the shape guards names the nested modules it skipped and the guard asks for exactly one. Guards over tracked files fail rather than skip when the file is missing - the notices, the workflows, the signing script. Three sentences said more than was true: the flag on a system nothing ships for claimed the window was drawn by the driver before the window was shown, and the readme, the changelog, the security policy and the release notes said the renderer was never touched on a machine with a driver, which the flag makes untrue. Each says now exactly what happens. Not changed, with the reason written down: the gles2 package of the binding is not patched, because no shipped binary links it - measured with go list on every release platform - and wrapping the restart's errors in English would put words a person reads outside the text package, which a guard refuses, while the sentence that shows them already says what failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * gui: six things the second outside review of the pull request named, each measured before it was changed The release notes, the changelog and the bill of materials said the software renderer is used when the graphics driver offers no OpenGL 2.1. The program tests something else - whether the first attempt left a window - and the notes now say that, with the driver as the usual reason. The guard over links to other projects forgave a release download by the script's NAME, and any project's download in that script. The review asked for the pinned address written into the guard, which the script does not hold: it builds the address from the pin. The exemption is now the script's path from the repository root plus the project the companion registry names, so a companion added later is covered on the day it arrives. The guards over sign_release.py read text, so a required operation inside a docstring or a string literal satisfied them - and one of the texts, codesign.go, stands in the script four times, once as the constant and three times in messages. The review asked for a reader that drops every string literal, which would have turned the guards red on the correct script: five of the ten things they look for are string contents by nature, measured with Python's own tokenizer. The reader now drops comments to the end of a line and docstrings whole, keeps short literals, and what is code is asked for as a statement at the start of a line. Two allowances - a computed library load, a spawn - forgave one operation of a KIND in a registered file. They now name the operation: the call, and the function that answers its first argument, followed back to its one binding in the enclosing function. The hardening guard reads the same registry instead of a copy. The helpers and their canary have a file of their own, because the telemetry guard had grown into the band the test shape ceiling watches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * guard: the forgiveness canary carries the approved spawn twice, the shape the mutation runner found missing Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 529dc75 commit 22e5d56

72 files changed

Lines changed: 49881 additions & 207 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.gitattributes‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,12 @@
1818
# The licence is a legal document. It is stored exactly as received.
1919
# ---------------------------------------------------------------------------
2020
/LICENSE -text
21+
22+
# ---------------------------------------------------------------------------
23+
# The copy of the OpenGL binding under third_party is a published module with
24+
# one patch, and a guard holds every file of it to the published bytes plus
25+
# that patch. The generated bindings are 2.3 MB and 0.8 MB of code nobody
26+
# wrote by hand, so they are marked generated for the pull request view. The
27+
# patched file, procaddr.go, is not - it is the one worth reading.
28+
# ---------------------------------------------------------------------------
29+
third_party/go-gl-gl/**/package.go linguist-generated=true

‎.github/mesa-dist-win‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
version=26.2.0
2+
archive=mesa3d-26.2.0-release-msvc.7z
3+
sha256=dcb2719ef346dab5b609fcb193a5f13cfc4b0502e3f4de1ad43d349477402f47
4+
file=x64/libgallium_wgl.dll 1a2e49cd5fdb1a857d98117ab04240d723b57da5dffe6d07f5386f42014557c1
5+
file=x64/opengl32.dll 33b217ed7947b48684baa987914475898a2b4d7d64cce96b078216c67a633582

‎.github/scripts/dependency_gate.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,17 @@
6666
# looked at. A name here is a decision with a reason, not a way to make a red
6767
# build green - and it names one package, never a whole ecosystem.
6868
EXCEPTIONS = {
69-
# (empty on purpose - add "name": "why this is fine" when it happens)
69+
# The OpenGL binding, carried as a copy in this repository since
70+
# 2026-09-17 and named by go.mod through a replace directive. GitHub's
71+
# graph reports the directory as a new dependency with no licence, because
72+
# a directory is not a package on any registry. The licence is the copied
73+
# module's own MIT, in the LICENSE file that travels with the directory,
74+
# and the bytes are the published module plus one patch - which is what
75+
# TestTheOpenGLBindingIsThePinnedModulePlusExactlyThePatch holds, by
76+
# downloading the pinned version and comparing. The reason for the copy is
77+
# in third_party/go-gl-gl/PATCH.md. This names one directory, and a second
78+
# copy of something else needs a line of its own here.
79+
"./third_party/go-gl-gl": "a copy of github.com/go-gl/gl, MIT, held to the published module plus one patch by a guard",
7080
}
7181

7282
# GitHub reports license: null for every action, measured on this repository on
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#!/usr/bin/env bash
2+
# Puts the software renderer beside the window binary, for the Windows
3+
# archive: the two files of Mesa's llvmpipe that the window loads when the
4+
# graphics driver offers no OpenGL 2.1.
5+
#
6+
# Usage: fetch_software_renderer.sh <directory the window binary is in>
7+
#
8+
# The release of pal1000/mesa-dist-win to take them from, the SHA-256 of that
9+
# release's archive, and the two files with their own sums, come from
10+
# .github/mesa-dist-win and nowhere else. The same version and sums stand in
11+
# internal/legal/companions.go, which is what the notices and the bill of
12+
# materials are rendered from, and a guard holds the two files equal - so a
13+
# bump here without the review there is refused before it is built.
14+
#
15+
# The archive's sum is checked BEFORE anything is unpacked. A download that
16+
# does not match is an archive nobody reviewed, and unpacking it first would
17+
# put two files nobody reviewed next to a program that loads them by path.
18+
#
19+
# Exactly two files, taken from one directory of the archive, into one
20+
# directory beside the program. The names are the ones the window looks for -
21+
# see internal/gui/software.go - and the guard above holds these to the
22+
# registry as well, so a renamed file cannot ship under the old name.
23+
set -euo pipefail
24+
25+
into="${1:?usage: fetch_software_renderer.sh <directory>}"
26+
pin=".github/mesa-dist-win"
27+
28+
version="$(grep '^version=' "${pin}" | cut -d= -f2-)"
29+
archive="$(grep '^archive=' "${pin}" | cut -d= -f2-)"
30+
sha256="$(grep '^sha256=' "${pin}" | cut -d= -f2-)"
31+
for value in "${version}" "${archive}" "${sha256}"; do
32+
if [ -z "${value}" ]; then
33+
echo "fetch_software_renderer: ${pin} does not name the version, the archive and the sum" >&2
34+
exit 1
35+
fi
36+
done
37+
38+
# Beside the target rather than under /tmp: on one machine /tmp was a
39+
# directory this user could write and not read back, and a download that
40+
# cannot be summed is a download that cannot be trusted.
41+
mkdir -p "${into}"
42+
fetched="$(mktemp -d "${into}/.software-renderer.XXXXXX")"
43+
curl --silent --show-error --fail --location --retry 3 \
44+
--output "${fetched}/${archive}" \
45+
"https://github.com/pal1000/mesa-dist-win/releases/download/${version}/${archive}"
46+
(cd "${fetched}" && echo "${sha256} ${archive}" | sha256sum -c -)
47+
48+
# The files, each with its own sum, from the same pin. The archive's sum
49+
# already covers them, and this is the half a person can check against the
50+
# notices without downloading seventy megabytes: the notices name these two
51+
# sums, and so does the registry the notices are rendered from.
52+
mkdir -p "${into}/opengl"
53+
grep '^file=' "${pin}" | cut -d= -f2- | while read -r inside sum; do
54+
7z e -bso0 -o"${into}/opengl" "${fetched}/${archive}" "${inside}"
55+
(cd "${into}/opengl" && echo "${sum} $(basename "${inside}")" | sha256sum -c -)
56+
done
57+
rm -rf "${fetched}"
58+
59+
# Two files and no more, or the archive is not what the notices describe.
60+
count="$(find "${into}/opengl" -type f | wc -l | tr -d ' ')"
61+
if [ "${count}" != "2" ]; then
62+
echo "fetch_software_renderer: expected two files under opengl and found ${count}" >&2
63+
exit 1
64+
fi
65+
ls -l "${into}/opengl"

‎.github/scripts/sign_release.py‎

Lines changed: 67 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,15 @@ def run(argv, **kw):
108108
return subprocess.run(argv, check=True, **kw)
109109

110110

111-
def powershell(script):
111+
def powershell(script, env=None):
112+
"""Run a PowerShell script. Values the script needs go in env, never in
113+
the script's text: a file name with a quote in it would end the string
114+
and the rest would run as PowerShell. -LiteralPath does not help, because
115+
the injected quote is parsed before the parameter is."""
112116
out = subprocess.run(
113117
["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
114-
capture_output=True, text=True)
118+
capture_output=True, text=True,
119+
env=None if env is None else {**os.environ, **env})
115120
if out.returncode != 0:
116121
raise SystemExit("sign_release: powershell failed:\n%s" % out.stderr.strip())
117122
# PowerShell errors are NON TERMINATING by default, so a script can print
@@ -243,15 +248,21 @@ def signing_thumbprint(pin):
243248

244249

245250
def certificate_of(path):
246-
"""The sha256 of the certificate that actually signed a file."""
251+
"""The sha256 of the certificate that actually signed a file.
252+
253+
The path reaches PowerShell as an environment variable and never as part
254+
of the script. Until 2026-09-17 it was interpolated into the text, which
255+
was harmless while the only file was our own tfg-gui.exe and stopped
256+
being harmless the day the archive gained files named by somebody else
257+
- an outside review of the pull request named it."""
247258
script = (
248-
"$s = Get-AuthenticodeSignature -LiteralPath '%s'; "
259+
"$s = Get-AuthenticodeSignature -LiteralPath $env:TFG_SIGNED_FILE; "
249260
"if ($s.Status -ne 'Valid') { Write-Error ('signature status: ' + $s.Status); exit 1 }; "
250261
"$h = [System.Security.Cryptography.SHA256]::Create()"
251262
".ComputeHash($s.SignerCertificate.RawData); "
252-
"(($h | ForEach-Object { $_.ToString('x2') }) -join '')" % path
263+
"(($h | ForEach-Object { $_.ToString('x2') }) -join '')"
253264
)
254-
return powershell(script).strip()
265+
return powershell(script, env={"TFG_SIGNED_FILE": path}).strip()
255266

256267

257268
def sha256_of(path):
@@ -333,40 +344,76 @@ def windows_archives(directory):
333344
return found
334345

335346

347+
def files_under(work):
348+
"""Every file under work, as a path relative to it with forward slashes.
349+
350+
Recursive, and that is the whole point of it. Until 2026-09-17 the
351+
archive was put back together from os.listdir, which names a directory
352+
and none of its contents, and zipfile writes a directory entry for a
353+
directory and nothing more - so a subdirectory came out of the signing
354+
EMPTY, with no error and a valid archive. Nothing shipped in a
355+
subdirectory until the software renderer did, so nothing had noticed.
356+
"""
357+
found = []
358+
for base, _dirs, names in os.walk(work):
359+
for name in names:
360+
full = os.path.join(base, name)
361+
found.append(os.path.relpath(full, work).replace(os.sep, "/"))
362+
return sorted(found)
363+
364+
336365
def sign_archive(path, thumbprint, pin, signtool, dry_run):
337-
"""Sign the program inside one archive and put the archive back together."""
366+
"""Sign everything Authenticode can sign inside one archive - the program
367+
and any library beside it - and put the archive back together whole."""
338368
work = path + ".unpacked"
339369
if os.path.isdir(work):
340370
shutil.rmtree(work)
341371
os.makedirs(work)
342372
with zipfile.ZipFile(path) as archive:
343373
archive.extractall(work)
344-
programs = [n for n in sorted(os.listdir(work)) if n.endswith(".exe")]
374+
inside = files_under(work)
375+
programs = [n for n in inside if n.endswith(".exe")]
345376
if len(programs) != 1:
346377
raise SystemExit("sign_release: %s holds %d programs, expected one"
347378
% (os.path.basename(path), len(programs)))
348-
program = os.path.join(work, programs[0])
349-
350-
command = [signtool, "sign", "/sha1", thumbprint, "/fd", "sha256",
351-
"/tr", TIMESTAMP_URL, "/td", "sha256", "/v", program]
352-
if dry_run:
353-
print(" DRY RUN, would run: %s" % " ".join(command))
354-
else:
379+
# The libraries beside the program too: the software renderer in the
380+
# window's archive. Their bytes are somebody else's and reviewed as such
381+
# (internal/legal/companions.go), and the signature says this release
382+
# vouches for exactly these bytes next to its program - a library beside
383+
# a signed program is otherwise the one file a tamperer would swap.
384+
signed = programs + [n for n in inside if n.endswith(".dll")]
385+
386+
for name in signed:
387+
target = os.path.join(work, name)
388+
command = [signtool, "sign", "/sha1", thumbprint, "/fd", "sha256",
389+
"/tr", TIMESTAMP_URL, "/td", "sha256", "/v", target]
390+
if dry_run:
391+
print(" DRY RUN, would run: %s" % " ".join(command))
392+
continue
355393
run(command)
356-
run([signtool, "verify", "/pa", "/v", program])
357-
actual = certificate_of(program)
394+
run([signtool, "verify", "/pa", "/v", target])
395+
actual = certificate_of(target)
358396
if actual != pin:
359397
raise SystemExit(
360398
"sign_release: %s was signed by a DIFFERENT certificate\n"
361399
" expected %s\n got %s\nNothing has been uploaded."
362-
% (programs[0], pin, actual))
400+
% (name, pin, actual))
363401

364402
os.remove(path)
365403
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive:
366-
for name in sorted(os.listdir(work)):
404+
for name in files_under(work):
367405
archive.write(os.path.join(work, name), name)
406+
# What went in is what comes out: every file, in every directory. A
407+
# repack that lost a file would be the quietest defect this script could
408+
# have, so it is counted rather than trusted.
409+
with zipfile.ZipFile(path) as archive:
410+
repacked = sorted(n for n in archive.namelist() if not n.endswith("/"))
411+
if repacked != inside:
412+
raise SystemExit("sign_release: %s was repacked with %d file(s) and held %d\nNothing has been uploaded."
413+
% (os.path.basename(path), len(repacked), len(inside)))
368414
shutil.rmtree(work)
369-
print(" %s: %s signed and repacked" % (os.path.basename(path), programs[0]))
415+
print(" %s: %s signed and repacked, %d file(s)"
416+
% (os.path.basename(path), ", ".join(signed), len(repacked)))
370417

371418

372419
def macos_archives(directory):

‎.github/workflows/ci.yml‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,17 @@ jobs:
139139
# updated the list and not the prose. The list is checked by the job
140140
# below and the prose is checked by nobody.
141141
#
142+
# Unchanged on 2026-09-17, when github.com/go-gl/gl gained a replace
143+
# directive pointing at a copy under third_party, and that is worth
144+
# saying because it looks like the kind of change this list exists to
145+
# notice. go list still reports the module under its own path, so
146+
# the list below is the same - what moved is where the bytes come
147+
# from, and two guards hold that instead: one keeps the copy equal to
148+
# the published version plus exactly the one patch its PATCH.md
149+
# describes, and one reads the built window binary's import table.
150+
# go.sum no longer carries the module's sum, because a replaced
151+
# module has none there. The sum lives in that PATCH.md now.
152+
#
142153
# The second question protects what ships to most people. The
143154
# command line binary links exactly four external modules and the
144155
# toolkit is not among them, so a build for a server carries no

‎.github/workflows/dev-build.yml‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,22 @@ jobs:
204204
"${work}" "tfg-gui" "com.donislawdev.tfg-gui" "dev-${short}"
205205
fi
206206
207+
# The software renderer, beside the program, Windows only: two files
208+
# of Mesa's llvmpipe the window loads when the graphics driver offers
209+
# no OpenGL 2.1 - a virtual machine without 3D acceleration, a remote
210+
# desktop, a server. Measured on 2026-09-17 on such a guest: without
211+
# them the window refuses, with them it opens. The version and the
212+
# sums come from .github/mesa-dist-win, the script checks the sums
213+
# before it unpacks anything, and a guard holds that file to the
214+
# registry the notices are rendered from. Linux has Mesa in the
215+
# system and macOS has never offered the toolkit less than it needs,
216+
# so nothing of the kind ships there. Here as well as in the
217+
# release, by the owner's decision: a build from a branch has to be the
218+
# build a guest without a driver can be handed.
219+
if [ "$os" = "windows" ]; then
220+
.github/scripts/fetch_software_renderer.sh "${work}"
221+
fi
222+
207223
cp LICENSE THIRD-PARTY-NOTICES.md README.md "${work}/"
208224
.github/scripts/unofficial_note.sh "${work}/UNOFFICIAL-BUILD.txt" "${short}"
209225

‎.github/workflows/release.yml‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,20 @@ jobs:
314314
"${work}" "tfg-gui" "com.donislawdev.tfg-gui" "${version}"
315315
fi
316316
317+
# The software renderer, beside the program, Windows only: two files
318+
# of Mesa's llvmpipe the window loads when the graphics driver offers
319+
# no OpenGL 2.1 - a virtual machine without 3D acceleration, a remote
320+
# desktop, a server. Measured on 2026-09-17 on such a guest: without
321+
# them the window refuses, with them it opens. The version and the
322+
# sums come from .github/mesa-dist-win, the script checks the sums
323+
# before it unpacks anything, and a guard holds that file to the
324+
# registry the notices are rendered from. Linux has Mesa in the
325+
# system and macOS has never offered the toolkit less than it needs,
326+
# so nothing of the kind ships there.
327+
if [ "$os" = "windows" ]; then
328+
.github/scripts/fetch_software_renderer.sh "${work}"
329+
fi
330+
317331
cp LICENSE THIRD-PARTY-NOTICES.md README.md "${work}/"
318332
319333
base="tfg-gui_${version}_${label}_${arch}"
@@ -445,6 +459,14 @@ jobs:
445459
echo "- \`tfg_*\` is the command line. It carries no graphics toolkit and no network stack."
446460
echo "- \`tfg-gui_*\` is the desktop window. Same engine, same features."
447461
echo
462+
echo "The Windows window archive also carries a software OpenGL renderer, Mesa llvmpipe,"
463+
echo "in \`opengl\` next to the program. The window loads it only after its first attempt"
464+
echo "at a window failed - typically a graphics driver with no OpenGL 2.1: a virtual"
465+
echo "machine without 3D acceleration, a remote desktop, a server - and says so on its"
466+
echo "About screen. On a machine with a driver it is not touched unless you ask for it"
467+
echo "with \`--software-gl\`. Both files are signed like the program. Keep the folder next"
468+
echo "to the program."
469+
echo
448470
echo "Match the file to your system and architecture. Check what you downloaded against"
449471
echo "\`verify-SHA256SUMS.txt\`, at the bottom of this list."
450472
echo

0 commit comments

Comments
 (0)