Skip to content

Commit 36d80e3

Browse files
donislawdevclaude
andauthored
packaging: a Windows installer, built from the signed archives (#147)
* packaging: the Windows installer, built from the two signed archives One installer carries both programs for every account on the machine: Program Files, the folder on the machine's PATH once, the window in the Start menu started in its own folder, which the window now recognises. build_msi.py builds it from the signed amd64 archives with WiX 5.0.2, from the tree it sits in, so a release can build it from the tagged tree. A release candidate gets none, and a version Windows Installer cannot hold is refused. Nothing that is running is ended - the Restart Manager is off from the first installer on, measured to upgrade in place while tfg runs. Guards read the rendered source as XML and hold the lines the measurements rest on, pin the UpgradeCode for good, and run every refusal of the script without WiX. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * release: the signing script builds, signs and expects the installer sign_release.py builds the installer from the signed amd64 archives with build_msi.py taken from the tree of the tag, exported with git archive, so what was tagged is what ships whatever the checkout stands on. It asks whether the installer can be built before the card signs anything, signs it with a timestamp and reads its certificate back like the programs'. A draft is complete with exactly one installer for a release, and none for a candidate - a tag with a hyphen, the one rule release.yml, build_msi.py and this script share. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: install the installer on a Windows runner and ask what it did Built unsigned from the latest release's two amd64 archives, checked against its checksums, and this commit's template. Installed silently: one entry in Programs and Features, the folder holding exactly the two archives, the folder on the machine's PATH once with the software renderer one level down, tfg version answering through PATH, the shortcut starting the window in its own folder. Built again and installed over itself while a tfg run is in progress, which carries on. Removed with a file of somebody else's in the folder, which alone is left. The same checks passed on Windows Server 2025 under Windows PowerShell 5.1 before this was pushed. A guard holds the job to the lines that ask all this and to the WiX version build_msi.py builds with. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * release: the last phase asks the published page for the installer One installer for a release, under the name build_msi.py gives it, and none for a candidate. Its signature is a step of its own - valid, with a timestamp, by the pinned certificate - with its own line in the verdict, so the step over the three archives and a candidate's run stay as they were. Both steps were run as written: the count in five folders, the signature on no installer and on an unsigned one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * packaging: the installer's place on the release page, and what it does in words The installer sorts among the programs, between the window's archives and the command line's - asked of the name build_msi.py gives it. The changelog says what it installs and what an upgrade while tfg runs does, and the packaging readme says how it is built and why each line of it is there. The site, the readme and the release notes change with the release that first carries it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * release: the tagged tree's extraction is settled for semgrep, with the measurement beside it The data filter keeps every name inside the folder - measured with a crafted archive: a name with .. and a link pointing out are refused, an absolute name lands inside, and nothing appears beside the folder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * guard: the installer source is read to its end with errors.Is Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * release: the semgrep note sits above the line the rule reports Measured with semgrep 1.177.0, the version CI pins: the rule reports the with statement, not the extraction under it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * packaging: one name to Windows is one file, and the tagged tree goes whatever happens Outside review of #147. Two archives holding LICENSE and License with different bytes put one over the other on a Windows disk without a word, because the names were compared as written - they are compared folded to one case now, and refused like any two copies that differ. The tree of the tag is exported for the check before the card and again for the installer, and removed after each whatever happened in between, so a refusal no longer leaves it beside the release's files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * packaging: the installer's signature names the product, and so does the prompt sign_release.py asks build_msi.py from the tagged tree for the name the package carries (--product-name) and signs the installer with it as the signature's description. Measured on Windows 11 with two copies signed by the card: without it the elevation prompt named a string of digits, with it the product and the verified publisher. check_installer asks for the name before the card signs anything. The packaging README and the changelog say what a double click shows while the window is open, measured at the console of the Windows Server 2025 VM: Windows Installer lists the window and offers Cancel, Retry and Ignore, and closes nothing itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * guard: the product name is read with its edges, and a tag that names none is refused The probe of the tagged tree printed the name at the end of a line, so a name still carrying its newline passed as well. It is printed in brackets now, and a release candidate - whose build_msi.py refuses - has to end in the signing script's refusal rather than in an empty description. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent d977cbf commit 36d80e3

12 files changed

Lines changed: 1595 additions & 23 deletions

File tree

‎.github/scripts/build_msi.py‎

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
#!/usr/bin/env python3
2+
"""Build the Windows installer of one release from its two signed archives.
3+
4+
python .github/scripts/build_msi.py --tag v0.5.0 --archives <dir> --out-dir <dir>
5+
python .github/scripts/build_msi.py --tag v0.5.0 --check
6+
python .github/scripts/build_msi.py --tag v0.5.0 --source-only
7+
python .github/scripts/build_msi.py --tag v0.5.0 --product-name
8+
9+
One installer carries both programs, the window and the command line, for every
10+
account on the machine - decided by the owner on 2026-09-28, beside the zips and
11+
the feed packages, which stay as they are. It is built from the SIGNED amd64
12+
archives, because the signature is on the programs inside them: an installer made
13+
before the card signed them would carry unsigned copies. So a release builds it in
14+
sign_release.py, on the machine with the card, and never in release.yml. The same
15+
script builds an unsigned one on a Windows runner in ci.yml, from the latest
16+
published release, so that what the installer DOES is asked on a clean machine.
17+
18+
Every value comes from the tree this script sits in: the template in packaging/msi/,
19+
the names and addresses build_packages.py already reads, the icon. sign_release.py
20+
runs it from the tree of the TAG, exported with git archive, so the installer is
21+
built from what was tagged, whatever the checkout happens to stand on.
22+
23+
--check asks only whether this machine can build it (the tag, the template, WiX),
24+
so sign_release.py can refuse before the card signs anything. --source-only prints
25+
the rendered installer source and builds nothing, which is what the guards read.
26+
--product-name prints the name the installer carries and nothing else, which is
27+
what sign_release.py signs it with.
28+
29+
Exit codes:
30+
0 the installer was written to --out-dir, or --check found nothing missing
31+
1 refused, and the message says which input and why
32+
33+
Nothing here signs or publishes anything.
34+
"""
35+
import argparse
36+
import os
37+
import re
38+
import shutil
39+
import subprocess
40+
import sys
41+
import tempfile
42+
import zipfile
43+
44+
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
45+
import build_packages as packages # noqa: E402 - the sibling script, found beside this one
46+
47+
ROOT = packages.ROOT
48+
TEMPLATE = os.path.join(ROOT, "packaging", "msi", "tfg-setup.wxs.in")
49+
ICON = os.path.join(ROOT, "internal", "gui", "icon", "chickpea.ico")
50+
51+
# The file a person downloads, public for good (owner's decision of 2026-09-28).
52+
# Not tfg_*, because the release notes say tfg_* is the command line, and this
53+
# carries both programs. It sorts between the window's archives and the command
54+
# line's on the release page, never among the verify- files.
55+
NAME = "tfg-setup_{version}_windows_amd64.msi"
56+
57+
# The product, not a build, and it never changes - see the comment on it in the
58+
# template. Generated once on 2026-09-28 and pinned by a guard.
59+
UPGRADE_CODE = "7DB637B7-3BEB-4FBD-BE84-60822854AA2F"
60+
61+
# The WiX this project builds with. Another version builds another package from
62+
# the same source, so a different one is refused rather than used.
63+
WIX_VERSION = "5.0.2"
64+
65+
# The window has no arm64 build, so neither has the installer.
66+
ARCH = "amd64"
67+
68+
69+
def refuse(message):
70+
raise SystemExit("build_msi: %s" % message)
71+
72+
73+
def parse_version(tag):
74+
"""The version a tag names, or a refusal saying why it gets no installer.
75+
76+
A tag with a hyphen is a release candidate, the same rule release.yml uses
77+
to mark one as a pre-release. Windows Installer reads only the three
78+
numbers, so a candidate's installer would take the release's own version
79+
and the release could not replace it.
80+
"""
81+
if "-" in tag:
82+
refuse("%s is a release candidate, and candidates get no installer. Windows "
83+
"Installer reads only the three numbers, so it would take %s for the "
84+
"release itself and the release would not replace it"
85+
% (tag, tag.split("-")[0]))
86+
found = re.fullmatch(r"v(\d+)\.(\d+)\.(\d+)", tag)
87+
if not found:
88+
refuse("%r is not a release tag. Pass it the way the release is tagged, for "
89+
"example v0.5.0" % tag)
90+
major, minor, patch = (int(n) for n in found.groups())
91+
if major > 255 or minor > 255 or patch > 65535:
92+
refuse("%s does not fit an installer version, which holds at most 255 for the "
93+
"first two numbers and 65535 for the third" % tag)
94+
return "%s.%s.%s" % found.groups()
95+
96+
97+
def values(version, tag):
98+
"""Every placeholder the template may use."""
99+
repo_url = "https://github.com/%s" % packages.repository()
100+
window = next(p for p in packages.PACKAGES if p.kind == "window")
101+
return {
102+
"APP_NAME": packages.APP_NAME,
103+
"PUBLISHER": packages.PUBLISHER,
104+
"VERSION": version,
105+
"UPGRADE_CODE": UPGRADE_CODE,
106+
"PROJECT_URL": packages.site(),
107+
"ISSUES_URL": repo_url + "/issues",
108+
"RELEASE_NOTES_URL": "%s/releases/tag/%s" % (repo_url, tag),
109+
"SHORTCUT_DESCRIPTION": packages.SHORT["window"],
110+
"WINDOW_EXE": window.program + ".exe",
111+
}
112+
113+
114+
def source(version, tag):
115+
"""The installer source for one release, every placeholder filled."""
116+
if not os.path.isfile(TEMPLATE):
117+
refuse("there is no template at %s. Run this from a tree that has one - a tag "
118+
"from before the installer existed has none" % TEMPLATE)
119+
text = packages.read_text(TEMPLATE)
120+
table = values(version, tag)
121+
unused = set(table) - set(packages.PLACEHOLDER.findall(text))
122+
if unused:
123+
refuse("the template uses no %s any more - take it out of build_msi.py"
124+
% ", ".join(sorted(unused)))
125+
return packages.render(text, table, "tfg-setup.wxs")
126+
127+
128+
def find_wix():
129+
"""The wix command, at the version this project builds with, or a refusal."""
130+
found = shutil.which("wix") or shutil.which(
131+
"wix", path=os.path.join(os.path.expanduser("~"), ".dotnet", "tools"))
132+
install = (" dotnet tool install --global wix --version %s\n"
133+
"It runs on .NET - install the .NET SDK first if there is no dotnet command."
134+
% WIX_VERSION)
135+
if not found:
136+
refuse("WiX is not installed, and the installer is built with it. Install the "
137+
"version this project builds with:\n" + install)
138+
said = subprocess.run([found, "--version"], capture_output=True, text=True)
139+
version = said.stdout.strip().split("+")[0]
140+
if said.returncode != 0 or version != WIX_VERSION:
141+
refuse("%s says it is version %r, and the installer is built with %s - another "
142+
"version builds another package from the same source. Replace it:\n"
143+
" dotnet tool uninstall --global wix\n%s"
144+
% (found, version, WIX_VERSION, install))
145+
return found
146+
147+
148+
def safe_name(name):
149+
"""Whether a name inside an archive stays inside the folder it is unpacked into."""
150+
parts = name.split("/")
151+
return (bool(name) and not name.startswith("/") and "\\" not in name and ":" not in name
152+
and all(part not in ("", ".", "..") for part in parts))
153+
154+
155+
def unpack(archives, version, into):
156+
"""The window's and the command line's amd64 archives, in one folder.
157+
158+
A file both archives hold goes in once, and only when both hold the same
159+
bytes - the three documents, today. Two different copies of one file are a
160+
question about how the release was built, and the installer does not pick one.
161+
162+
One file means one name to Windows, where the installer puts it: LICENSE
163+
and License are the same file there, so the names are compared folded to
164+
one case. Compared as written, the second would have overwritten the first
165+
without a word (outside review of #147).
166+
"""
167+
came_from = {}
168+
for package in packages.PACKAGES:
169+
name = package.archive.format(version=version, arch=ARCH)
170+
path = os.path.join(archives, name)
171+
if not os.path.isfile(path):
172+
refuse("%s holds no %s. Pass the folder that holds the signed archives of "
173+
"this release" % (archives, name))
174+
try:
175+
with zipfile.ZipFile(path) as archive:
176+
for entry in archive.infolist():
177+
if entry.is_dir():
178+
continue
179+
if not safe_name(entry.filename):
180+
refuse("%s holds %r, a name that leads out of the folder it is "
181+
"unpacked into. That is not an archive the release built"
182+
% (name, entry.filename))
183+
target = os.path.join(into, *entry.filename.split("/"))
184+
key = entry.filename.casefold()
185+
if key in came_from:
186+
first, held_at, held_as = came_from[key]
187+
with open(held_at, "rb") as held:
188+
if held.read() != archive.read(entry):
189+
refuse("%s holds %s and %s holds %s, one file on Windows, and "
190+
"not the same bytes. One installer carries one copy - "
191+
"look at how the release built the two archives"
192+
% (first, held_as, name, entry.filename))
193+
continue
194+
came_from[key] = (name, target, entry.filename)
195+
os.makedirs(os.path.dirname(target), exist_ok=True)
196+
with archive.open(entry) as src, open(target, "wb") as dst:
197+
shutil.copyfileobj(src, dst)
198+
except zipfile.BadZipFile as err:
199+
refuse("%s is not a zip archive it can read (%s). Download it again" % (path, err))
200+
for package in packages.PACKAGES:
201+
program = package.program + ".exe"
202+
if program.casefold() not in came_from:
203+
refuse("the archives hold no %s at the top, and the installer puts it on PATH. "
204+
"That is not the shape the release builds" % program)
205+
return came_from
206+
207+
208+
def build(tag, archives, out_dir):
209+
"""Write the installer into out_dir, all or nothing, and return its path."""
210+
version = parse_version(tag)
211+
text = source(version, tag)
212+
if not os.path.isdir(out_dir):
213+
refuse("--out-dir %s is not a folder. Pass one that exists" % out_dir)
214+
target = os.path.join(out_dir, NAME.format(version=version))
215+
if os.path.exists(target):
216+
refuse("%s is already there. Nothing is overwritten - remove it or pass another "
217+
"--out-dir" % target)
218+
if not os.path.isfile(ICON):
219+
refuse("the icon %s is not in the tree" % ICON)
220+
221+
# Beside nothing of the caller's: sign_release.py hands its own folder of
222+
# files to publish as --out-dir, and a folder left inside it would be
223+
# published, or would break the checksums written over it.
224+
staging = tempfile.mkdtemp(prefix="tfg-msi-")
225+
try:
226+
payload = os.path.join(staging, "payload")
227+
os.makedirs(payload)
228+
unpack(archives, version, payload)
229+
wix = find_wix()
230+
wxs = os.path.join(staging, "tfg-setup.wxs")
231+
with open(wxs, "w", encoding="utf-8", newline="\n") as handle:
232+
handle.write(text)
233+
built = os.path.join(staging, os.path.basename(target))
234+
command = [wix, "build", wxs, "-arch", "x64", "-pdbtype", "none",
235+
"-d", "PayloadDir=" + payload, "-d", "IconFile=" + ICON, "-o", built]
236+
print(" $ %s" % " ".join(command))
237+
result = subprocess.run(command)
238+
if result.returncode != 0 or not os.path.isfile(built):
239+
refuse("wix build exited %d, and nothing was written to %s. What it said is above"
240+
% (result.returncode, out_dir))
241+
# Moved in only once it is whole, so a run that fails or is stopped
242+
# leaves no half written installer where the caller would find it.
243+
shutil.move(built, target)
244+
except OSError as err:
245+
refuse("cannot build the installer: %s. Nothing was written to %s" % (err, out_dir))
246+
finally:
247+
shutil.rmtree(staging, ignore_errors=True)
248+
return target
249+
250+
251+
def main(argv=None):
252+
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
253+
parser.add_argument("--tag", required=True, help="the release, for example v0.5.0")
254+
parser.add_argument("--archives", help="the folder holding its signed amd64 zip archives")
255+
parser.add_argument("--out-dir", help="the folder the installer is written into")
256+
parser.add_argument("--check", action="store_true",
257+
help="only say whether this machine can build it")
258+
parser.add_argument("--source-only", action="store_true",
259+
help="print the installer source and build nothing")
260+
parser.add_argument("--product-name", action="store_true",
261+
help="print the name the installer carries and build nothing")
262+
args = parser.parse_args(argv)
263+
264+
if args.source_only:
265+
sys.stdout.write(source(parse_version(args.tag), args.tag))
266+
return 0
267+
if args.product_name:
268+
print(values(parse_version(args.tag), args.tag)["APP_NAME"])
269+
return 0
270+
if args.check:
271+
version = parse_version(args.tag)
272+
source(version, args.tag)
273+
if not os.path.isfile(ICON):
274+
refuse("the icon %s is not in the tree" % ICON)
275+
print("ready to build %s with %s" % (NAME.format(version=version), find_wix()))
276+
return 0
277+
if not args.archives or not args.out_dir:
278+
parser.error("--archives and --out-dir are needed to build")
279+
print(build(args.tag, args.archives, args.out_dir))
280+
return 0
281+
282+
283+
if __name__ == "__main__":
284+
sys.exit(main())

‎.github/scripts/build_packages.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,14 +312,19 @@ def render(text, table, name):
312312
# something else: a quote ends a PowerShell string early, a bracket or an
313313
# ampersand is markup in the nuspec, and a colon followed by a space or a space
314314
# followed by a hash turns the rest of a plain YAML value into a key or a
315-
# comment.
315+
# comment. The installer source (build_msi.py) is XML, and WiX reads it once
316+
# more after the parser, taking $( as one of its own variables.
316317
BREAKS = (
317318
(".ps1", "'", "a single quote ends the PowerShell string it sits in"),
318319
(".nuspec", "<", "the nuspec reads it as markup"),
319320
(".nuspec", ">", "the nuspec reads it as markup"),
320321
(".nuspec", "&", "the nuspec reads it as markup"),
321322
(".yaml", ": ", "YAML reads the rest as a key"),
322323
(".yaml", " #", "YAML reads the rest as a comment"),
324+
(".wxs", '"', "a double quote ends the attribute it sits in"),
325+
(".wxs", "<", "the installer source reads it as markup"),
326+
(".wxs", "&", "the installer source reads it as markup"),
327+
(".wxs", "$(", "WiX reads it as one of its own variables"),
323328
)
324329

325330

0 commit comments

Comments
 (0)