From 1896f43d84e576c823c64ff086623d4a56281f17 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Mon, 28 Sep 2026 20:13:50 +0200 Subject: [PATCH 01/11] 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 --- .github/scripts/build_msi.py | 269 ++++++++++++++++ .github/scripts/build_packages.py | 7 +- internal/guard/msi_test.go | 407 ++++++++++++++++++++++++ internal/guard/packaging_test.go | 5 +- internal/guard/packagingrefusal_test.go | 20 +- packaging/msi/tfg-setup.wxs.in | 124 ++++++++ 6 files changed, 824 insertions(+), 8 deletions(-) create mode 100644 .github/scripts/build_msi.py create mode 100644 internal/guard/msi_test.go create mode 100644 packaging/msi/tfg-setup.wxs.in diff --git a/.github/scripts/build_msi.py b/.github/scripts/build_msi.py new file mode 100644 index 00000000..684567b1 --- /dev/null +++ b/.github/scripts/build_msi.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +"""Build the Windows installer of one release from its two signed archives. + + python .github/scripts/build_msi.py --tag v0.5.0 --archives --out-dir + python .github/scripts/build_msi.py --tag v0.5.0 --check + python .github/scripts/build_msi.py --tag v0.5.0 --source-only + +One installer carries both programs, the window and the command line, for every +account on the machine - decided by the owner on 2026-09-28, beside the zips and +the feed packages, which stay as they are. It is built from the SIGNED amd64 +archives, because the signature is on the programs inside them: an installer made +before the card signed them would carry unsigned copies. So a release builds it in +sign_release.py, on the machine with the card, and never in release.yml. The same +script builds an unsigned one on a Windows runner in ci.yml, from the latest +published release, so that what the installer DOES is asked on a clean machine. + +Every value comes from the tree this script sits in: the template in packaging/msi/, +the names and addresses build_packages.py already reads, the icon. sign_release.py +runs it from the tree of the TAG, exported with git archive, so the installer is +built from what was tagged, whatever the checkout happens to stand on. + +--check asks only whether this machine can build it (the tag, the template, WiX), +so sign_release.py can refuse before the card signs anything. --source-only prints +the rendered installer source and builds nothing, which is what the guards read. + +Exit codes: + 0 the installer was written to --out-dir, or --check found nothing missing + 1 refused, and the message says which input and why + +Nothing here signs or publishes anything. +""" +import argparse +import os +import re +import shutil +import subprocess +import sys +import tempfile +import zipfile + +sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) +import build_packages as packages # noqa: E402 - the sibling script, found beside this one + +ROOT = packages.ROOT +TEMPLATE = os.path.join(ROOT, "packaging", "msi", "tfg-setup.wxs.in") +ICON = os.path.join(ROOT, "internal", "gui", "icon", "chickpea.ico") + +# The file a person downloads, public for good (owner's decision of 2026-09-28). +# Not tfg_*, because the release notes say tfg_* is the command line, and this +# carries both programs. It sorts between the window's archives and the command +# line's on the release page, never among the verify- files. +NAME = "tfg-setup_{version}_windows_amd64.msi" + +# The product, not a build, and it never changes - see the comment on it in the +# template. Generated once on 2026-09-28 and pinned by a guard. +UPGRADE_CODE = "7DB637B7-3BEB-4FBD-BE84-60822854AA2F" + +# The WiX this project builds with. Another version builds another package from +# the same source, so a different one is refused rather than used. +WIX_VERSION = "5.0.2" + +# The window has no arm64 build, so neither has the installer. +ARCH = "amd64" + + +def refuse(message): + raise SystemExit("build_msi: %s" % message) + + +def parse_version(tag): + """The version a tag names, or a refusal saying why it gets no installer. + + A tag with a hyphen is a release candidate, the same rule release.yml uses + to mark one as a pre-release. Windows Installer reads only the three + numbers, so a candidate's installer would take the release's own version + and the release could not replace it. + """ + if "-" in tag: + refuse("%s is a release candidate, and candidates get no installer. Windows " + "Installer reads only the three numbers, so it would take %s for the " + "release itself and the release would not replace it" + % (tag, tag.split("-")[0])) + found = re.fullmatch(r"v(\d+)\.(\d+)\.(\d+)", tag) + if not found: + refuse("%r is not a release tag. Pass it the way the release is tagged, for " + "example v0.5.0" % tag) + major, minor, patch = (int(n) for n in found.groups()) + if major > 255 or minor > 255 or patch > 65535: + refuse("%s does not fit an installer version, which holds at most 255 for the " + "first two numbers and 65535 for the third" % tag) + return "%s.%s.%s" % found.groups() + + +def values(version, tag): + """Every placeholder the template may use.""" + repo_url = "https://github.com/%s" % packages.repository() + window = next(p for p in packages.PACKAGES if p.kind == "window") + return { + "APP_NAME": packages.APP_NAME, + "PUBLISHER": packages.PUBLISHER, + "VERSION": version, + "UPGRADE_CODE": UPGRADE_CODE, + "PROJECT_URL": packages.site(), + "ISSUES_URL": repo_url + "/issues", + "RELEASE_NOTES_URL": "%s/releases/tag/%s" % (repo_url, tag), + "SHORTCUT_DESCRIPTION": packages.SHORT["window"], + "WINDOW_EXE": window.program + ".exe", + } + + +def source(version, tag): + """The installer source for one release, every placeholder filled.""" + if not os.path.isfile(TEMPLATE): + refuse("there is no template at %s. Run this from a tree that has one - a tag " + "from before the installer existed has none" % TEMPLATE) + text = packages.read_text(TEMPLATE) + table = values(version, tag) + unused = set(table) - set(packages.PLACEHOLDER.findall(text)) + if unused: + refuse("the template uses no %s any more - take it out of build_msi.py" + % ", ".join(sorted(unused))) + return packages.render(text, table, "tfg-setup.wxs") + + +def find_wix(): + """The wix command, at the version this project builds with, or a refusal.""" + found = shutil.which("wix") or shutil.which( + "wix", path=os.path.join(os.path.expanduser("~"), ".dotnet", "tools")) + install = (" dotnet tool install --global wix --version %s\n" + "It runs on .NET - install the .NET SDK first if there is no dotnet command." + % WIX_VERSION) + if not found: + refuse("WiX is not installed, and the installer is built with it. Install the " + "version this project builds with:\n" + install) + said = subprocess.run([found, "--version"], capture_output=True, text=True) + version = said.stdout.strip().split("+")[0] + if said.returncode != 0 or version != WIX_VERSION: + refuse("%s says it is version %r, and the installer is built with %s - another " + "version builds another package from the same source. Replace it:\n" + " dotnet tool uninstall --global wix\n%s" + % (found, version, WIX_VERSION, install)) + return found + + +def safe_name(name): + """Whether a name inside an archive stays inside the folder it is unpacked into.""" + parts = name.split("/") + return (bool(name) and not name.startswith("/") and "\\" not in name and ":" not in name + and all(part not in ("", ".", "..") for part in parts)) + + +def unpack(archives, version, into): + """The window's and the command line's amd64 archives, in one folder. + + A file both archives hold goes in once, and only when both hold the same + bytes - the three documents, today. Two different copies of one file are a + question about how the release was built, and the installer does not pick one. + """ + came_from = {} + for package in packages.PACKAGES: + name = package.archive.format(version=version, arch=ARCH) + path = os.path.join(archives, name) + if not os.path.isfile(path): + refuse("%s holds no %s. Pass the folder that holds the signed archives of " + "this release" % (archives, name)) + try: + with zipfile.ZipFile(path) as archive: + for entry in archive.infolist(): + if entry.is_dir(): + continue + if not safe_name(entry.filename): + refuse("%s holds %r, a name that leads out of the folder it is " + "unpacked into. That is not an archive the release built" + % (name, entry.filename)) + target = os.path.join(into, *entry.filename.split("/")) + if entry.filename in came_from: + with open(target, "rb") as held: + if held.read() != archive.read(entry): + refuse("%s and %s both hold %s, and not the same bytes. One " + "installer carries one copy - look at how the release " + "built the two archives" + % (came_from[entry.filename], name, entry.filename)) + continue + came_from[entry.filename] = name + os.makedirs(os.path.dirname(target), exist_ok=True) + with archive.open(entry) as src, open(target, "wb") as dst: + shutil.copyfileobj(src, dst) + except zipfile.BadZipFile as err: + refuse("%s is not a zip archive it can read (%s). Download it again" % (path, err)) + for package in packages.PACKAGES: + program = package.program + ".exe" + if program not in came_from: + refuse("the archives hold no %s at the top, and the installer puts it on PATH. " + "That is not the shape the release builds" % program) + return came_from + + +def build(tag, archives, out_dir): + """Write the installer into out_dir, all or nothing, and return its path.""" + version = parse_version(tag) + text = source(version, tag) + if not os.path.isdir(out_dir): + refuse("--out-dir %s is not a folder. Pass one that exists" % out_dir) + target = os.path.join(out_dir, NAME.format(version=version)) + if os.path.exists(target): + refuse("%s is already there. Nothing is overwritten - remove it or pass another " + "--out-dir" % target) + if not os.path.isfile(ICON): + refuse("the icon %s is not in the tree" % ICON) + + # Beside nothing of the caller's: sign_release.py hands its own folder of + # files to publish as --out-dir, and a folder left inside it would be + # published, or would break the checksums written over it. + staging = tempfile.mkdtemp(prefix="tfg-msi-") + try: + payload = os.path.join(staging, "payload") + os.makedirs(payload) + unpack(archives, version, payload) + wix = find_wix() + wxs = os.path.join(staging, "tfg-setup.wxs") + with open(wxs, "w", encoding="utf-8", newline="\n") as handle: + handle.write(text) + built = os.path.join(staging, os.path.basename(target)) + command = [wix, "build", wxs, "-arch", "x64", "-pdbtype", "none", + "-d", "PayloadDir=" + payload, "-d", "IconFile=" + ICON, "-o", built] + print(" $ %s" % " ".join(command)) + result = subprocess.run(command) + if result.returncode != 0 or not os.path.isfile(built): + refuse("wix build exited %d, and nothing was written to %s. What it said is above" + % (result.returncode, out_dir)) + # Moved in only once it is whole, so a run that fails or is stopped + # leaves no half written installer where the caller would find it. + shutil.move(built, target) + except OSError as err: + refuse("cannot build the installer: %s. Nothing was written to %s" % (err, out_dir)) + finally: + shutil.rmtree(staging, ignore_errors=True) + return target + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + parser.add_argument("--tag", required=True, help="the release, for example v0.5.0") + parser.add_argument("--archives", help="the folder holding its signed amd64 zip archives") + parser.add_argument("--out-dir", help="the folder the installer is written into") + parser.add_argument("--check", action="store_true", + help="only say whether this machine can build it") + parser.add_argument("--source-only", action="store_true", + help="print the installer source and build nothing") + args = parser.parse_args(argv) + + if args.source_only: + sys.stdout.write(source(parse_version(args.tag), args.tag)) + return 0 + if args.check: + version = parse_version(args.tag) + source(version, args.tag) + if not os.path.isfile(ICON): + refuse("the icon %s is not in the tree" % ICON) + print("ready to build %s with %s" % (NAME.format(version=version), find_wix())) + return 0 + if not args.archives or not args.out_dir: + parser.error("--archives and --out-dir are needed to build") + print(build(args.tag, args.archives, args.out_dir)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/build_packages.py b/.github/scripts/build_packages.py index 9a30789f..e8c2ccfb 100644 --- a/.github/scripts/build_packages.py +++ b/.github/scripts/build_packages.py @@ -312,7 +312,8 @@ def render(text, table, name): # something else: a quote ends a PowerShell string early, a bracket or an # ampersand is markup in the nuspec, and a colon followed by a space or a space # followed by a hash turns the rest of a plain YAML value into a key or a -# comment. +# comment. The installer source (build_msi.py) is XML, and WiX reads it once +# more after the parser, taking $( as one of its own variables. BREAKS = ( (".ps1", "'", "a single quote ends the PowerShell string it sits in"), (".nuspec", "<", "the nuspec reads it as markup"), @@ -320,6 +321,10 @@ def render(text, table, name): (".nuspec", "&", "the nuspec reads it as markup"), (".yaml", ": ", "YAML reads the rest as a key"), (".yaml", " #", "YAML reads the rest as a comment"), + (".wxs", '"', "a double quote ends the attribute it sits in"), + (".wxs", "<", "the installer source reads it as markup"), + (".wxs", "&", "the installer source reads it as markup"), + (".wxs", "$(", "WiX reads it as one of its own variables"), ) diff --git a/internal/guard/msi_test.go b/internal/guard/msi_test.go new file mode 100644 index 00000000..740b5985 --- /dev/null +++ b/internal/guard/msi_test.go @@ -0,0 +1,407 @@ +package guard + +import ( + "archive/zip" + "encoding/xml" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "testing" +) + +// The Windows installer, and the script that builds it. +// +// What the installer DOES on a machine is asked by the job in ci.yml that +// installs it on a Windows runner, and before that on a virtual machine +// (docs/PACKAGING-2026-09-25.md section 13). These guards hold what that job +// cannot see coming: the lines each of those measurements rests on, a value +// that must never change, and the refusals of the script. Every refusal comes +// before WiX is asked for, so these run on every system, WiX or not. + +// installerUpgradeCode is the product's identity in Windows Installer, for +// good. Every machine that has the program finds the version it has through +// it, so a new one would leave the old install beside the new one on every +// one of them. Written here a second time on purpose: this is the copy that +// does not move when the script does. +const installerUpgradeCode = "7DB637B7-3BEB-4FBD-BE84-60822854AA2F" + +func msiScript(t *testing.T) string { + t.Helper() + return filepath.Join(repoRoot(t), ".github", "scripts", "build_msi.py") +} + +// runMSI runs build_msi.py with a temporary folder of its own, so a guard can +// see what a run leaves behind. withoutWix also takes WiX out of its reach - +// an empty PATH and a home with no .dotnet in it - so a run that gets past +// every check on the archives stops at the same place on every system. +func runMSI(t *testing.T, temp string, withoutWix bool, args ...string) rendering { + t.Helper() + python := pythonForGate(t) + env := append(os.Environ(), "TMP="+temp, "TEMP="+temp, "TMPDIR="+temp) + if withoutWix { + nowhere := t.TempDir() + env = append(env, "PATH="+nowhere, "HOME="+nowhere, "USERPROFILE="+nowhere) + } + // The interpreter is the one found on PATH, the script is a file of this + // repository, and every argument is a value this guard chose. + // nosemgrep: go.lang.security.audit.dangerous-exec-command.dangerous-exec-command + cmd := exec.Command(python, append([]string{msiScript(t)}, args...)...) + cmd.Dir = repoRoot(t) + cmd.Env = env + said, err := cmd.CombinedOutput() + code := 0 + var exit *exec.ExitError + if errors.As(err, &exit) { + code = exit.ExitCode() + } else if err != nil { + t.Fatalf("build_msi.py could not be started: %v", err) + } + return rendering{said: string(said), code: code} +} + +// wxsElement is one element of the installer source: its name, its namespace, +// its attributes and the element it sits in. Comments are not elements, so a +// word in the explanation of a line never counts as the line. +type wxsElement struct { + name, space string + attrs map[string]string + parent int +} + +func installerElements(t *testing.T, source string) []wxsElement { + t.Helper() + dec := xml.NewDecoder(strings.NewReader(source)) + var found []wxsElement + open := []int{-1} + for { + tok, err := dec.Token() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("the installer source is not XML: %v", err) + } + switch el := tok.(type) { + case xml.StartElement: + attrs := map[string]string{} + for _, a := range el.Attr { + key := a.Name.Local + if a.Name.Space != "" { + key = a.Name.Space + ":" + a.Name.Local + } + attrs[key] = a.Value + } + found = append(found, wxsElement{el.Name.Local, el.Name.Space, attrs, open[len(open)-1]}) + open = append(open, len(found)-1) + case xml.EndElement: + open = open[:len(open)-1] + } + } + return found +} + +// renderedInstaller is the installer source of the fixture release, rendered +// the way the build renders it. +func renderedInstaller(t *testing.T) []wxsElement { + t.Helper() + r := runMSI(t, t.TempDir(), false, "--tag", packagingTag, "--source-only") + if r.code != 0 { + t.Fatalf("build_msi.py refused to render %s (exit %d):\n%s", packagingTag, r.code, r.said) + } + return installerElements(t, r.said) +} + +// named returns the elements of one name, and one whose attribute has a value. +func named(els []wxsElement, name string) []wxsElement { + var out []wxsElement + for _, e := range els { + if e.name == name { + out = append(out, e) + } + } + return out +} + +func withAttr(els []wxsElement, name, attr, value string) []wxsElement { + var out []wxsElement + for _, e := range named(els, name) { + if e.attrs[attr] == value { + out = append(out, e) + } + } + return out +} + +// The lines the measurements rest on, read from the rendered source. +// +// Each was measured on Windows Server 2025 before it was written, and each +// one changed would still build a working installer - which is why it needs +// a guard rather than a build. What breaks is an upgrade, an uninstall, or +// another account's Start menu, on somebody else's machine. +func TestTheInstallerIsTheShapeThatWasMeasured(t *testing.T) { + els := renderedInstaller(t) + if len(els) < 10 { + t.Fatalf("read %d element(s) from the installer source, so this guard is not reading it", len(els)) + } + + // No extension, no custom action, nothing that ends a program. The + // owner's decision of 2026-09-25: a run in progress may be half way + // through a set of files. An extension also puts code of its own into the + // package, and then the package is not only our files. + for _, e := range els { + if e.space != "http://wixtoolset.org/schemas/v4/wxs" { + t.Errorf("<%s> is from %q, an extension of WiX - the installer carries none", e.name, e.space) + } + for key := range e.attrs { + if strings.HasPrefix(key, "xmlns:") { + t.Errorf("<%s> brings in the namespace %s - the installer uses no extension", e.name, key) + } + } + switch e.name { + case "CustomAction", "CloseApplication", "InstallExecuteSequence", "UI", "UIRef": + t.Errorf("the installer has a <%s>. It runs no action of its own and ends nothing that "+ + "is running, and its only dialogs are Windows Installer's", e.name) + } + } + + pkg := named(els, "Package") + if len(pkg) != 1 { + t.Fatalf("the source has %d , and it is one package", len(pkg)) + } + if got := pkg[0].attrs["UpgradeCode"]; got != installerUpgradeCode { + t.Errorf("the UpgradeCode is %q. It is %s for good - with another one every machine "+ + "keeps the old install beside the new one", got, installerUpgradeCode) + } + if got := pkg[0].attrs["Scope"]; got != "perMachine" { + t.Errorf("the package installs %q. It installs for the machine, so the command line is "+ + "on PATH for a build agent and the window in every account's Start menu", got) + } + + upgrade := named(els, "MajorUpgrade") + if len(upgrade) != 1 || upgrade[0].attrs["Schedule"] != "afterInstallExecute" || + upgrade[0].attrs["AllowSameVersionUpgrades"] != "yes" { + t.Errorf("the major upgrade is %v. It removes the old version after the new files are "+ + "in place (afterInstallExecute), and a rebuild of the same version replaces the first "+ + "build instead of installing beside it (AllowSameVersionUpgrades)", upgrade) + } + + // Measured: with the Restart Manager on, an upgrade while tfg ran failed + // after thirty seconds and closed the program anyway. It must be in the + // package, because the old package's properties decide the upgrade. + manager := withAttr(els, "Property", "Id", "MSIRESTARTMANAGERCONTROL") + if len(manager) != 1 || manager[0].attrs["Value"] != "Disable" { + t.Errorf("the package sets MSIRESTARTMANAGERCONTROL as %v. It turns the Restart Manager "+ + "off with Disable, or an upgrade while tfg runs fails and ends the run", manager) + } + + env := named(els, "Environment") + if len(env) != 1 { + t.Fatalf("the source has %d , and it sets one PATH entry", len(env)) + } + for attr, want := range map[string]string{ + "Name": "PATH", "System": "yes", "Part": "last", "Value": "[INSTALLFOLDER]", "Permanent": "no", + } { + if got := env[0].attrs[attr]; got != want { + t.Errorf("the PATH entry has %s=%q, want %q: the folder goes on the machine's PATH, "+ + "after everything already there, and comes off again at uninstall", attr, got, want) + } + } + + shortcuts := named(els, "Shortcut") + if len(shortcuts) != 1 { + t.Fatalf("the source has %d shortcut(s). The window has one, and the command line none", len(shortcuts)) + } + if got := shortcuts[0].attrs["Target"]; got != "[INSTALLFOLDER]tfg-gui.exe" { + t.Errorf("the shortcut starts %q, and it starts the window", got) + } + if got := shortcuts[0].attrs["WorkingDirectory"]; got != "INSTALLFOLDER" { + t.Errorf("the shortcut starts in %q. It starts in the install folder, which the window "+ + "recognises as its own and sends its offer to the home folder instead. A profile "+ + "variable is expanded at install time, in the installing account", got) + } + + folder := withAttr(els, "Directory", "Id", "INSTALLFOLDER") + if len(folder) != 1 || folder[0].parent < 0 || + els[folder[0].parent].attrs["Id"] != "ProgramFiles64Folder" { + t.Error("the install folder is not directly under ProgramFiles64Folder") + } +} + +// What a person reads from the installer follows the punctuation rule (D17): a +// flat hyphen, and no semicolons. The refusal to go back to an older version +// is the one sentence a person sees from it, and the shortcut's description +// is its tooltip. +func TestTheInstallerTextFollowsThePunctuationRule(t *testing.T) { + forbidden := string([]rune{';', rune(0x2013), rune(0x2014)}) + read := 0 + for _, e := range renderedInstaller(t) { + for _, attr := range []string{"DowngradeErrorMessage", "Description", "Name"} { + text, ok := e.attrs[attr] + if !ok || (attr == "Name" && e.name != "Package" && e.name != "Shortcut") { + continue + } + read++ + if strings.ContainsAny(text, forbidden) { + t.Errorf("<%s %s> shows a person %q, which breaks the punctuation rule", e.name, attr, text) + } + } + } + if read < 4 { + t.Errorf("read %d line(s) a person sees, and the installer shows four", read) + } +} + +// A release candidate gets no installer, and a version Windows Installer +// cannot hold is refused rather than cut. +// +// A tag with a hyphen is a candidate - the rule release.yml marks a +// pre-release by. Windows Installer reads only the three numbers, so a +// candidate's installer would carry the release's own version and the release +// could not replace it. +func TestTheInstallerIsBuiltForAReleaseOnly(t *testing.T) { + for _, c := range []struct{ tag, says string }{ + {"v0.5.0-rc1", "is a release candidate"}, + {"v0.5.0-beta.2", "is a release candidate"}, + {"0.5.0", "is not a release tag"}, + {"v256.0.0", "does not fit an installer version"}, + {"v0.256.0", "does not fit an installer version"}, + {"v0.0.65536", "does not fit an installer version"}, + } { + t.Run(c.tag, func(t *testing.T) { + r := runMSI(t, t.TempDir(), false, "--tag", c.tag, "--source-only") + if r.code != 1 || !strings.Contains(r.said, c.says) { + t.Errorf("build_msi.py --tag %s exited %d and said:\n%s\nwant exit 1 and %q", c.tag, r.code, r.said, c.says) + } + }) + } + // And the largest version that fits is not refused, so the cases above + // are refused for what they are. + if r := runMSI(t, t.TempDir(), false, "--tag", "v255.255.65535", "--source-only"); r.code != 0 { + t.Errorf("the largest version an installer holds is refused (exit %d):\n%s", r.code, r.said) + } +} + +// writeZipOf writes a zip archive holding the given files. +func writeZipOf(t *testing.T, path string, files map[string]string) { + t.Helper() + f, err := os.Create(path) + if err != nil { + t.Fatalf("creating %s: %v", path, err) + } + w := zip.NewWriter(f) + var names []string + for name := range files { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + part, err := w.Create(name) + if err == nil { + _, err = part.Write([]byte(files[name])) + } + if err != nil { + t.Fatalf("writing %s into %s: %v", name, path, err) + } + } + if err := w.Close(); err != nil { + t.Fatalf("closing %s: %v", path, err) + } + if err := f.Close(); err != nil { + t.Fatalf("closing %s: %v", path, err) + } +} + +// The installer is built from both signed archives, or not at all - and a run +// that refuses leaves nothing behind, neither in the folder it was to write +// into nor in its own working folder. +// +// Each case differs from a set of archives that gets through in one thing, +// and that set is asked first: it reaches the step that asks for WiX, which +// this guard keeps out of reach. So each refusal below is the refusal of what +// the case changed, not of something the fixture got wrong. +func TestTheInstallerIsBuiltFromBothArchivesOrNotAtAll(t *testing.T) { + const tag, version = "v9.9.9", "9.9.9" + cli := "tfg_" + version + "_windows_amd64.zip" + window := "tfg-gui_" + version + "_windows_amd64.zip" + installer := "tfg-setup_" + version + "_windows_amd64.msi" + good := func() map[string]map[string]string { + return map[string]map[string]string{ + cli: {"tfg.exe": "the command line", "LICENSE": "the licence", "README.md": "read me"}, + window: {"tfg-gui.exe": "the window", "opengl/opengl32.dll": "a renderer", "LICENSE": "the licence", "README.md": "read me"}, + } + } + + type archives = map[string]map[string]string + for _, c := range []struct { + what string + change func(t *testing.T, a archives, dir, out string) + says string + }{ + {"nothing wrong with the archives", func(*testing.T, archives, string, string) {}, + "dotnet tool install --global wix --version 5.0.2"}, + {"a document differs between the two archives", func(_ *testing.T, a archives, _, _ string) { + a[window]["LICENSE"] = "another licence" + }, "both hold LICENSE, and not the same bytes"}, + {"the command line archive is missing", func(_ *testing.T, a archives, _, _ string) { + delete(a, cli) + }, "holds no " + cli}, + {"an archive holds no program", func(_ *testing.T, a archives, _, _ string) { + delete(a[cli], "tfg.exe") + }, "hold no tfg.exe at the top"}, + // Deep enough to leave the run's working folder too: unpacked as + // written, it would land beside the folders of this case, where the + // guard looks for it below. + {"a name inside an archive leads out of the folder", func(_ *testing.T, a archives, _, _ string) { + a[cli]["../../../escaped.txt"] = "out" + }, "a name that leads out of the folder"}, + {"an archive is not a zip", func(t *testing.T, a archives, dir, _ string) { + delete(a, cli) + if err := os.WriteFile(filepath.Join(dir, cli), []byte("not a zip"), 0o600); err != nil { + t.Fatal(err) + } + }, "is not a zip archive"}, + {"the installer is already there", func(t *testing.T, _ archives, _, out string) { + if err := os.WriteFile(filepath.Join(out, installer), []byte("an earlier one"), 0o600); err != nil { + t.Fatal(err) + } + }, "is already there. Nothing is overwritten"}, + } { + t.Run(c.what, func(t *testing.T) { + base := t.TempDir() + dir, out, temp := filepath.Join(base, "archives"), filepath.Join(base, "out"), filepath.Join(base, "temp") + for _, d := range []string{dir, out, temp} { + if err := os.Mkdir(d, 0o700); err != nil { + t.Fatal(err) + } + } + set := good() + c.change(t, set, dir, out) + for name, files := range set { + writeZipOf(t, filepath.Join(dir, name), files) + } + before := entriesOf(t, out) + + r := runMSI(t, temp, true, "--tag", tag, "--archives", dir, "--out-dir", out) + if r.code != 1 || !strings.Contains(r.said, c.says) { + t.Errorf("exit %d, and build_msi.py said:\n%s\nwant exit 1 and %q", r.code, r.said, c.says) + } + if strings.Contains(r.said, "github.com/wixtoolset") { + t.Errorf("the refusal gives an address to download WiX from. It gives the command " + + "that installs the pinned version, and nothing to click") + } + if after := entriesOf(t, out); after != before { + t.Errorf("--out-dir held %q before and %q after a run that refused", before, after) + } + if left := entriesOf(t, temp); left != "" { + t.Errorf("a run that refused left %q in its working folder", left) + } + if _, err := os.Stat(filepath.Join(base, "escaped.txt")); err == nil { + t.Error("a name inside an archive wrote a file outside the folder it was unpacked into") + } + }) + } +} diff --git a/internal/guard/packaging_test.go b/internal/guard/packaging_test.go index 67527ba6..b4bdb086 100644 --- a/internal/guard/packaging_test.go +++ b/internal/guard/packaging_test.go @@ -463,10 +463,11 @@ func TestThePackagesNameTheProductAndLicenceTheProgramDoes(t *testing.T) { // does to their machine. A missing file shows up on somebody else's clone. func TestThePackageSourcesAreTrackedByGit(t *testing.T) { tracked := map[string]bool{} - for _, name := range strings.Fields(gitOutput(t, "ls-files", "packaging", ".github/scripts/build_packages.py")) { + for _, name := range strings.Fields(gitOutput(t, "ls-files", "packaging", + ".github/scripts/build_packages.py", ".github/scripts/build_msi.py")) { tracked[name] = true } - want := []string{".github/scripts/build_packages.py", "packaging/README.md"} + want := []string{".github/scripts/build_packages.py", ".github/scripts/build_msi.py", "packaging/README.md"} for name := range packagingTemplates(t) { want = append(want, name) } diff --git a/internal/guard/packagingrefusal_test.go b/internal/guard/packagingrefusal_test.go index 46ed4e71..1915825d 100644 --- a/internal/guard/packagingrefusal_test.go +++ b/internal/guard/packagingrefusal_test.go @@ -293,7 +293,8 @@ sys.path.insert(0, sys.argv[1]) import build_packages as bp table = {"OK": "fine", "QUOTE": "it's", "MARKUP": "a & b", "COLON": "key: value", - "HASH": "a #b", "LINES": "one\ntwo"} + "HASH": "a #b", "LINES": "one\ntwo", "DQUOTE": 'say "so"', "LT": "a < b", + "WIXVAR": "$(PayloadDir)"} for label, text, name in [ ("unknown placeholder", "x {{NOT_A_KEY}} y", "chocolatey/tools/a.ps1"), ("quote in a script", "Write-Host '{{QUOTE}}'", "chocolatey/tools/a.ps1"), @@ -302,6 +303,11 @@ for label, text, name in [ ("comment in YAML", "Short: {{HASH}}", "winget/locale.en-US.yaml"), ("several lines inside a line", "x {{LINES}} y", "winget/locale.en-US.yaml"), ("clean", "Short: {{OK}}", "winget/locale.en-US.yaml"), + ("double quote in the installer", 'Name="{{DQUOTE}}"', "tfg-setup.wxs"), + ("bracket in the installer", 'Name="{{LT}}"', "tfg-setup.wxs"), + ("ampersand in the installer", 'Name="{{MARKUP}}"', "tfg-setup.wxs"), + ("WiX variable in the installer", 'Name="{{WIXVAR}}"', "tfg-setup.wxs"), + ("clean installer", 'Name="{{OK}}"', "tfg-setup.wxs"), ]: try: bp.render(text, table, name) @@ -336,13 +342,17 @@ except SystemExit as refusal: answers[m[1]] = m[2] } for _, label := range []string{"unknown placeholder", "quote in a script", "markup in the nuspec", - "colon in YAML", "comment in YAML", "several lines inside a line", "unused value"} { + "colon in YAML", "comment in YAML", "several lines inside a line", "unused value", + "double quote in the installer", "bracket in the installer", "ampersand in the installer", + "WiX variable in the installer"} { if answers[label] != "REFUSED" { t.Errorf("%s: the renderer answered %q, and it has to refuse:\n%s", label, answers[label], said) } } - if answers["clean"] != "RENDERED" { - t.Errorf("the clean case was not rendered (%q), so the probe cannot tell a refusal from "+ - "a failure:\n%s", answers["clean"], said) + for _, clean := range []string{"clean", "clean installer"} { + if answers[clean] != "RENDERED" { + t.Errorf("the %s case was not rendered (%q), so the probe cannot tell a refusal from "+ + "a failure:\n%s", clean, answers[clean], said) + } } } diff --git a/packaging/msi/tfg-setup.wxs.in b/packaging/msi/tfg-setup.wxs.in new file mode 100644 index 00000000..20dfd7b2 --- /dev/null +++ b/packaging/msi/tfg-setup.wxs.in @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From f59f60d3a465c27c597a687c52d92bbdb8a71b13 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Mon, 28 Sep 2026 23:04:53 +0200 Subject: [PATCH 02/11] 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 --- .github/scripts/sign_release.py | 148 +++++++++++++++++++++++++++++--- internal/guard/msi_test.go | 130 ++++++++++++++++++++++++++++ 2 files changed, 265 insertions(+), 13 deletions(-) diff --git a/.github/scripts/sign_release.py b/.github/scripts/sign_release.py index 2e4e26d7..26e3d886 100644 --- a/.github/scripts/sign_release.py +++ b/.github/scripts/sign_release.py @@ -34,11 +34,16 @@ each one, notarises it with Apple and staples the ticket on. A bare macOS binary cannot be stapled at all, so without this those two archives are refused by Gatekeeper even though they are signed; - 6. repacks those archives and writes verify-SHA256SUMS.txt over everything it - is about to publish, signed and unsigned alike; - 7. uploads the lot to the DRAFT release and asks attest-release.yml for the + 6. builds the Windows installer from the two signed amd64 archives, with + build_msi.py taken from the tree of the TAG rather than from the checkout, + signs it with the card and reads its certificate back like the programs'. + A release candidate gets no installer, because Windows Installer reads + only the three numbers of a version; + 7. writes verify-SHA256SUMS.txt over everything it is about to publish, + signed and unsigned alike; + 8. uploads the lot to the DRAFT release and asks attest-release.yml for the statement about the signed bytes; - 8. waits for that statement and confirms the draft is complete. Until this + 9. waits for that statement and confirms the draft is complete. Until this existed in the project this came from, the script ended at "dispatched, go look" - and a draft missing one file looks almost exactly like a finished one. @@ -48,12 +53,14 @@ import argparse import datetime import hashlib +import io import json import os import re import shutil import subprocess import sys +import tarfile import time import zipfile @@ -461,6 +468,96 @@ def sign_macos(tag, directory, host, dry_run): print(" %d macOS archive(s) signed, notarised and stapled" % len(archives)) +def is_candidate(tag): + """A tag with a hyphen is a release candidate. + + The one rule, in the three places that ask it: release.yml marks such a + release a pre-release, build_msi.py refuses to build it an installer, and + this script neither builds one nor expects one on the draft. Windows + Installer reads only the three numbers of a version, so a candidate's + installer would carry the release's own version. + """ + return "-" in tag + + +def export_tree(tag, into): + """The tree of the tag, exactly as it was tagged, in a folder of its own. + + The installer is built from what was tagged. Nothing in this script knows + which commit the checkout stands on, and a template changed on main after + the tag would otherwise go into a release that never carried it - so the + script that builds the installer runs from this copy, and reads the + template, the names and the icon beside it. Beside the work folder, never + inside it: everything in there gets a checksum and goes on the page. + """ + if os.path.isdir(into): + shutil.rmtree(into) + found = subprocess.run(["git", "rev-parse", "--verify", "--quiet", tag + "^{commit}"], + capture_output=True, text=True) + if found.returncode != 0: + raise SystemExit( + "sign_release: this clone has no tag %s, so there is no tagged tree to build " + "the installer from. Fetch it first:\n git fetch --tags" % tag) + archive = subprocess.run(["git", "archive", "--format=tar", tag], capture_output=True) + if archive.returncode != 0: + raise SystemExit("sign_release: git archive %s failed:\n%s" + % (tag, archive.stderr.decode(errors="replace").strip())) + os.makedirs(into) + with tarfile.open(fileobj=io.BytesIO(archive.stdout)) as tar: + tar.extractall(into, filter="data") + print(" the tree of %s: %d file(s) in %s" % (tag, len(files_under(into)), into)) + + +def installer_script(tree): + """build_msi.py as the tag has it, or a refusal for a tag from before it.""" + script = os.path.join(tree, ".github", "scripts", "build_msi.py") + if not os.path.isfile(script): + raise SystemExit( + "sign_release: the tag has no .github/scripts/build_msi.py - it was tagged " + "before the installer existed, and the release cannot carry one") + return script + + +def check_installer(tag, tree): + """Refuse before the card signs anything when the installer cannot be built. + + Stopping after the programs are signed would leave a draft without the + installer, and the next run signs them all again. + """ + run([sys.executable, installer_script(tree), "--tag", tag, "--check"]) + + +def build_installer(tag, tree, work, thumbprint, pin, signtool, dry_run): + """Build the installer from the signed archives in work, and sign it. + + Signed like the programs, with a timestamp, and its certificate read back + out of the file and held to the pin. The installer is what an + administrator runs with the highest rights the machine has, so it is the + last file to leave unsigned. + """ + run([sys.executable, installer_script(tree), "--tag", tag, + "--archives", work, "--out-dir", work]) + installers = [n for n in sorted(os.listdir(work)) if n.endswith(".msi")] + if len(installers) != 1: + raise SystemExit("sign_release: expected one installer in %s after building it and " + "found %d: %s" % (work, len(installers), ", ".join(installers) or "none")) + path = os.path.join(work, installers[0]) + command = [signtool, "sign", "/sha1", thumbprint, "/fd", "sha256", + "/tr", TIMESTAMP_URL, "/td", "sha256", "/v", path] + if dry_run: + print(" DRY RUN, would run: %s" % " ".join(command)) + return + run(command) + run([signtool, "verify", "/pa", "/v", path]) + signer = certificate_of(path) + if signer != pin: + raise SystemExit( + "sign_release: %s was signed by a DIFFERENT certificate\n" + " expected %s\n got %s\nNothing has been uploaded." + % (installers[0], pin, signer)) + print(" %s: built from the tagged tree, signed" % installers[0]) + + def name_for_publication(directory, tag): """Give the build's own files the names a person will see, or drop them. @@ -545,6 +642,14 @@ def confirm_draft(tag, wait_seconds, dry_run): missing = [] if sum(1 for n in names if n.endswith((".zip", ".tar.gz"))) != 8: missing.append("eight archives") + # One installer for a release, none for a candidate - asked both ways, since + # an installer on a candidate's page would carry the release's version. + installers = [n for n in names if n.endswith(".msi")] + if is_candidate(tag) and installers: + raise SystemExit("sign_release: %s is a release candidate and its draft holds an " + "installer: %s" % (tag, ", ".join(installers))) + if not is_candidate(tag) and len(installers) != 1: + missing.append("one installer (it holds %d)" % len(installers)) # Each of the four is asked for WITH its prefix, not by suffix alone. A # suffix would be happy with a file the prefix fell off, and the prefix is # the only thing keeping these four at the end of the download list. @@ -589,34 +694,51 @@ def main(argv=None): "archives are rejected by Gatekeeper unless this step runs.") pin = pinned_digest() work = os.path.join("dist", "signing", args.tag) - - print("\n[1/8] fetching the build for %s" % args.tag) + tree = os.path.join("dist", "signing", args.tag + ".tree") + + # Asked here, like the Mac above, before the card signs anything: stopping + # at the installer would leave signed programs and no installer. + print("\nbefore anything: can this machine build the installer") + if is_candidate(args.tag): + print(" %s is a release candidate, so it gets no installer" % args.tag) + else: + export_tree(args.tag, tree) + check_installer(args.tag, tree) + + print("\n[1/9] fetching the build for %s" % args.tag) fetch_build(args.tag, work) - print("\n[2/8] checking it before touching it") + print("\n[2/9] checking it before touching it") verify_before_touching(work) - print("\n[3/8] the card") + print("\n[3/9] the card") thumbprint = signing_thumbprint(pin) signtool = find_signtool() - print("\n[4/8] signing the Windows programs") + print("\n[4/9] signing the Windows programs") for name in windows_archives(work): sign_archive(os.path.join(work, name), thumbprint, pin, signtool, args.dry_run) - print("\n[5/8] signing the macOS bundles on %s" % args.macos_host) + print("\n[5/9] signing the macOS bundles on %s" % args.macos_host) sign_macos(args.tag, work, args.macos_host, args.dry_run) - print("\n[6/8] checksums over what will be published") + print("\n[6/9] the Windows installer") + if is_candidate(args.tag): + print(" none for a release candidate") + else: + build_installer(args.tag, tree, work, thumbprint, pin, signtool, args.dry_run) + shutil.rmtree(tree) + + print("\n[7/9] checksums over what will be published") name_for_publication(work, args.tag) digest = write_checksums(work) print(" %sSHA256SUMS.txt: %s" % (AUX_PREFIX, digest)) - print("\n[7/8] uploading to the draft") + print("\n[8/9] uploading to the draft") upload_to_draft(args.tag, work, args.dry_run) ask_for_the_statement(args.tag, digest, args.dry_run) - print("\n[8/8] waiting for the statement and checking the draft") + print("\n[9/9] waiting for the statement and checking the draft") confirm_draft(args.tag, args.wait, args.dry_run) print("\nDone. %s is a complete DRAFT." % args.tag) diff --git a/internal/guard/msi_test.go b/internal/guard/msi_test.go index 740b5985..b2853e74 100644 --- a/internal/guard/msi_test.go +++ b/internal/guard/msi_test.go @@ -8,7 +8,9 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "sort" + "strconv" "strings" "testing" ) @@ -405,3 +407,131 @@ func TestTheInstallerIsBuiltFromBothArchivesOrNotAtAll(t *testing.T) { }) } } + +// pythonDef is one top-level function of a script, cut at the next top-level +// def - by what the text says, never by line numbers. +func pythonDef(t *testing.T, code, name string) string { + t.Helper() + start := strings.Index(code, "\ndef "+name+"(") + if start < 0 { + t.Fatalf("the script has no function %s", name) + } + rest := code[start+1:] + if end := strings.Index(rest, "\ndef "); end >= 0 { + rest = rest[:end] + } + return rest +} + +// The release signs the installer the way it signs the programs, asks whether +// it can build one before the card signs anything, and does not call a draft +// complete without it. +// +// The installer is what an administrator runs with the highest rights the +// machine has. And a check after the card would leave signed programs on a +// draft that can never get its installer from that run. +func TestTheSigningSignsTheInstallerAndExpectsItOnTheDraft(t *testing.T) { + script := activePython(signingScript(t)) + build := pythonDef(t, script, "build_installer") + for what, want := range map[string]string{ + "it timestamps the installer's signature, or it dies with the certificate": `"/tr", TIMESTAMP_URL, "/td", "sha256", "/v", path]`, + "it reads the installer's certificate back out of the file": `signer = certificate_of(path)`, + "it holds that certificate to the pin": `if signer != pin:`, + "it runs build_msi.py as the tag has it": `installer_script(tree)`, + } { + if !strings.Contains(build, want) { + t.Errorf("%s: build_installer does not contain %q", what, want) + } + } + + main := pythonDef(t, script, "main") + check, card := strings.Index(main, "check_installer(args.tag, tree)"), strings.Index(main, "signing_thumbprint(pin)") + if check < 0 || card < 0 || check > card { + t.Errorf("main asks whether the installer can be built at %d and reaches the card at %d. "+ + "It asks first, so a machine without WiX stops before anything is signed", check, card) + } + + draft := pythonDef(t, script, "confirm_draft") + for what, want := range map[string]string{ + "a release's draft is not complete without exactly one installer": `if not is_candidate(tag) and len(installers) != 1:`, + "a candidate's draft carries none": `if is_candidate(tag) and installers:`, + } { + if !statementIn(draft, regexp.QuoteMeta(want)) { + t.Errorf("%s: no line of confirm_draft begins with %s", what, want) + } + } +} + +// The installer is built from the tree of the tag, not from the checkout. +// +// Nothing in the signing script knows which commit the checkout stands on, and +// a template changed on main after the tag would otherwise ship in a release +// that never carried it. Asked of the real mechanism: the tree of HEAD is +// exported the way a tag's is, and it has to hold exactly what the commit +// holds - a copy of the working tree would bring along whatever lies in it +// untracked - with build_msi.py taken from inside it. +func TestTheInstallerIsBuiltFromTheTaggedTreeNotTheCheckout(t *testing.T) { + probe := ` +import os, sys +sys.path.insert(0, sys.argv[1]) +import sign_release as sr + +base = sys.argv[2] +tree = os.path.join(base, "tree") +sr.export_tree("HEAD", tree) +count = sum(len(files) for _, _, files in os.walk(tree)) +print("files: %d" % count) +print("script: " + os.path.relpath(sr.installer_script(tree), base).replace(os.sep, "/")) +for tag in ("v0.5.0", "v0.5.0-rc1", "v1.0.0-beta.2"): + print("candidate %s: %s" % (tag, sr.is_candidate(tag))) +try: + sr.export_tree("refs/tags/no-such-tag", os.path.join(base, "none")) + print("missing tag: EXPORTED") +except SystemExit as refusal: + print("missing tag: " + ("REFUSED" if "git fetch --tags" in str(refusal) else str(refusal))) +` + dir := t.TempDir() + file := filepath.Join(dir, "probe.py") + if err := os.WriteFile(file, []byte(probe), 0o600); err != nil { + t.Fatal(err) + } + // The interpreter is the one found on PATH, the script is the probe this + // guard just wrote, and every argument is a path it chose. + // nosemgrep: go.lang.security.audit.dangerous-exec-command.dangerous-exec-command + cmd := exec.Command(pythonForGate(t), file, filepath.Join(repoRoot(t), ".github", "scripts"), dir) + cmd.Dir = repoRoot(t) + said, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("the probe failed: %v\n%s", err, said) + } + committed := len(strings.Fields(gitOutput(t, "ls-tree", "-r", "--name-only", "HEAD"))) + for _, want := range []string{ + "files: " + strconv.Itoa(committed), + "script: tree/.github/scripts/build_msi.py", + "candidate v0.5.0: False", + "candidate v0.5.0-rc1: True", + "candidate v1.0.0-beta.2: True", + "missing tag: REFUSED", + } { + if !strings.Contains(string(said), want+"\n") && !strings.Contains(string(said), want+"\r\n") { + t.Errorf("the probe did not say %q:\n%s", want, said) + } + } +} + +// One rule for a release candidate, in every place that asks it: a hyphen in +// the tag. release.yml marks such a release a pre-release, build_msi.py builds +// it no installer, and the signing script neither builds one nor expects one. +// Two rules would disagree about a tag like v1.0.0-beta, and the one that +// says "release" would put an installer on a candidate's page. +func TestEveryPlaceAsksTheSameQuestionOfACandidate(t *testing.T) { + if !strings.Contains(withoutYamlComments(workflowText(t, "release.yml")), "*-*) flags+=(--prerelease) ;;") { + t.Error("release.yml no longer marks a tag with a hyphen as a pre-release in the words this guard reads") + } + if !statementIn(pythonDef(t, activePython(signingScript(t)), "is_candidate"), `return "-" in tag$`) { + t.Error("sign_release.py does not call a tag with a hyphen a candidate") + } + if !statementIn(activePython(readRepoFile(t, ".github/scripts/build_msi.py")), `if "-" in tag:$`) { + t.Error("build_msi.py does not refuse a tag with a hyphen as a candidate") + } +} From 22cbbd399c88cdcb6254a8fa950e7d0be4a973b3 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Mon, 28 Sep 2026 23:11:56 +0200 Subject: [PATCH 03/11] 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 --- .github/workflows/ci.yml | 157 +++++++++++++++++++++++++++++++++++++ internal/guard/msi_test.go | 46 +++++++++++ 2 files changed, 203 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f5f7216..8d9a39f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -528,6 +528,163 @@ jobs: if ($failed -ne 0) { throw "$failed check(s) failed - read the FAILED lines above" } "every check passed" + installer: + name: the installer installs and leaves + # What the Windows installer DOES, asked on a clean Windows machine rather + # than read off its source. The guards in internal/guard/msi_test.go hold + # the lines each measurement rests on, and a line being there is not the + # install working (docs/PACKAGING-2026-09-25.md section 13). The installer + # is built unsigned here, from the latest published release's two amd64 + # archives - checked against its checksums - and this commit's template, + # the way sign_release.py builds the signed one from a tag. It is + # installed silently and asked what it did, built again and installed over + # itself while a tfg run is in progress, and removed with a file of + # somebody else's in its folder. + # + # Against the latest release for the reason the job above gives: the + # archives are the ones a person downloads. The upgrade from one version + # to the next needs two published installers, so it was measured on a + # virtual machine instead, 0.3.0 to 0.4.0 (tools/packaging-vm). + runs-on: windows-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + + - name: build the installer twice from the latest release + id: build + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference = 'Stop' + # The WiX this project builds with. build_msi.py refuses any other, + # and finds it in the dotnet tools folder without a PATH change. + dotnet tool install --global wix --version 5.0.2 + if ($LASTEXITCODE -ne 0) { throw "could not install WiX 5.0.2" } + $tag = gh release view --json tagName --jq .tagName + if ($LASTEXITCODE -ne 0 -or -not $tag) { throw "could not read the latest release" } + $archives = Join-Path $env:RUNNER_TEMP 'archives' + gh release download $tag --dir $archives --pattern 'tfg_*_windows_amd64.zip' --pattern 'tfg-gui_*_windows_amd64.zip' --pattern verify-SHA256SUMS.txt + if ($LASTEXITCODE -ne 0) { throw "could not download the archives of $tag" } + $sums = Get-Content (Join-Path $archives 'verify-SHA256SUMS.txt') + foreach ($zip in Get-ChildItem $archives -Filter '*.zip') { + $got = (Get-FileHash $zip.FullName -Algorithm SHA256).Hash.ToLower() + if (-not ($sums -contains ($got + ' ' + $zip.Name))) { throw "$($zip.Name) does not match the checksums of $tag" } + } + # Twice, because every build carries a new ProductCode - the second + # is the same version rebuilt, as a feed's moderation may ask for. + foreach ($build in 'first', 'second') { + $out = Join-Path $env:RUNNER_TEMP $build + New-Item -ItemType Directory -Force $out | Out-Null + python .github/scripts/build_msi.py --tag $tag --archives $archives --out-dir $out + if ($LASTEXITCODE -ne 0) { throw "build_msi.py refused $tag" } + } + "version=$($tag.TrimStart('v'))" >> $env:GITHUB_OUTPUT + + - name: install, ask the machine, install again while tfg runs, remove, ask again + shell: pwsh + env: + VERSION: ${{ steps.build.outputs.version }} + run: | + $ErrorActionPreference = 'Stop' + $failed = 0 + function Check($ok, $what) { + if ($ok) { "ok $what" } else { "FAILED $what"; $script:failed++ } + } + $name = 'Testing Files Generator' + $dir = Join-Path $env:ProgramFiles $name + $shortcut = Join-Path ([Environment]::GetFolderPath('CommonPrograms')) "$name.lnk" + $archives = Join-Path $env:RUNNER_TEMP 'archives' + $first = (Get-ChildItem (Join-Path $env:RUNNER_TEMP 'first') -Filter '*.msi').FullName + $second = (Get-ChildItem (Join-Path $env:RUNNER_TEMP 'second') -Filter '*.msi').FullName + + # The arguments in a variable and a limit on the wait: a quote that + # swallows the file name leaves msiexec waiting on a help window. + function Msi($verb, $file, $log) { + $argv = @($verb, "`"$file`"", '/qn', '/norestart', '/l*v', "`"$(Join-Path $env:RUNNER_TEMP $log)`"") + $p = Start-Process -FilePath (Join-Path $env:SystemRoot 'System32\msiexec.exe') -ArgumentList $argv -PassThru + $null = $p.Handle + if (-not $p.WaitForExit(600000)) { throw "msiexec $verb did not finish in ten minutes" } + return $p.ExitCode + } + function Entries { + @(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -eq $name }) + } + function Listing { + if (-not (Test-Path -LiteralPath $dir)) { return '(no folder)' } + (Get-ChildItem -LiteralPath $dir -Recurse -File | ForEach-Object { $_.FullName.Substring($dir.Length + 1).Replace('\', '/') } | Sort-Object) -join ', ' + } + function OnPath { + @(([Environment]::GetEnvironmentVariable('Path', 'Machine')).Split(';') | Where-Object { $_.TrimEnd('\') -eq $dir }) + } + # What the folder has to hold: the two archives, read out of them. + Add-Type -AssemblyName System.IO.Compression.FileSystem + $want = (Get-ChildItem $archives -Filter '*.zip' | ForEach-Object { + $zip = [IO.Compression.ZipFile]::OpenRead($_.FullName) + try { $zip.Entries | Where-Object { $_.Name } | ForEach-Object { $_.FullName } } finally { $zip.Dispose() } + } | Sort-Object -Unique) -join ', ' + function Installed { + $e = @(Entries) + Check ($e.Count -eq 1 -and $e[0].DisplayVersion -eq $env:VERSION) "one entry in Programs and Features, at $env:VERSION ($(($e | ForEach-Object { $_.DisplayVersion }) -join ', '))" + Check ((Listing) -eq $want) "the folder holds exactly the two archives ($(Listing))" + Check (@(OnPath).Count -eq 1) "the folder is on the machine PATH once ($(@(OnPath).Count))" + Check (-not (Test-Path (Join-Path $dir 'opengl32.dll'))) "opengl32.dll is not in the folder on PATH, where other programs would load it" + # Asked from a new process, with the PATH a new terminal would read. + $env:Path = [Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [Environment]::GetEnvironmentVariable('Path', 'User') + $said = ((& cmd.exe /c 'tfg version' 2>&1) | Out-String).Trim() + Check ($said -eq $env:VERSION) "tfg version, found on PATH, answers $env:VERSION (said '$said')" + Check (Test-Path $shortcut) "the Start menu has the shortcut, for every account" + $link = (New-Object -ComObject WScript.Shell).CreateShortcut($shortcut) + Check ($link.TargetPath -eq (Join-Path $dir 'tfg-gui.exe')) "the shortcut starts the window ($($link.TargetPath))" + Check ($link.WorkingDirectory.TrimEnd('\') -eq $dir) "the shortcut starts in the install folder, which the window recognises as its own ($($link.WorkingDirectory))" + } + + Check (@(Entries).Count -eq 0 -and -not (Test-Path $dir) -and @(OnPath).Count -eq 0) "nothing of ours is on the runner before the install" + $code = Msi '/i' $first 'install.log' + Check ($code -eq 0) "the installer installs (exit $code)" + Installed + $firstCode = @(Entries)[0].PSChildName + + # A tfg run kept in progress without writing to disk: its output goes + # into a pipe nobody reads, and once the pipe is full it waits. + $info = [Diagnostics.ProcessStartInfo]::new((Join-Path $dir 'tfg.exe'), 'formats --json') + $info.UseShellExecute = $false + $info.RedirectStandardOutput = $true + $info.CreateNoWindow = $true + $held = [Diagnostics.Process]::Start($info) + Start-Sleep -Seconds 2 + Check (-not $held.HasExited) "a tfg run is in progress" + $code = Msi '/i' $second 'rebuild.log' + Check ($code -eq 0 -or $code -eq 3010) "the rebuild installs over the first while tfg runs (exit $code)" + Check (-not $held.HasExited) "the tfg run is still going - nothing ended it" + [void]$held.StandardOutput.ReadToEnd() + [void]$held.WaitForExit(10000) + Check ($held.ExitCode -eq 0) "the tfg run finished with 0 ($($held.ExitCode))" + Installed + Check (@(Entries).Count -eq 1 -and @(Entries)[0].PSChildName -ne $firstCode) "the rebuild replaced the first build instead of installing beside it" + + $planted = Join-Path $dir 'somebody-elses.txt' + Set-Content -LiteralPath $planted -Value 'not ours' + $code = Msi '/x' $second 'uninstall.log' + Check ($code -eq 0) "the uninstall succeeds (exit $code)" + Check (@(Entries).Count -eq 0) "no entry is left in Programs and Features" + Check ((Listing) -eq 'somebody-elses.txt') "only the file of somebody else is left in the folder ($(Listing))" + Check (@(OnPath).Count -eq 0) "nothing of ours is left on the machine PATH" + Check (-not (Test-Path $shortcut)) "the shortcut is gone" + Check (-not (Test-Path 'HKLM:\Software\DonislawDev')) "nothing of ours is left in the registry" + + if ($failed -ne 0) { + Get-ChildItem $env:RUNNER_TEMP -Filter '*.log' | ForEach-Object { "--- $($_.Name)"; Get-Content $_.FullName -Tail 40 } + throw "$failed check(s) failed - read the FAILED lines above" + } + "every check passed" + govulncheck: name: known vulnerabilities runs-on: ubuntu-latest diff --git a/internal/guard/msi_test.go b/internal/guard/msi_test.go index b2853e74..fc4ce671 100644 --- a/internal/guard/msi_test.go +++ b/internal/guard/msi_test.go @@ -535,3 +535,49 @@ func TestEveryPlaceAsksTheSameQuestionOfACandidate(t *testing.T) { t.Error("build_msi.py does not refuse a tag with a hyphen as a candidate") } } + +// What the installer does on a machine is asked by one job in ci.yml, and the +// guards above only hold its source - so the job is held here, the way the +// import table's is: a job that stopped building the installer, stopped +// installing it or stopped failing on a failed check would leave every guard +// green and the installer asked by nobody. +// +// The WiX version is read out of build_msi.py rather than typed here, because +// the script refuses every other version and a job installing another one +// would stop at that refusal instead of at the install. +func TestTheInstallerIsInstalledOnARunner(t *testing.T) { + jobs := ciJobs(workflowText(t, "ci.yml")) + if len(jobs) < 5 { + t.Fatalf("only %d job(s) were read out of ci.yml, so this guard is not reading the file it thinks it is", len(jobs)) + } + var builders []string + for job, block := range jobs { + if strings.Contains(withoutYamlComments(block), "python .github/scripts/build_msi.py") { + builders = append(builders, job) + } + } + if len(builders) != 1 { + t.Fatalf("%d job(s) in ci.yml build the installer, and exactly one has to: %v", len(builders), builders) + } + job := builders[0] + block := withoutYamlComments(jobs[job]) + + wix := regexp.MustCompile(`(?m)^WIX_VERSION = "([^"]+)"$`).FindStringSubmatch(readRepoFile(t, ".github/scripts/build_msi.py")) + if wix == nil { + t.Fatal("build_msi.py names no WIX_VERSION, so there is nothing to hold the job to") + } + for what, want := range map[string]string{ + "it runs on Windows, the only system that installs it": "runs-on: windows-latest", + "it installs the WiX version build_msi.py builds with": "dotnet tool install --global wix --version " + wix[1], + "it installs silently, as a deployment does": "'/qn'", + "it asks the command line through PATH, from a new process": "cmd.exe /c 'tfg version'", + "it asks where the shortcut starts the window": "$link.WorkingDirectory", + "it asks an upgrade while a tfg run is in progress": "$held.HasExited", + "it uninstalls and asks what is left": "Msi '/x'", + "it fails the step when a check failed, rather than printing FAILED": `throw "$failed check(s) failed`, + } { + if !strings.Contains(block, want) { + t.Errorf("%s: job %q does not contain %q", what, job, want) + } + } +} From 6484e7839209d86490281e2516ee915c81533485 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Mon, 28 Sep 2026 23:20:18 +0200 Subject: [PATCH 04/11] 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 --- .github/workflows/verify-release.yml | 48 +++++++++++++++++++++++++++- internal/guard/msi_test.go | 48 ++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/.github/workflows/verify-release.yml b/.github/workflows/verify-release.yml index e14511e6..53cd60b1 100644 --- a/.github/workflows/verify-release.yml +++ b/.github/workflows/verify-release.yml @@ -186,14 +186,29 @@ jobs: continue-on-error: true shell: bash working-directory: published + env: + TAG: ${{ steps.which.outputs.tag }} # A release missing one file looks almost exactly like a finished one, # which is why this counts rather than glances. Eight archives, the - # checksums, the bill of materials and the two statements. + # installer, the checksums, the bill of materials and the two statements. run: | set -euo pipefail archives="$(ls -1 -- *.zip *.tar.gz 2>/dev/null | wc -l)" echo "archives: $archives" test "$archives" = "8" || { echo "expected eight archives"; exit 1; } + # One installer for a release and none for a release candidate - a + # tag with a hyphen, the rule release.yml and sign_release.py use. + # Windows Installer reads only the three numbers of a version, so a + # candidate's installer would carry the release's own version. + shopt -s nullglob + installers=(*.msi) + echo "installers: ${#installers[@]}" + case "$TAG" in + *-*) test "${#installers[@]}" = "0" || { echo "a release candidate carries an installer"; exit 1; } ;; + *) test "${#installers[@]}" = "1" && test -f "tfg-setup_${TAG#v}_windows_amd64.msi" || + { echo "expected one installer, tfg-setup_${TAG#v}_windows_amd64.msi"; exit 1; } ;; + esac + shopt -u nullglob # Named with the verify- prefix rather than by suffix alone, because # the prefix is the only thing keeping these four at the end of the # download list, and a suffix is happy with a file that lost it. @@ -320,6 +335,35 @@ jobs: if ($checked -lt 3) { throw "only $checked signed file(s) were checked across three archives" } "$checked file(s) signed by the pinned certificate, each with a timestamp" + - name: the installer is signed, stamped, and by OUR certificate + id: installer_signature + continue-on-error: true + shell: pwsh + working-directory: published + # A step of its own rather than a fourth file in the one above, so a + # release candidate - which carries no installer - leaves that one as + # it was, and an unsigned installer is its own line in the verdict. The + # installer is what an administrator runs with the machine's highest + # rights. Whether there is one at all is the asset count's question, + # so this checks every installer published and nothing else. + run: | + $line = Select-String -Path ../internal/legal/codesign.go ` + -Pattern 'CodeSigningSHA256 = "([0-9a-f]{64})"' + if (-not $line) { throw "could not read the pinned certificate out of internal/legal/codesign.go" } + $pinned = $line.Matches[0].Groups[1].Value + $installers = @(Get-ChildItem -Filter *.msi) + if ($installers.Count -eq 0) { "no installer is published, so there is no signature to check"; exit 0 } + foreach ($msi in $installers) { + $sig = Get-AuthenticodeSignature -LiteralPath $msi.FullName + "$($msi.Name): $($sig.Status)" + if ($sig.Status -ne 'Valid') { throw "$($msi.Name): signature status is $($sig.Status)" } + if (-not $sig.TimeStamperCertificate) { throw "$($msi.Name): the signature carries no timestamp" } + $bytes = [System.Security.Cryptography.SHA256]::Create().ComputeHash($sig.SignerCertificate.RawData) + $actual = ($bytes | ForEach-Object { $_.ToString('x2') }) -join '' + if ($actual -ne $pinned) { throw "$($msi.Name) was signed by a DIFFERENT certificate: $actual" } + } + "$($installers.Count) installer(s) signed by the pinned certificate, with a timestamp" + - name: the page promises what was just checked id: notes continue-on-error: true @@ -394,6 +438,7 @@ jobs: COMMANDS_API: ${{ steps.commands_api.outcome }} COMMANDS_OFFLINE: ${{ steps.commands_offline.outcome }} SIGNATURES: ${{ steps.signatures.outcome }} + INSTALLER_SIGNATURE: ${{ steps.installer_signature.outcome }} NOTES: ${{ steps.notes.outcome }} LATEST: ${{ steps.latest.outcome }} run: | @@ -411,6 +456,7 @@ jobs: report "$COMMANDS_API" "the commands the notes tell people to run" report "$COMMANDS_OFFLINE" "the same commands with no network" report "$SIGNATURES" "every Windows program by our certificate" + report "$INSTALLER_SIGNATURE" "the installer by our certificate" report "$NOTES" "the page promises what was just checked" report "$LATEST" "a full release is the one people are offered" echo diff --git a/internal/guard/msi_test.go b/internal/guard/msi_test.go index fc4ce671..9c35dd13 100644 --- a/internal/guard/msi_test.go +++ b/internal/guard/msi_test.go @@ -534,6 +534,54 @@ func TestEveryPlaceAsksTheSameQuestionOfACandidate(t *testing.T) { if !statementIn(activePython(readRepoFile(t, ".github/scripts/build_msi.py")), `if "-" in tag:$`) { t.Error("build_msi.py does not refuse a tag with a hyphen as a candidate") } + if !strings.Contains(releaseStepRun(t, "assets"), `*-*) test "${#installers[@]}" = "0"`) { + t.Error("verify-release.yml does not expect no installer on a tag with a hyphen") + } +} + +// releaseStepRun is the script of one step of verify-release.yml, found by its +// id, with its comment lines taken out. +func releaseStepRun(t *testing.T, id string) string { + t.Helper() + var found []string + for _, job := range readReleaseWorkflow(t).Jobs { + for _, step := range job.Steps { + if step.ID == id { + found = append(found, strings.Join(scriptLines(step.Run), "\n")) + } + } + } + if len(found) != 1 { + t.Fatalf("verify-release.yml has %d step(s) with the id %s, and this reads exactly one", len(found), id) + } + return found[0] +} + +// The last phase asks the page a person downloads from for the installer: one +// for a release, under the name build_msi.py gives it, and signed by our +// certificate with a timestamp. The two phases before it are run by the +// people who made the release - this one is the check that looks at what was +// published. +func TestTheReleaseCheckAsksForTheInstaller(t *testing.T) { + name := regexp.MustCompile(`(?m)^NAME = "([^"]+)"$`).FindStringSubmatch(readRepoFile(t, ".github/scripts/build_msi.py")) + if name == nil || !strings.Contains(name[1], "{version}") { + t.Fatal("build_msi.py names no installer with a {version} in it, so there is nothing to hold the check to") + } + published := strings.Replace(name[1], "{version}", "${TAG#v}", 1) + if !strings.Contains(releaseStepRun(t, "assets"), `test -f "`+published+`"`) { + t.Errorf("the asset count does not ask for %s, the name build_msi.py gives the installer - "+ + "a release would pass it with the installer missing or misnamed", published) + } + signature := releaseStepRun(t, "installer_signature") + for what, want := range map[string]string{ + "it reads the installer's signature": "Get-AuthenticodeSignature -LiteralPath $msi.FullName", + "it refuses an installer with no timestamp": "if (-not $sig.TimeStamperCertificate)", + "it refuses one signed by another certificate": "if ($actual -ne $pinned)", + } { + if !strings.Contains(signature, want) { + t.Errorf("%s: the installer's signature step does not contain %q", what, want) + } + } } // What the installer does on a machine is asked by one job in ci.yml, and the From 29ef490cff4be6e3b3cde8df2d4e58f1f80b8c3e Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Mon, 28 Sep 2026 23:25:45 +0200 Subject: [PATCH 05/11] 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 --- CHANGELOG.md | 13 ++++++++ internal/guard/downloadorder_test.go | 25 +++++++++++++++ packaging/README.md | 47 +++++++++++++++++++++++++++- 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18c0c182..3e1952b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,19 @@ because it turns other people's test suites red. ## [Unreleased] +### Added + +- **A Windows installer, `tfg-setup__windows_amd64.msi`, beside the + zip archives.** It installs the window and the command line for every + account on the machine, in `Program Files\Testing Files Generator`, puts + that folder on the machine's `PATH` once and the window in the Start menu, + and asks for administrator rights to do it. Upgrading while a `tfg` + command runs does not stop the command. The new version is in place at + once, and the old copy is removed at the next restart. Uninstalling + removes the folder entry from `PATH` and leaves any file in the folder + that the installer did not put there. A release candidate gets no + installer. + ### Fixed - **The window no longer offers to write into a folder it cannot or should diff --git a/internal/guard/downloadorder_test.go b/internal/guard/downloadorder_test.go index bb180370..0f0e498c 100644 --- a/internal/guard/downloadorder_test.go +++ b/internal/guard/downloadorder_test.go @@ -94,6 +94,31 @@ func TestTheFilesYouCheckADownloadWithSortToTheEnd(t *testing.T) { } } +// The installer sorts among the programs: after the window's archives and +// before the command line's, never among the files a person checks a download +// with. Its name is read out of build_msi.py, the one place it is written, so +// renaming it there is asked here. +func TestTheInstallerSortsAmongThePrograms(t *testing.T) { + found := regexp.MustCompile(`(?m)^NAME = "([^"]+)"$`).FindStringSubmatch(readRepoFile(t, ".github/scripts/build_msi.py")) + if found == nil || !strings.Contains(found[1], "{version}") { + t.Fatal("build_msi.py names no installer with a {version} in it, so there is nothing to sort") + } + installer := strings.Replace(found[1], "{version}", "0.2.0", 1) + for _, window := range []string{"tfg-gui_0.2.0_windows_amd64.zip", "tfg-gui_0.2.0_linux_amd64.tar.gz", "tfg-gui_0.2.0_macos_arm64.tar.gz"} { + if !sortsAfter(installer, window) { + t.Errorf("%s sorts before %s, so it lands above the window's archives", installer, window) + } + } + for _, cli := range []string{"tfg_0.2.0_windows_amd64.zip", "tfg_0.2.0_linux_arm64.tar.gz", "tfg_0.2.0_macos_arm64.tar.gz"} { + if !sortsAfter(cli, installer) { + t.Errorf("%s sorts after %s, so it lands below the command line's archives", installer, cli) + } + } + if !sortsAfter(auxPrefix(t)+"SHA256SUMS.txt", installer) { + t.Errorf("%s sorts among the files a person checks a download with", installer) + } +} + // Every place that MAKES one of those four files has to use the prefix. // // Three different files create them - the build workflow makes the bill of diff --git a/packaging/README.md b/packaging/README.md index b35e9c4a..bd5cbfb0 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -1,4 +1,4 @@ -# Package sources: WinGet and Chocolatey +# Package sources: WinGet, Chocolatey and the Windows installer These are **templates**, not packages. Every `{{PLACEHOLDER}}` is filled by `.github/scripts/build_packages.py` from the one place that owns the value: the @@ -86,6 +86,51 @@ scope and on Windows 11 in user scope: and, on Windows 11, for the command line while a tfg command was running. A portable package carries no script, so the description is where this is said. +## The Windows installer + +`msi/tfg-setup.wxs.in` is the source of `tfg-setup__windows_amd64.msi`, +a release asset of its own beside the zip archives - the feed packages above stay +on the zips. `.github/scripts/build_msi.py` fills it and builds it with WiX 5.0.2, +from the two signed amd64 archives: + + python .github/scripts/build_msi.py --tag v0.5.0 --archives --out-dir + +It is built by `sign_release.py`, after the card has signed the programs, and +signed the same way. The signing script runs `build_msi.py` from the tree of the +tag, exported with `git archive`, so the installer is built from what was tagged +whatever the checkout stands on. `ci.yml` builds an unsigned one from the latest +release in every pull request and installs it on a Windows runner. + +What it does, each line measured on Windows Server 2025 before it was written: + +- **For the machine.** `Program Files\Testing Files Generator` with both programs, + the software renderer in `opengl` and the three documents. The folder goes on + the machine's `PATH` once, at the end, and comes off at uninstall. The software + renderer stays one level down, because a library named `opengl32.dll` in a + folder on `PATH` is one other programs would load. +- **The window in the Start menu,** started in the install folder. The window + recognises its own folder and offers `tfg-out` in the home folder of whoever + opened it. The shortcut cannot say that itself - Windows Installer expands a + profile variable when it installs, in the installing account. +- **Nothing running is ended.** The Restart Manager is off. With it on, an + upgrade while `tfg` ran waited thirty seconds, failed and closed the program + anyway. With it off the file in use is set aside, the new version is in place + at once and the upgrade answers 3010, a restart to remove the old copy. It has + to be in the package from the first installer on, because an upgrade removes + the old version under the OLD package's properties. +- **One entry in Programs and Features.** A rebuild of the same version replaces + the first build, and an older version is refused with a sentence. +- **No extension, no custom action, no dialogs of WiX's own**, so nothing but our + files and Windows Installer's own tables goes into the package. + +The `UpgradeCode` in `build_msi.py` is the product's identity for good. Every +machine finds the version it has through it, so a new one would leave the old +install in place beside the new one - a guard pins it. A release candidate, a tag +with a hyphen, gets no installer: Windows Installer reads only the three numbers +of a version. `build_msi.py` refuses one, and refuses two archives that hold +different copies of one file, an archive missing a program, a name that leads +out of the folder, and a WiX of another version. + ## Submitting Submitting is a person's step and stays one. Nothing here is wired into a From 33d232358def19bb934f13ac2ffc6c21de9025bf Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Mon, 28 Sep 2026 23:34:37 +0200 Subject: [PATCH 06/11] 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 --- .github/scripts/sign_release.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/scripts/sign_release.py b/.github/scripts/sign_release.py index 26e3d886..38d42e46 100644 --- a/.github/scripts/sign_release.py +++ b/.github/scripts/sign_release.py @@ -504,6 +504,12 @@ def export_tree(tag, into): % (tag, archive.stderr.decode(errors="replace").strip())) os.makedirs(into) with tarfile.open(fileobj=io.BytesIO(archive.stdout)) as tar: + # Settled, not suppressed: the archive is git's own export of our tag, + # and the "data" filter keeps every name inside the folder. Measured + # on Python 3.14.7 on 2026-09-28 with a crafted archive: "../x" and a + # link pointing out are refused, "/x" lands inside with the slash + # dropped, and nothing appeared beside the folder in any of the three. + # nosemgrep: trailofbits.python.tarfile-extractall-traversal.tarfile-extractall-traversal tar.extractall(into, filter="data") print(" the tree of %s: %d file(s) in %s" % (tag, len(files_under(into)), into)) From 876f2aace58c60e11aab42939ac0e7e362823d80 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Mon, 28 Sep 2026 23:35:32 +0200 Subject: [PATCH 07/11] guard: the installer source is read to its end with errors.Is Co-Authored-By: Claude Opus 5.5 --- internal/guard/msi_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/guard/msi_test.go b/internal/guard/msi_test.go index 9c35dd13..1275746a 100644 --- a/internal/guard/msi_test.go +++ b/internal/guard/msi_test.go @@ -81,7 +81,7 @@ func installerElements(t *testing.T, source string) []wxsElement { open := []int{-1} for { tok, err := dec.Token() - if err == io.EOF { + if errors.Is(err, io.EOF) { break } if err != nil { From e9643eb2ae93598cc6cc2d4eb316c542834f9243 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Mon, 28 Sep 2026 23:36:50 +0200 Subject: [PATCH 08/11] 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 --- .github/scripts/sign_release.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/scripts/sign_release.py b/.github/scripts/sign_release.py index 38d42e46..a8d93b1e 100644 --- a/.github/scripts/sign_release.py +++ b/.github/scripts/sign_release.py @@ -503,13 +503,14 @@ def export_tree(tag, into): raise SystemExit("sign_release: git archive %s failed:\n%s" % (tag, archive.stderr.decode(errors="replace").strip())) os.makedirs(into) + # Settled, not suppressed: the archive is git's own export of our tag, and + # the "data" filter keeps every name inside the folder. Measured on Python + # 3.14.7 on 2026-09-28 with a crafted archive: "../x" and a link pointing + # out are refused, "/x" lands inside with the slash dropped, and nothing + # appeared beside the folder in any of the three. The rule reports the + # with line, so that is the line the comment sits above. + # nosemgrep: trailofbits.python.tarfile-extractall-traversal.tarfile-extractall-traversal with tarfile.open(fileobj=io.BytesIO(archive.stdout)) as tar: - # Settled, not suppressed: the archive is git's own export of our tag, - # and the "data" filter keeps every name inside the folder. Measured - # on Python 3.14.7 on 2026-09-28 with a crafted archive: "../x" and a - # link pointing out are refused, "/x" lands inside with the slash - # dropped, and nothing appeared beside the folder in any of the three. - # nosemgrep: trailofbits.python.tarfile-extractall-traversal.tarfile-extractall-traversal tar.extractall(into, filter="data") print(" the tree of %s: %d file(s) in %s" % (tag, len(files_under(into)), into)) From 8b41ea275612a3378e4756effafc4df5dfd2cc28 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Mon, 28 Sep 2026 23:41:12 +0200 Subject: [PATCH 09/11] 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 --- .github/scripts/build_msi.py | 23 +++++++++++++++-------- .github/scripts/sign_release.py | 26 ++++++++++++++++++++++---- internal/guard/msi_test.go | 19 ++++++++++++++++++- 3 files changed, 55 insertions(+), 13 deletions(-) diff --git a/.github/scripts/build_msi.py b/.github/scripts/build_msi.py index 684567b1..8fb9af53 100644 --- a/.github/scripts/build_msi.py +++ b/.github/scripts/build_msi.py @@ -155,6 +155,11 @@ def unpack(archives, version, into): A file both archives hold goes in once, and only when both hold the same bytes - the three documents, today. Two different copies of one file are a question about how the release was built, and the installer does not pick one. + + One file means one name to Windows, where the installer puts it: LICENSE + and License are the same file there, so the names are compared folded to + one case. Compared as written, the second would have overwritten the first + without a word (outside review of #147). """ came_from = {} for package in packages.PACKAGES: @@ -173,15 +178,17 @@ def unpack(archives, version, into): "unpacked into. That is not an archive the release built" % (name, entry.filename)) target = os.path.join(into, *entry.filename.split("/")) - if entry.filename in came_from: - with open(target, "rb") as held: + key = entry.filename.casefold() + if key in came_from: + first, held_at, held_as = came_from[key] + with open(held_at, "rb") as held: if held.read() != archive.read(entry): - refuse("%s and %s both hold %s, and not the same bytes. One " - "installer carries one copy - look at how the release " - "built the two archives" - % (came_from[entry.filename], name, entry.filename)) + refuse("%s holds %s and %s holds %s, one file on Windows, and " + "not the same bytes. One installer carries one copy - " + "look at how the release built the two archives" + % (first, held_as, name, entry.filename)) continue - came_from[entry.filename] = name + came_from[key] = (name, target, entry.filename) os.makedirs(os.path.dirname(target), exist_ok=True) with archive.open(entry) as src, open(target, "wb") as dst: shutil.copyfileobj(src, dst) @@ -189,7 +196,7 @@ def unpack(archives, version, into): refuse("%s is not a zip archive it can read (%s). Download it again" % (path, err)) for package in packages.PACKAGES: program = package.program + ".exe" - if program not in came_from: + if program.casefold() not in came_from: refuse("the archives hold no %s at the top, and the installer puts it on PATH. " "That is not the shape the release builds" % program) return came_from diff --git a/.github/scripts/sign_release.py b/.github/scripts/sign_release.py index a8d93b1e..19709d29 100644 --- a/.github/scripts/sign_release.py +++ b/.github/scripts/sign_release.py @@ -51,6 +51,7 @@ presses the button. """ import argparse +import contextlib import datetime import hashlib import io @@ -515,6 +516,23 @@ def export_tree(tag, into): print(" the tree of %s: %d file(s) in %s" % (tag, len(files_under(into)), into)) +@contextlib.contextmanager +def tagged_tree(tag, into): + """The tree of the tag for as long as it is needed, and gone afterwards. + + Gone whatever happened inside: a refusal anywhere between the check + before the card and the installer left the export behind until the next + run cleared it (outside review of #147). Exported twice rather than kept + between the two, because the steps in between are the card and the Mac, + and either can stop the run. + """ + export_tree(tag, into) + try: + yield into + finally: + shutil.rmtree(into, ignore_errors=True) + + def installer_script(tree): """build_msi.py as the tag has it, or a refusal for a tag from before it.""" script = os.path.join(tree, ".github", "scripts", "build_msi.py") @@ -709,8 +727,8 @@ def main(argv=None): if is_candidate(args.tag): print(" %s is a release candidate, so it gets no installer" % args.tag) else: - export_tree(args.tag, tree) - check_installer(args.tag, tree) + with tagged_tree(args.tag, tree): + check_installer(args.tag, tree) print("\n[1/9] fetching the build for %s" % args.tag) fetch_build(args.tag, work) @@ -733,8 +751,8 @@ def main(argv=None): if is_candidate(args.tag): print(" none for a release candidate") else: - build_installer(args.tag, tree, work, thumbprint, pin, signtool, args.dry_run) - shutil.rmtree(tree) + with tagged_tree(args.tag, tree): + build_installer(args.tag, tree, work, thumbprint, pin, signtool, args.dry_run) print("\n[7/9] checksums over what will be published") name_for_publication(work, args.tag) diff --git a/internal/guard/msi_test.go b/internal/guard/msi_test.go index 1275746a..6273feaa 100644 --- a/internal/guard/msi_test.go +++ b/internal/guard/msi_test.go @@ -347,7 +347,14 @@ func TestTheInstallerIsBuiltFromBothArchivesOrNotAtAll(t *testing.T) { "dotnet tool install --global wix --version 5.0.2"}, {"a document differs between the two archives", func(_ *testing.T, a archives, _, _ string) { a[window]["LICENSE"] = "another licence" - }, "both hold LICENSE, and not the same bytes"}, + }, "holds LICENSE, one file on Windows, and not the same bytes"}, + // One file to Windows, where the installer puts it. Compared as + // written, the second name overwrote the first on a Windows disk + // and nothing was said (outside review of #147). + {"a document differs and its name only in letter case", func(_ *testing.T, a archives, _, _ string) { + delete(a[cli], "LICENSE") + a[cli]["License"] = "another licence" + }, "holds License, one file on Windows, and not the same bytes"}, {"the command line archive is missing", func(_ *testing.T, a archives, _, _ string) { delete(a, cli) }, "holds no " + cli}, @@ -489,6 +496,14 @@ try: print("missing tag: EXPORTED") except SystemExit as refusal: print("missing tag: " + ("REFUSED" if "git fetch --tags" in str(refusal) else str(refusal))) +kept = os.path.join(base, "kept") +try: + with sr.tagged_tree("HEAD", kept): + print("inside: %s" % os.path.isfile(os.path.join(kept, "go.mod"))) + raise SystemExit("a refusal inside") +except SystemExit: + pass +print("left after a refusal: %s" % os.path.exists(kept)) ` dir := t.TempDir() file := filepath.Join(dir, "probe.py") @@ -512,6 +527,8 @@ except SystemExit as refusal: "candidate v0.5.0-rc1: True", "candidate v1.0.0-beta.2: True", "missing tag: REFUSED", + "inside: True", + "left after a refusal: False", } { if !strings.Contains(string(said), want+"\n") && !strings.Contains(string(said), want+"\r\n") { t.Errorf("the probe did not say %q:\n%s", want, said) From ece249145cbff68eb5eb3b4e2b41f0bcd0480912 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 29 Sep 2026 00:21:00 +0200 Subject: [PATCH 10/11] 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 --- .github/scripts/build_msi.py | 8 ++++++++ .github/scripts/sign_release.py | 24 +++++++++++++++++++++++- CHANGELOG.md | 8 +++++--- internal/guard/msi_test.go | 33 +++++++++++++++++++++++++++++++++ packaging/README.md | 9 +++++++-- 5 files changed, 76 insertions(+), 6 deletions(-) diff --git a/.github/scripts/build_msi.py b/.github/scripts/build_msi.py index 8fb9af53..c450f7c4 100644 --- a/.github/scripts/build_msi.py +++ b/.github/scripts/build_msi.py @@ -4,6 +4,7 @@ python .github/scripts/build_msi.py --tag v0.5.0 --archives --out-dir python .github/scripts/build_msi.py --tag v0.5.0 --check python .github/scripts/build_msi.py --tag v0.5.0 --source-only + python .github/scripts/build_msi.py --tag v0.5.0 --product-name One installer carries both programs, the window and the command line, for every account on the machine - decided by the owner on 2026-09-28, beside the zips and @@ -22,6 +23,8 @@ --check asks only whether this machine can build it (the tag, the template, WiX), so sign_release.py can refuse before the card signs anything. --source-only prints the rendered installer source and builds nothing, which is what the guards read. +--product-name prints the name the installer carries and nothing else, which is +what sign_release.py signs it with. Exit codes: 0 the installer was written to --out-dir, or --check found nothing missing @@ -254,11 +257,16 @@ def main(argv=None): help="only say whether this machine can build it") parser.add_argument("--source-only", action="store_true", help="print the installer source and build nothing") + parser.add_argument("--product-name", action="store_true", + help="print the name the installer carries and build nothing") args = parser.parse_args(argv) if args.source_only: sys.stdout.write(source(parse_version(args.tag), args.tag)) return 0 + if args.product_name: + print(values(parse_version(args.tag), args.tag)["APP_NAME"]) + return 0 if args.check: version = parse_version(args.tag) source(version, args.tag) diff --git a/.github/scripts/sign_release.py b/.github/scripts/sign_release.py index 19709d29..f74229e5 100644 --- a/.github/scripts/sign_release.py +++ b/.github/scripts/sign_release.py @@ -543,6 +543,26 @@ def installer_script(tree): return script +def product_name(tag, tree): + """The name the installer carries, as build_msi.py from the tag renders it. + + The installer's signature carries it as its description, which Windows + shows as the program's name when it asks an administrator to let the + installer run - the same name Programs and Features lists afterwards. + Without it that prompt named a string of digits, a temporary copy of the + file (measured on Windows 11 on 2026-09-29, both copies signed by the + card). Asked of the tag's own script, so the two cannot disagree. + """ + said = subprocess.run([sys.executable, installer_script(tree), "--tag", tag, "--product-name"], + capture_output=True, encoding="utf-8", + env={**os.environ, "PYTHONUTF8": "1"}) + name = said.stdout.strip() + if said.returncode != 0 or not name: + raise SystemExit("sign_release: build_msi.py from the tag named no product (exit %d):\n%s" + % (said.returncode, said.stderr.strip())) + return name + + def check_installer(tag, tree): """Refuse before the card signs anything when the installer cannot be built. @@ -550,6 +570,7 @@ def check_installer(tag, tree): installer, and the next run signs them all again. """ run([sys.executable, installer_script(tree), "--tag", tag, "--check"]) + print(" its signature will name it %r" % product_name(tag, tree)) def build_installer(tag, tree, work, thumbprint, pin, signtool, dry_run): @@ -560,6 +581,7 @@ def build_installer(tag, tree, work, thumbprint, pin, signtool, dry_run): administrator runs with the highest rights the machine has, so it is the last file to leave unsigned. """ + name = product_name(tag, tree) run([sys.executable, installer_script(tree), "--tag", tag, "--archives", work, "--out-dir", work]) installers = [n for n in sorted(os.listdir(work)) if n.endswith(".msi")] @@ -567,7 +589,7 @@ def build_installer(tag, tree, work, thumbprint, pin, signtool, dry_run): raise SystemExit("sign_release: expected one installer in %s after building it and " "found %d: %s" % (work, len(installers), ", ".join(installers) or "none")) path = os.path.join(work, installers[0]) - command = [signtool, "sign", "/sha1", thumbprint, "/fd", "sha256", + command = [signtool, "sign", "/sha1", thumbprint, "/fd", "sha256", "/d", name, "/tr", TIMESTAMP_URL, "/td", "sha256", "/v", path] if dry_run: print(" DRY RUN, would run: %s" % " ".join(command)) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e1952b1..85f52c86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,9 +20,11 @@ because it turns other people's test suites red. zip archives.** It installs the window and the command line for every account on the machine, in `Program Files\Testing Files Generator`, puts that folder on the machine's `PATH` once and the window in the Start menu, - and asks for administrator rights to do it. Upgrading while a `tfg` - command runs does not stop the command. The new version is in place at - once, and the old copy is removed at the next restart. Uninstalling + and asks for administrator rights to do it, in a prompt that names + Testing Files Generator. Upgrading while a `tfg` command runs does not + stop the command. The new version is in place at once, and the old copy + is removed at the next restart. Upgrading with the window open, Windows + names the window and lets you close it before going on. Uninstalling removes the folder entry from `PATH` and leaves any file in the folder that the installer did not put there. A release candidate gets no installer. diff --git a/internal/guard/msi_test.go b/internal/guard/msi_test.go index 6273feaa..bdb9fe39 100644 --- a/internal/guard/msi_test.go +++ b/internal/guard/msi_test.go @@ -233,6 +233,27 @@ func TestTheInstallerIsTheShapeThatWasMeasured(t *testing.T) { } } +// The name the installer is signed with is the name it carries. +// +// sign_release.py signs the installer with what --product-name prints, and +// Windows shows that as the program's name when it asks an administrator to +// let the installer run. Asked of the effect: the printed name is the Name of +// the rendered package, so a template that renames the product renames both. +func TestTheInstallerIsSignedWithTheNameItCarries(t *testing.T) { + pkg := named(renderedInstaller(t), "Package") + if len(pkg) != 1 || pkg[0].attrs["Name"] == "" { + t.Fatalf("read %d from the installer source, and this guard reads the name of one", len(pkg)) + } + r := runMSI(t, t.TempDir(), false, "--tag", packagingTag, "--product-name") + if r.code != 0 { + t.Fatalf("build_msi.py --product-name exited %d:\n%s", r.code, r.said) + } + if got := strings.TrimRight(r.said, "\r\n"); got != pkg[0].attrs["Name"] { + t.Errorf("build_msi.py --product-name says %q and the package is named %q. The signature "+ + "names the product the installer carries, and the signing script takes every word printed", got, pkg[0].attrs["Name"]) + } +} + // What a person reads from the installer follows the punctuation rule (D17): a // flat hyphen, and no semicolons. The refusal to go back to an older version // is the one sentence a person sees from it, and the shortcut's description @@ -445,11 +466,17 @@ func TestTheSigningSignsTheInstallerAndExpectsItOnTheDraft(t *testing.T) { "it reads the installer's certificate back out of the file": `signer = certificate_of(path)`, "it holds that certificate to the pin": `if signer != pin:`, "it runs build_msi.py as the tag has it": `installer_script(tree)`, + "it asks the tag for the name the installer carries": `name = product_name(tag, tree)`, + "it signs the installer with that name": `"/d", name,`, } { if !strings.Contains(build, want) { t.Errorf("%s: build_installer does not contain %q", what, want) } } + if !strings.Contains(pythonDef(t, script, "check_installer"), "product_name(tag, tree)") { + t.Error("check_installer does not ask for the name the installer is signed with, so a tag " + + "that cannot give one is found only after the card has signed the programs") + } main := pythonDef(t, script, "main") check, card := strings.Index(main, "check_installer(args.tag, tree)"), strings.Index(main, "signing_thumbprint(pin)") @@ -489,6 +516,7 @@ sr.export_tree("HEAD", tree) count = sum(len(files) for _, _, files in os.walk(tree)) print("files: %d" % count) print("script: " + os.path.relpath(sr.installer_script(tree), base).replace(os.sep, "/")) +print("name: " + sr.product_name("v0.5.0", tree)) for tag in ("v0.5.0", "v0.5.0-rc1", "v1.0.0-beta.2"): print("candidate %s: %s" % (tag, sr.is_candidate(tag))) try: @@ -520,9 +548,14 @@ print("left after a refusal: %s" % os.path.exists(kept)) t.Fatalf("the probe failed: %v\n%s", err, said) } committed := len(strings.Fields(gitOutput(t, "ls-tree", "-r", "--name-only", "HEAD"))) + pkg := named(renderedInstaller(t), "Package") + if len(pkg) != 1 { + t.Fatalf("read %d from the installer source, and this guard reads the name of one", len(pkg)) + } for _, want := range []string{ "files: " + strconv.Itoa(committed), "script: tree/.github/scripts/build_msi.py", + "name: " + pkg[0].attrs["Name"], "candidate v0.5.0: False", "candidate v0.5.0-rc1: True", "candidate v1.0.0-beta.2: True", diff --git a/packaging/README.md b/packaging/README.md index bd5cbfb0..7989efe5 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -96,7 +96,9 @@ from the two signed amd64 archives: python .github/scripts/build_msi.py --tag v0.5.0 --archives --out-dir It is built by `sign_release.py`, after the card has signed the programs, and -signed the same way. The signing script runs `build_msi.py` from the tree of the +signed the same way, with the product's name as the signature's description. +Windows shows that name when it asks an administrator to let the installer run, +and a string of digits without it. The signing script runs `build_msi.py` from the tree of the tag, exported with `git archive`, so the installer is built from what was tagged whatever the checkout stands on. `ci.yml` builds an unsigned one from the latest release in every pull request and installs it on a Windows runner. @@ -117,7 +119,10 @@ What it does, each line measured on Windows Server 2025 before it was written: anyway. With it off the file in use is set aside, the new version is in place at once and the upgrade answers 3010, a restart to remove the old copy. It has to be in the package from the first installer on, because an upgrade removes - the old version under the OLD package's properties. + the old version under the OLD package's properties. Started with a double + click, Windows Installer asks first. It names the open window and offers + Cancel, Retry and Ignore. With the window closed before going on, the upgrade + needs no restart. - **One entry in Programs and Features.** A rebuild of the same version replaces the first build, and an older version is refused with a sentence. - **No extension, no custom action, no dialogs of WiX's own**, so nothing but our From ec8b64649e658ccfef05553009bbd35b1f3b5dd6 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 29 Sep 2026 00:22:28 +0200 Subject: [PATCH 11/11] 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 --- internal/guard/msi_test.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/guard/msi_test.go b/internal/guard/msi_test.go index bdb9fe39..5800ef12 100644 --- a/internal/guard/msi_test.go +++ b/internal/guard/msi_test.go @@ -516,7 +516,11 @@ sr.export_tree("HEAD", tree) count = sum(len(files) for _, _, files in os.walk(tree)) print("files: %d" % count) print("script: " + os.path.relpath(sr.installer_script(tree), base).replace(os.sep, "/")) -print("name: " + sr.product_name("v0.5.0", tree)) +print("name: [" + sr.product_name("v0.5.0", tree) + "]") +try: + print("candidate name: GIVEN [" + sr.product_name("v0.5.0-rc1", tree) + "]") +except SystemExit as refusal: + print("candidate name: " + ("REFUSED" if "named no product" in str(refusal) else str(refusal))) for tag in ("v0.5.0", "v0.5.0-rc1", "v1.0.0-beta.2"): print("candidate %s: %s" % (tag, sr.is_candidate(tag))) try: @@ -555,7 +559,8 @@ print("left after a refusal: %s" % os.path.exists(kept)) for _, want := range []string{ "files: " + strconv.Itoa(committed), "script: tree/.github/scripts/build_msi.py", - "name: " + pkg[0].attrs["Name"], + "name: [" + pkg[0].attrs["Name"] + "]", + "candidate name: REFUSED", "candidate v0.5.0: False", "candidate v0.5.0-rc1: True", "candidate v1.0.0-beta.2: True",