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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +