diff --git a/.github/scripts/build_msi.py b/.github/scripts/build_msi.py
new file mode 100644
index 00000000..c450f7c4
--- /dev/null
+++ b/.github/scripts/build_msi.py
@@ -0,0 +1,284 @@
+#!/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
+ 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
+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.
+--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
+ 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.
+
+ 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:
+ 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("/"))
+ 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 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[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)
+ 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.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
+
+
+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")
+ 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)
+ 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/.github/scripts/sign_release.py b/.github/scripts/sign_release.py
index 2e4e26d7..f74229e5 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.
@@ -46,14 +51,17 @@
presses the button.
"""
import argparse
+import contextlib
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 +469,142 @@ 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)
+ # 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:
+ tar.extractall(into, filter="data")
+ 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")
+ 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 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.
+
+ 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"])
+ print(" its signature will name it %r" % product_name(tag, tree))
+
+
+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.
+ """
+ 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")]
+ 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", "/d", name,
+ "/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 +689,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 +741,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:
+ 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)
- 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:
+ 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)
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/.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/.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/CHANGELOG.md b/CHANGELOG.md
index 18c0c182..85f52c86 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,21 @@ 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, 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.
+
### 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/internal/guard/msi_test.go b/internal/guard/msi_test.go
new file mode 100644
index 00000000..5800ef12
--- /dev/null
+++ b/internal/guard/msi_test.go
@@ -0,0 +1,686 @@
+package guard
+
+import (
+ "archive/zip"
+ "encoding/xml"
+ "errors"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strconv"
+ "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 errors.Is(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")
+ }
+}
+
+// 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
+// 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"
+ }, "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},
+ {"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")
+ }
+ })
+ }
+}
+
+// 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)`,
+ "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)")
+ 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, "/"))
+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:
+ 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)))
+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")
+ 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")))
+ 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 name: REFUSED",
+ "candidate v0.5.0: False",
+ "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)
+ }
+ }
+}
+
+// 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")
+ }
+ 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
+// 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)
+ }
+ }
+}
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/README.md b/packaging/README.md
index b35e9c4a..7989efe5 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,56 @@ 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, 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.
+
+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. 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
+ 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
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+