diff --git a/.github/scripts/build_packages.py b/.github/scripts/build_packages.py index 3a78a28a..84751ae5 100644 --- a/.github/scripts/build_packages.py +++ b/.github/scripts/build_packages.py @@ -14,9 +14,13 @@ originals by a guard. Usage: - python .github/scripts/build_packages.py --tag v0.4.0 \\ + python .github/scripts/build_packages.py --tag vX.Y.Z \\ --sums verify-SHA256SUMS.txt --out +The window's WinGet package installs the Windows installer first, so a +checksum file without the installer's line is refused. A release published +without one can still be packaged for Chocolatey, with --only chocolatey. + Exit codes: 0 every package was rendered into --out 1 refused, and the message says which input and why @@ -118,15 +122,27 @@ def how_to_start(package, feed): "upgrade runs after tfg has finished.") return text if feed == "winget": - return ("This package is the desktop window. The command line is the package %s. " - "WinGet adds no Start menu shortcut for it. Open a new terminal and type " - "tfg-gui. The window offers a tfg-out folder in the directory it was started " - "from. Close the window before you upgrade. WinGet cannot replace a running " - "program, so it stops half way, and the package works again once the " - "upgrade runs with the window closed." % other_id) + # Two installers, so two shapes to describe. The archive is what + # --scope user gets, and what an install made before the package had + # the installer keeps: WinGet upgrades only within the kind installed. + return ("This package is the desktop window. WinGet installs it with the Windows " + "installer, for every account on the machine, and asks for administrator " + "rights. That puts it in the Start menu and puts both tfg-gui and the command " + "line tfg on PATH, so the package %s is not needed beside it. Started from " + "the Start menu, the window offers a tfg-out folder in your user profile. " + "With --scope user it needs no administrator rights, but WinGet installs the " + "archive instead, which has no Start menu shortcut. Open a new terminal and " + "type tfg-gui, and the window offers a tfg-out folder in the directory it was " + "started from. An install made before this package had the installer stays " + "without a shortcut when it is upgraded. Uninstall it and install it again to " + "get one. Close the window before you upgrade. Without the installer WinGet " + "cannot replace a running program, so it stops half way, and the package " + "works again once the upgrade runs with the window closed." % other_id) return ("This package is the desktop window. The command line is the package %s. It " "adds a Start menu shortcut and the tfg-gui command. Started from the shortcut, " - "the window offers a tfg-out folder in your user profile." % other_id) + "the window offers a tfg-out folder in your user profile. With the Windows " + "installer of the program installed as well, the Start menu keeps the " + "installer's shortcut." % other_id) def refuse(message): @@ -225,21 +241,46 @@ def archives(package, version): return {arch: package.archive.format(version=version, arch=arch) for arch in package.arches} -def checked_archives(sums, version, path): - """Every archive a package needs, with its checksum, or a refusal naming what is missing.""" +def installer(): + """The Windows installer's file name, with {version} in it, and its UpgradeCode. + + build_msi.py owns both, and a guard pins the UpgradeCode for good. It + imports this script, so it is imported here, when asked, and not at the top. + """ + import build_msi # the sibling script, found beside this one + return build_msi.NAME, build_msi.UPGRADE_CODE + + +def another_release(sums, pattern): + """The hint for a missing line: a name that fits the pattern with another version.""" + other = sorted(n for n in sums if re.fullmatch( + re.escape(pattern).replace(r"\{version\}", r"[^_]+"), n)) + return (" It lists %s - that is the checksum file of another release." % other[0] + if other else "") + + +def checked_archives(sums, version, path, feeds): + """Every file the packages download, with its checksum, or a refusal naming what is missing.""" found = {} for package in PACKAGES: for arch, name in archives(package, version).items(): if name in sums: found[name] = sums[name] continue - other = sorted(n for n in sums if re.fullmatch( - re.escape(package.archive).replace(r"\{version\}", r"[^_]+") - .replace(r"\{arch\}", re.escape(arch)), n)) - hint = (" It lists %s - that is the checksum file of another release." % other[0] - if other else "") refuse("%s has no line for %s.%s Download the checksum file of the release " - "you are packaging." % (path, name, hint)) + "you are packaging." % (path, name, another_release( + sums, package.archive.replace("{arch}", arch)))) + if "winget" in feeds: + pattern, _ = installer() + name = pattern.format(version=version) + if name not in sums: + hint = another_release(sums, pattern) + refuse("%s has no line for %s, the Windows installer, which the window's WinGet " + "package installs first.%s %s" % (path, name, hint, ( + "Download the checksum file of the release you are packaging." if hint + else "A release published without an installer can be packaged for " + "Chocolatey only - pass --only chocolatey."))) + found[name] = sums[name] return found @@ -253,7 +294,18 @@ def values(package, version, tag, sums): installers += ["- Architecture: %s" % WINGET_ARCH[arch], " InstallerUrl: %s/releases/download/%s/%s" % (repo_url, tag, name), " InstallerSha256: %s" % sums[name].upper()] - return { + # Only when the release has an installer - checked_archives asks for its + # line whenever WinGet is rendered, so a WinGet template never goes without. + pattern, upgrade_code = installer() + msi = pattern.format(version=version) + with_installer = {} if msi not in sums else { + "MSI_URL": "%s/releases/download/%s/%s" % (repo_url, tag, msi), + "MSI_SHA256": sums[msi].upper(), + # In braces, as Windows Installer spells a code and as merged + # manifests in winget-pkgs carry it (7zip.7zip, read 2026-10-06). + "UPGRADE_CODE": "{%s}" % upgrade_code, + } + return dict(with_installer, **{ "VERSION": version, "WINGET_SCHEMA": WINGET_SCHEMA, "WINGET_ID": package.winget_id, @@ -278,6 +330,7 @@ def values(package, version, tag, sums): "RELEASE_DATE": release_date(version), "URL_AMD64": "%s/releases/download/%s/%s" % (repo_url, tag, names["amd64"]), "SHA256_AMD64": sums[names["amd64"]], + "WINGET_SHA256_AMD64": sums[names["amd64"]].upper(), "SHORT_DESCRIPTION": SHORT[package.kind], "DESCRIPTION": DESCRIPTION, "WINGET_HOW_TO_START": how_to_start(package, "winget"), @@ -285,7 +338,7 @@ def values(package, version, tag, sums): "WINGET_TAGS": "\n".join("- " + t for t in TAGS + (KIND_TAG[package.kind],)), "CHOCO_TAGS": " ".join(TAGS + (KIND_TAG[package.kind],)), "WINGET_INSTALLERS": "\n".join(installers), - } + }) def render(text, table, name): @@ -351,14 +404,17 @@ def check_script(text, name): refuse("%s line %d is not ASCII: %r" % (name, number, line)) -def templates(kind): +FEEDS = ("winget", "chocolatey") + + +def templates(kind, feeds=FEEDS): """(template path, output path relative to the package) for one kind of package. A template named name..ext.in belongs to that kind only, and renders to name.ext. One named name.ext.in belongs to every package. """ kinds = {p.kind for p in PACKAGES} - for feed in ("winget", "chocolatey"): + for feed in feeds: for base, _, files in os.walk(os.path.join(TEMPLATES, feed)): for file in sorted(files): if not file.endswith(TEMPLATE_SUFFIX): @@ -428,11 +484,11 @@ def remove_empty(folders): return -def build(tag, sums_path, out): - """Render every package into out, all or nothing.""" +def build(tag, sums_path, out, feeds=FEEDS): + """Render every package of the given feeds into out, all or nothing.""" version = parse_tag(tag) out = check_out(out) - sums = checked_archives(read_sums(sums_path), version, sums_path) + sums = checked_archives(read_sums(sums_path), version, sums_path, feeds) if not os.path.isfile(os.path.join(ROOT, ICON)): refuse("the icon %s is not in the repository" % ICON) @@ -447,14 +503,16 @@ def build(tag, sums_path, out): "or pass another --out" % (parent, err.strerror or err)) finished = False try: - used = set() + # What every template uses, read from all of them whichever feeds are + # rendered, so a run for one feed does not call the other's values unused. + used = {key for package in PACKAGES for source, _ in templates(package.kind) + for key in PLACEHOLDER.findall(read_text(source))} known = set() for package in PACKAGES: table = values(package, version, tag, sums) known |= set(table) - for source, relative in templates(package.kind): + for source, relative in templates(package.kind, feeds): text = read_text(source) - used |= set(PLACEHOLDER.findall(text)) rendered = render(text, table, relative) if relative.endswith(".ps1"): check_script(rendered, relative) @@ -487,8 +545,10 @@ def main(argv=None): parser.add_argument("--tag", required=True, help="the published release, for example v0.4.0") parser.add_argument("--sums", required=True, help="that release's verify-SHA256SUMS.txt") parser.add_argument("--out", required=True, help="an empty or new directory outside the repository") + parser.add_argument("--only", choices=FEEDS, + help="render the packages of one feed - Chocolatey needs no installer") args = parser.parse_args(argv) - out = build(args.tag, args.sums, args.out) + out = build(args.tag, args.sums, args.out, (args.only,) if args.only else FEEDS) for base, _, files in sorted(os.walk(out)): for file in sorted(files): print(os.path.join(base, file)) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c4f102b..1650e363 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -406,12 +406,16 @@ jobs: # shim does not wait, the software renderer lies beside the window, the # Start menu shortcut starts in the user's profile. Then it removes them and # asks again, including whether a shortcut of somebody else's under the same - # name was left alone. + # name was left alone - by the uninstall, and by an install that found it + # there, which is what the program's Windows installer leaves. # # Against the LATEST release, because a package can only point at one that # exists. A change that renames the archives in release.yml will fail here # until a release with the new names is published, and that is the right - # answer: the packages cannot be submitted before it either. + # answer: the packages cannot be submitted before it either. Chocolatey + # only (--only chocolatey): the window's WinGet package needs the release's + # Windows installer, which releases before it was added do not have, and + # WinGet is not installed here anyway. # # WinGet is not asked here. The runner image does not carry it - its own # inventory lists Chocolatey 2.7.4 and no WinGet (Windows2025-Readme.md, @@ -441,7 +445,7 @@ jobs: if ($LASTEXITCODE -ne 0) { throw "could not download the checksums of $tag" } python .github/scripts/build_packages.py --tag $tag ` --sums (Join-Path $env:RUNNER_TEMP 'verify-SHA256SUMS.txt') ` - --out (Join-Path $env:RUNNER_TEMP 'packages') + --out (Join-Path $env:RUNNER_TEMP 'packages') --only chocolatey if ($LASTEXITCODE -ne 0) { throw "the renderer refused $tag" } "version=$($tag.TrimStart('v'))" >> $env:GITHUB_OUTPUT @@ -537,6 +541,44 @@ jobs: if ($LASTEXITCODE -ne 0) { throw "the third uninstall exited $LASTEXITCODE" } Check (-not (Test-Path $shortcut)) "removing the window takes its own shortcut" + # A shortcut of another install already there when the package comes + # - the program's Windows installer makes one under this very name - + # is left alone by the install, and so by the uninstall. So is one to + # a shell item, which points at no file. One whose target is gone + # belongs to nobody, and the install replaces it. + $notepad = Join-Path $env:WINDIR 'notepad.exe' + $other = $shell.CreateShortcut($shortcut) + $other.TargetPath = $notepad + $other.Save() + $said = (choco.exe install testing-files-generator --source $feed --yes --no-progress) -join "`n" + if ($LASTEXITCODE -ne 0) { throw "the fourth install exited $LASTEXITCODE" } + Check ($shell.CreateShortcut($shortcut).TargetPath -eq $notepad) "the install leaves a shortcut of another install alone" + Check ($said -match 'It points at .*notepad\.exe, not at this package') "and the install says where it points" + choco.exe uninstall testing-files-generator --yes --no-progress + if ($LASTEXITCODE -ne 0) { throw "the fourth uninstall exited $LASTEXITCODE" } + Check ($shell.CreateShortcut($shortcut).TargetPath -eq $notepad) "and so does the uninstall" + Remove-Item -LiteralPath $shortcut -Force + $other = $shell.CreateShortcut($shortcut) + $other.TargetPath = '::{20D04FE0-3AEA-1069-A2D8-08002B30309D}' + $other.Save() + $said = (choco.exe install testing-files-generator --source $feed --yes --no-progress) -join "`n" + if ($LASTEXITCODE -ne 0) { throw "the shell item install exited $LASTEXITCODE" } + Check ((Test-Path $shortcut) -and $shell.CreateShortcut($shortcut).TargetPath -eq '') "the install leaves a shortcut to a shell item alone" + Check ($said -match 'It points at no file, so it is not from this package') "and the install says so in a whole sentence" + choco.exe uninstall testing-files-generator --yes --no-progress + if ($LASTEXITCODE -ne 0) { throw "the shell item uninstall exited $LASTEXITCODE" } + Check ((Test-Path $shortcut) -and $shell.CreateShortcut($shortcut).TargetPath -eq '') "and so does the uninstall" + Remove-Item -LiteralPath $shortcut -Force + $other = $shell.CreateShortcut($shortcut) + $other.TargetPath = Join-Path $env:RUNNER_TEMP 'gone\tfg-gui.exe' + $other.Save() + choco.exe install testing-files-generator --source $feed --yes --no-progress + if ($LASTEXITCODE -ne 0) { throw "the fifth install exited $LASTEXITCODE" } + Check ($shell.CreateShortcut($shortcut).TargetPath -eq $window) "the install replaces a shortcut whose target is gone" + choco.exe uninstall testing-files-generator --yes --no-progress + if ($LASTEXITCODE -ne 0) { throw "the fifth uninstall exited $LASTEXITCODE" } + Check (-not (Test-Path $shortcut)) "and the uninstall takes the shortcut it made" + if ($failed -ne 0) { throw "$failed check(s) failed - read the FAILED lines above" } "every check passed" diff --git a/internal/guard/packaging_test.go b/internal/guard/packaging_test.go index f3dc6267..ec76ee5b 100644 --- a/internal/guard/packaging_test.go +++ b/internal/guard/packaging_test.go @@ -2,6 +2,7 @@ package guard import ( "errors" + "fmt" "io/fs" "os" "os/exec" @@ -10,6 +11,8 @@ import ( "sort" "strings" "testing" + + "github.com/goccy/go-yaml" ) // The WinGet and Chocolatey packages, and the renderer that fills them. @@ -24,7 +27,9 @@ import ( // (docs/PACKAGING-2026-09-25.md section 8). // // Every guard renders a real release: v0.4.0, with the checksums of its real -// archives, so the guards read the same shape a person packaging it reads. +// archives, so the guards read the same shape a person packaging it reads - +// with one line v0.4.0 never had, the Windows installer's, which the window's +// WinGet package needs and every release from the next one on carries. const packagingTag = "v0.4.0" @@ -34,8 +39,14 @@ var packagingSums = []string{ "3f74f66e181bdef20785bccd603c3e5c5ad04cdff3b38338487a02b1682eccc6 tfg_0.4.0_windows_arm64.zip", // A line no package needs, because the real file has eight of them. "c7a63f918db43cf2841a89359d3ef272c861a89a408018f6453dea7f7aaebb96 tfg_0.4.0_linux_amd64.tar.gz", + // Made up: v0.4.0 was published before the installer existed. Replace the + // fixture with the first release that has one. + "0d1c2b3a4f5e6d7c8b9a0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a tfg-setup_0.4.0_windows_amd64.msi", } +// The checksum of the installer in the fixture, as WinGet writes it. +const packagingInstallerSha = "0D1C2B3A4F5E6D7C8B9A0F1E2D3C4B5A69788796A5B4C3D2E1F00F1E2D3C4B5A" + // Where each package lands, in the layout the renderer writes. WinGet's // follows winget-pkgs, so the three files can be copied across as they are. const ( @@ -72,23 +83,24 @@ func fixtureSums() []byte { return []byte(strings.Join(packagingSums, "\n") + "\n") } -// renderPackages runs the renderer the way a person does. -func renderPackages(t *testing.T, tag string, sums []byte, out string) rendering { +// renderPackages runs the renderer the way a person does. extra is what a +// person may add after the three arguments every run needs. +func renderPackages(t *testing.T, tag string, sums []byte, out string, extra ...string) rendering { t.Helper() - return renderFrom(t, tag, sumsFile(t, sums), out) + return renderFrom(t, tag, sumsFile(t, sums), out, extra...) } // renderFrom is renderPackages with the checksum file named rather than // written, so a guard can hand it a path to a file that is not there. -func renderFrom(t *testing.T, tag, sumsPath, out string) rendering { +func renderFrom(t *testing.T, tag, sumsPath, out string, extra ...string) rendering { t.Helper() python := pythonForGate(t) // 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 or a file it // just wrote - nothing here comes from anything a person typed. // nosemgrep: go.lang.security.audit.dangerous-exec-command.dangerous-exec-command - cmd := exec.Command(python, packagingScript(t), - "--tag", tag, "--sums", sumsPath, "--out", out) + cmd := exec.Command(python, append([]string{packagingScript(t), + "--tag", tag, "--sums", sumsPath, "--out", out}, extra...)...) cmd.Dir = repoRoot(t) said, err := cmd.CombinedOutput() code := 0 @@ -105,7 +117,14 @@ func renderFrom(t *testing.T, tag, sumsPath, out string) rendering { // wrote, by its path under the output directory. func renderedPackages(t *testing.T) map[string]string { t.Helper() - r := renderPackages(t, packagingTag, fixtureSums(), filepath.Join(t.TempDir(), "packages")) + return renderedFrom(t, fixtureSums()) +} + +// renderedFrom is renderedPackages for a checksum file of the guard's own, +// with what a person may add after the three arguments every run needs. +func renderedFrom(t *testing.T, sums []byte, extra ...string) map[string]string { + t.Helper() + r := renderPackages(t, packagingTag, sums, filepath.Join(t.TempDir(), "packages"), extra...) if r.code != 0 { t.Fatalf("the renderer refused a real release (exit %d):\n%s", r.code, r.said) } @@ -215,7 +234,55 @@ func TestNoPackageSourceCarriesAVersionNumber(t *testing.T) { } } -// Both WinGet packages keep the program beside the files it came with. +// wingetInstallers reads a rendered WinGet installer manifest and returns its +// installers in order, each with the fields WinGet gives it: a field at the +// top of the manifest holds for every installer that does not set it itself. +// The command line's manifest keeps its fields at the top, the window's in +// each installer, because its two installers are of two kinds - so a guard +// asking one installer what it is has to ask both places. +func wingetInstallers(t *testing.T, name, text string) []map[string]any { + t.Helper() + var manifest map[string]any + if err := yaml.Unmarshal([]byte(text), &manifest); err != nil { + t.Fatalf("%s is not YAML: %v", name, err) + } + entries, _ := manifest["Installers"].([]any) + if len(entries) == 0 { + t.Fatalf("%s lists no installers, so there is nothing to ask", name) + } + var installers []map[string]any + for i, e := range entries { + entry, ok := e.(map[string]any) + if !ok { + t.Fatalf("%s installer %d is not a mapping: %T", name, i+1, e) + } + merged := map[string]any{} + for key, value := range manifest { + if key != "Installers" { + merged[key] = value + } + } + for key, value := range entry { + merged[key] = value + } + installers = append(installers, merged) + } + return installers +} + +// firstOf returns one field of the first mapping in a list field - the file a +// nested archive installs, the entry Windows lists the installer under. +func firstOf(installer map[string]any, list, field string) any { + items, _ := installer[list].([]any) + if len(items) == 0 { + return nil + } + item, _ := items[0].(map[string]any) + return item[field] +} + +// Both WinGet packages keep the program beside the files it came with, in +// every installer that unpacks the archive. // // Without ArchiveBinariesDependOnPath WinGet reaches the program through a // symbolic link, and the window started that way looks for its software @@ -223,52 +290,119 @@ func TestNoPackageSourceCarriesAVersionNumber(t *testing.T) { // goes on PATH. An alias would ask for the very link this avoids. func TestEveryWingetPackageKeepsItsProgramBesideItsFiles(t *testing.T) { files := renderedPackages(t) - for manifest, program := range map[string]string{ - windowWinget + ".installer.yaml": "tfg-gui.exe", - cliWinget + ".installer.yaml": "tfg.exe", + for manifest, want := range map[string]struct { + program string + archives int + }{ + windowWinget + ".installer.yaml": {"tfg-gui.exe", 1}, + cliWinget + ".installer.yaml": {"tfg.exe", 2}, } { - code := scriptLines(files[manifest]) - if !anyLine(code, `^ArchiveBinariesDependOnPath: true$`) { - t.Errorf("%s does not set ArchiveBinariesDependOnPath, so WinGet reaches the "+ - "program through a link and the window loses its renderer", manifest) + archives := 0 + for i, in := range wingetInstallers(t, manifest, files[manifest]) { + if in["InstallerType"] != "zip" { + continue + } + archives++ + if in["NestedInstallerType"] != "portable" { + t.Errorf("%s installer %d unpacks the archive as %v, not as a portable program", + manifest, i+1, in["NestedInstallerType"]) + } + if in["ArchiveBinariesDependOnPath"] != true { + t.Errorf("%s installer %d does not set ArchiveBinariesDependOnPath, so WinGet "+ + "reaches the program through a link and the window loses its renderer", manifest, i+1) + } + if got := firstOf(in, "NestedInstallerFiles", "RelativeFilePath"); got != want.program { + t.Errorf("%s installer %d installs %v, and the archive holds %s", manifest, i+1, got, want.program) + } } - if !anyLine(code, `^- RelativeFilePath: `+regexp.QuoteMeta(program)+`$`) { - t.Errorf("%s does not install %s, the program the archive holds", manifest, program) + // Asked first, so the answers above are about installers that were + // there to ask and not about none. + if archives != want.archives { + t.Errorf("%s has %d installer(s) that unpack the archive, and it should have %d - "+ + "the lines above asked fewer than they should have", manifest, archives, want.archives) } - if anyLine(code, `PortableCommandAlias`) { + if anyLine(scriptLines(files[manifest]), `PortableCommandAlias`) { t.Errorf("%s names an alias, which is the link this package exists to avoid", manifest) } } } // The command line ships for both Windows architectures and the window for -// one, each with the checksum the release published for that archive. +// one - from the installer first and from the archive second - each with the +// checksum the release published for that file. func TestEachWingetPackageOffersTheArchitecturesTheReleaseBuilds(t *testing.T) { files := renderedPackages(t) for manifest, want := range map[string][]string{ - windowWinget + ".installer.yaml": {"x64 E744F4FF793407218FAC9EEC139DD49C03264AB636DB2057BA2963D0515625AD"}, + windowWinget + ".installer.yaml": { + "x64 wix " + packagingInstallerSha, + "x64 zip E744F4FF793407218FAC9EEC139DD49C03264AB636DB2057BA2963D0515625AD", + }, cliWinget + ".installer.yaml": { - "x64 16FE56C7B3F2A13D22385A6B428ED6F0C9FB98C2103076B8C7C876401EEAAF79", - "arm64 3F74F66E181BDEF20785BCCD603C3E5C5AD04CDFF3B38338487A02B1682ECCC6", + "x64 zip 16FE56C7B3F2A13D22385A6B428ED6F0C9FB98C2103076B8C7C876401EEAAF79", + "arm64 zip 3F74F66E181BDEF20785BCCD603C3E5C5AD04CDFF3B38338487A02B1682ECCC6", }, } { - installer := regexp.MustCompile(`(?m)^- Architecture: (\S+)\n InstallerUrl: \S+\n InstallerSha256: (\S+)$`) var got []string - for _, m := range installer.FindAllStringSubmatch(files[manifest], -1) { - got = append(got, m[1]+" "+m[2]) + for _, in := range wingetInstallers(t, manifest, files[manifest]) { + got = append(got, fmt.Sprint(in["Architecture"], " ", in["InstallerType"], " ", in["InstallerSha256"])) } if strings.Join(got, "\n") != strings.Join(want, "\n") { - t.Errorf("%s offers %v, and the release built %v", manifest, got, want) + t.Errorf("%s offers\n%s\nand the release built\n%s", manifest, + strings.Join(got, "\n"), strings.Join(want, "\n")) + } + } +} + +// The window comes with a Start menu shortcut wherever WinGet can give it one. +// +// A portable package cannot have a shortcut - the manifest has no field for +// one - so the window installs from the Windows installer, which makes it. +// The installer is listed first, because that decides a fresh install on +// clients old and new. The archive stays, without a scope, for --scope user +// and for an install made before the package had the installer: WinGet +// upgrades only within the kind that is installed. And WinGet knows the +// installed program by the UpgradeCode, the one that never changes. +func TestTheWindowInstallsWithAStartMenuShortcutWhereWinGetCan(t *testing.T) { + manifest := windowWinget + ".installer.yaml" + installers := wingetInstallers(t, manifest, renderedPackages(t)[manifest]) + if len(installers) != 2 { + t.Fatalf("%s lists %d installer(s), and the window has two - the installer and the archive", + manifest, len(installers)) + } + msi, archive := installers[0], installers[1] + for field, want := range map[string]any{ + "InstallerType": "wix", + "Scope": "machine", + "ElevationRequirement": "elevatesSelf", + } { + if msi[field] != want { + t.Errorf("the first installer of %s has %s %v, and the installer is %v", manifest, field, msi[field], want) } } + if url, _ := msi["InstallerUrl"].(string); !strings.HasSuffix(url, "/v0.4.0/tfg-setup_0.4.0_windows_amd64.msi") { + t.Errorf("the first installer of %s downloads %q, not the release's installer", manifest, url) + } + if got := firstOf(msi, "AppsAndFeaturesEntries", "UpgradeCode"); got != "{"+installerUpgradeCode+"}" { + t.Errorf("the installer in %s is known by %v, and its UpgradeCode is {%s} - WinGet would not "+ + "recognise the program it installed", manifest, got, installerUpgradeCode) + } + if archive["InstallerType"] != "zip" { + t.Errorf("the second installer of %s is %v, not the archive", manifest, archive["InstallerType"]) + } + if scope, ok := archive["Scope"]; ok { + t.Errorf("the archive in %s is limited to the %v scope, so --scope user would find no installer", + manifest, scope) + } } -// Every archive a package downloads is one the release workflow builds. +// Every archive a package downloads is one the release workflow builds, and +// the installer is the one the signing step adds beside them. // // The renderer spells the archive names, and so does release.yml. This reads // the name pattern out of the workflow and holds every address the packages // download from to it, so renaming the archives in one place and not the -// other reddens here instead of in a feed's moderation queue. +// other reddens here instead of in a feed's moderation queue. The installer's +// name is public for good, so it is spelled here. func TestThePackagesDownloadWhatTheReleaseBuilds(t *testing.T) { release := withoutYamlComments(workflowText(t, "release.yml")) bases := regexp.MustCompile(`base="(tfg(?:-gui)?)_\$\{version\}_\$\{label\}_\$\{arch\}"`). @@ -277,7 +411,8 @@ func TestThePackagesDownloadWhatTheReleaseBuilds(t *testing.T) { t.Fatalf("release.yml names its archives %d way(s) this guard can read, and it reads "+ "exactly two - the command line and the window. Read the base= lines again", len(bases)) } - built := regexp.MustCompile(`^(` + bases[0][1] + `|` + bases[1][1] + `)_0\.4\.0_windows_(amd64|arm64)\.zip$`) + built := regexp.MustCompile(`^((` + bases[0][1] + `|` + bases[1][1] + `)_0\.4\.0_windows_(amd64|arm64)\.zip` + + `|tfg-setup_0\.4\.0_windows_amd64\.msi)$`) address := regexp.MustCompile(`https://github\.com/[^/\s']+/[^/\s']+/releases/download/v0\.4\.0/([^\s']+)`) seen := 0 for name, text := range renderedPackages(t) { @@ -288,8 +423,8 @@ func TestThePackagesDownloadWhatTheReleaseBuilds(t *testing.T) { } } } - if seen < 5 { - t.Errorf("found %d download address(es) in the packages, and they hold five - this "+ + if seen < 6 { + t.Errorf("found %d download address(es) in the packages, and they hold six - this "+ "guard is not reading what it thinks it reads", seen) } } @@ -343,8 +478,12 @@ func TestTheChocolateyIconIsAPinnedCdnAddress(t *testing.T) { // The shim waits for the program unless a .gui file lies beside it. The // shortcut's working directory is the directory the window offers its tfg-out // folder under, and the package's own folder is one an ordinary account cannot -// write to - owner's decision of 2026-09-25, the user's profile. And the -// uninstall removes only a shortcut that points into the package. +// write to - owner's decision of 2026-09-25, the user's profile. The install +// leaves alone a shortcut under the same name that starts something outside +// the package - the program's Windows installer makes exactly that one - or +// that points at no file, and the uninstall removes only a shortcut that +// points into the package. These +// are the lines. The packages job in ci.yml asks a machine what they do. func TestTheWindowPackageStartsWhereAPersonCanWrite(t *testing.T) { files := renderedPackages(t) install := scriptLines(files[windowChoco+"tools/chocolateyinstall.ps1"]) @@ -360,6 +499,16 @@ func TestTheWindowPackageStartsWhereAPersonCanWrite(t *testing.T) { t.Error("the Start menu shortcut does not start in the user's profile, so the window " + "offers to write into a folder the person cannot write to") } + if !anyLine(install, `^ \} elseif \(-not \$target\.StartsWith\(\$toolsDir \+ '\\', \[StringComparison\]::OrdinalIgnoreCase\) -and \(Test-Path -LiteralPath \$target\)\) \{$`) || + !anyLine(install, `^if \(\$keep\) \{$`) || !anyLine(install, `^ Install-ChocolateyShortcut `+"`"+`$`) { + t.Error("the install makes its shortcut without asking whether the one already there " + + "starts another install, so it takes over the shortcut of the Windows installer - " + + "and its uninstall then deletes it") + } + if !anyLine(install, `^ if \(-not \$target\) \{$`) { + t.Error("the install takes over a shortcut that points at no file - a shell item, " + + "somebody else's - and its uninstall then deletes it") + } uninstall := scriptLines(files[windowChoco+"tools/chocolateyuninstall.ps1"]) if !anyLine(uninstall, `^if \(\$target\.StartsWith\(\$toolsDir \+ '\\', `) { t.Error("the uninstall removes the Start menu shortcut without asking whether it points " + diff --git a/internal/guard/packagingrefusal_test.go b/internal/guard/packagingrefusal_test.go index 1915825d..d644f11e 100644 --- a/internal/guard/packagingrefusal_test.go +++ b/internal/guard/packagingrefusal_test.go @@ -20,7 +20,8 @@ import ( // leaves no half of a package to be submitted by mistake. func TestTheRendererRefusesInputsThatLookFineAndAreWrong(t *testing.T) { - withoutArm := []byte(strings.Join(packagingSums[:2], "\n") + "\n" + packagingSums[3] + "\n") + withoutArm := []byte(strings.Join(append(packagingSums[:2:2], packagingSums[3:]...), "\n") + "\n") + otherInstaller := []byte(strings.ReplaceAll(string(fixtureSums()), "tfg-setup_0.4.0", "tfg-setup_0.3.0")) otherRelease := []byte(strings.ReplaceAll(string(fixtureSums()), "0.4.0", "0.3.0")) unreleased := []byte(strings.ReplaceAll(string(fixtureSums()), "0.4.0", "9.9.9")) @@ -55,6 +56,8 @@ func TestTheRendererRefusesInputsThatLookFineAndAreWrong(t *testing.T) { {"a release candidate", "v0.4.0-rc1", fixtureSums(), "", "release candidate"}, {"a version without its v", "0.4.0", fixtureSums(), "", "not a release tag"}, {"a checksum file missing one archive", packagingTag, withoutArm, "", "tfg_0.4.0_windows_arm64.zip"}, + {"a checksum file without the installer", packagingTag, withoutInstaller(), "", "tfg-setup_0.4.0_windows_amd64.msi, the Windows installer"}, + {"the installer of another release", packagingTag, otherInstaller, "", "It lists tfg-setup_0.3.0_windows_amd64.msi"}, {"a version the changelog never released", "v9.9.9", unreleased, "", "CHANGELOG.md"}, {"a destination inside the repository", packagingTag, fixtureSums(), inside, "inside the repository"}, {"a destination that already holds something", packagingTag, fixtureSums(), occupied, "already holds"}, @@ -94,6 +97,54 @@ func TestTheRendererRefusesInputsThatLookFineAndAreWrong(t *testing.T) { } } +// withoutInstaller is the fixture as a release published before the Windows +// installer existed has it - v0.4.0's own checksum file. +func withoutInstaller() []byte { + var lines []string + for _, line := range packagingSums { + if !strings.HasSuffix(line, ".msi") { + lines = append(lines, line) + } + } + return []byte(strings.Join(lines, "\n") + "\n") +} + +// A release without the installer is refused for WinGet, whose window package +// installs it first, and still renders for Chocolatey, which needs none - the +// packages job in ci.yml renders the latest release, and v0.4.0 has none. Each +// feed rendered alone is, byte for byte, its part of a full render. +func TestAReleaseWithoutTheInstallerStillRendersForChocolatey(t *testing.T) { + r := renderPackages(t, packagingTag, withoutInstaller(), filepath.Join(t.TempDir(), "packages")) + if r.code != 1 || !strings.Contains(r.said, "pass --only chocolatey") { + t.Errorf("without the installer, a render of both feeds exited %d and did not say what "+ + "to do instead:\n%s", r.code, r.said) + } + full := renderedPackages(t) + for _, c := range []struct { + feed string + sums []byte + }{ + {"chocolatey", withoutInstaller()}, + {"winget", fixtureSums()}, + } { + alone := renderedFrom(t, c.sums, "--only", c.feed) + want := 0 + for name, body := range full { + if !strings.HasPrefix(name, c.feed+"/") { + continue + } + want++ + if got, ok := alone[name]; !ok || got != body { + t.Errorf("--only %s: %s is missing or differs from the full render", c.feed, name) + } + } + if want == 0 || len(alone) != want { + t.Errorf("--only %s wrote %d file(s), and the full render has %d for that feed", + c.feed, len(alone), want) + } + } +} + // entriesOf lists a directory's names, or says it is absent - which is what a // refusal must leave the parent of its destination as. func entriesOf(t *testing.T, dir string) string { diff --git a/packaging/README.md b/packaging/README.md index 24d94bf4..6976319f 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -8,9 +8,13 @@ release date from `CHANGELOG.md`. The three values the renderer keeps a copy of, the product name, the licence and the copyright line, are held to their Go originals by a guard. - python .github/scripts/build_packages.py --tag v0.4.0 \ + python .github/scripts/build_packages.py --tag vX.Y.Z \ --sums verify-SHA256SUMS.txt --out +The window's WinGet package installs the Windows installer first, so the +checksum file has to have the installer's line. A release published before the +installer existed renders for Chocolatey only, with `--only chocolatey`. + The renderer refuses, with a sentence that names the input and says what to do, and never a traceback: a tag that is a release candidate or not a tag, a checksum file of another release or missing an archive, a version the changelog @@ -29,7 +33,7 @@ destination. |---|---|---| | WinGet | `DonislawDev.TestingFilesGenerator` | `DonislawDev.TestingFilesGenerator.CLI` | | Chocolatey | `testing-files-generator` | `testing-files-generator-cli` | -| archive | `tfg-gui__windows_amd64.zip` | `tfg__windows_amd64.zip`, and `arm64` in WinGet | +| archive | `tfg-gui__windows_amd64.zip`, and in WinGet the installer `tfg-setup__windows_amd64.msi` first | `tfg__windows_amd64.zip`, and `arm64` in WinGet | | command | `tfg-gui` | `tfg` | The window does not need the command line - it carries the same engine - so @@ -47,8 +51,7 @@ its software renderer next to the link rather than in the `opengl` folder beside the real file. With it, WinGet makes no link and puts the package's folder on `PATH`. The command line would work either way, but without the field its shape depends on the machine - a link where symbolic links are allowed, the folder on -`PATH` where they are not. WinGet adds no Start menu shortcut for a portable -package, and the window's description says so. One cost of the field is not +`PATH` where they are not. One cost of the field is not ours to fix. WinGet 1.29.380 left the package's folder on `PATH` after an uninstall, for both of these packages, in machine scope on Windows Server 2025 and in user scope on Windows 11 - measured on 2026-09-25, the entry stays and @@ -56,6 +59,24 @@ points at a folder that no longer exists. [microsoft/winget-cli#6160](https://github.com/microsoft/winget-cli/issues/6160), open, reports the same for another package that sets the field. +**The window in WinGet: the installer first, the archive second.** A portable +package gets no Start menu shortcut, and nothing in a manifest can give it one - +the schema has no field for it, and +[microsoft/winget-cli#2299](https://github.com/microsoft/winget-cli/issues/2299) +asking for one has been open since 2022. So the window's manifest lists two +installers, and the order is load-bearing. The Windows installer comes first: +recent clients prefer it over a portable package, and older ones take the first +installer that applies. It installs for the whole machine, with the shortcut, +and WinGet knows it again by its `UpgradeCode`. The archive comes second, with +no scope, for two kinds of install the installer cannot serve. One is +`--scope user`, without administrator rights. The other is an install made +before the package had the installer - WinGet upgrades only within the kind +that is installed, so without the archive that install could not be upgraded at +all, and with it, it is upgraded as it is. The description says how to get the +shortcut then. The fields of the archive stand in its own entry, not at the top +of the manifest, because the two installers are of two kinds. The command line's +manifest keeps one kind and its fields at the top. + **Chocolatey.** The package downloads the release archive rather than carrying it, so it holds no binaries and owes no `VERIFICATION.txt`, and the archive is the same file, checksum and all, that the release page publishes. Each program is @@ -64,8 +85,11 @@ unpacked into a folder of its own under `tools`. The window gets an empty to close and holds the terminal. It also gets a Start menu shortcut whose working directory is `%USERPROFILE%`, because the window offers a `tfg-out` folder under the directory it was started from, and the package folder is one an ordinary -account cannot write to. The uninstall removes that shortcut only when it points -into the package. The icon is a jsDelivr address pinned to the release tag: +account cannot write to. The install leaves alone a shortcut under the same name +that starts a program outside the package - the Windows installer makes exactly +that one - or that points at no file, as a shortcut to a shell item does, and +replaces one whose target is gone. The uninstall removes the shortcut only when +it points into the package. The icon is a jsDelivr address pinned to the release tag: moderation refuses `raw.githubusercontent.com` and `github.com/.../raw` alike, and an icon on a branch would keep changing under an approved package. @@ -81,17 +105,25 @@ scope and on Windows 11 in user scope: after an upgrade until the next Chocolatey operation on the package, after an uninstall for good. `chocolateybeforemodify.ps1` says so at that moment and names the folder to delete once the program is closed. -- **WinGet stops half way.** An upgrade fails with "Access is denied" on the - program, having already deleted some of the other files, and the package works - again once the upgrade runs with the program closed. Measured for the window - 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. +- **WinGet with the archive stops half way.** An upgrade fails with "Access is + denied" on the program, having already deleted some of the other files, and + the package works again once the upgrade runs with the program closed. + Measured for the window 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. +- **WinGet with the Windows installer goes ahead.** Measured on 2026-10-06 on + Windows Server 2025, the installer of 0.4.0 over 0.3.0 while a tfg command was + running: WinGet said "Restart your PC to finish installation." and exited 0, + the run went on, and a new tfg was the new version at once - the restart only + removes the old copy, as the installer section below says. Not measured with + the window open, so the window's description still asks to close it first. ## 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, +a release asset of its own beside the zip archives. The window's WinGet package +installs it first. The Chocolatey packages and the command line's WinGet package +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 @@ -144,10 +176,13 @@ release: a package is published under the project's name to a feed somebody else moderates. 1. The release is published and its `verify-SHA256SUMS.txt` is the file you - render from. + render from, with the installer's line. 2. Render, then `winget validate` both WinGet folders and `choco pack` both nuspecs. -3. Install, run and remove every package on a machine you can break. +3. Install, run and remove every package on a machine you can break. A fresh + WinGet install of the window takes the installer and puts the window in the + Start menu, `--scope user` takes the archive, and an install of the archive + is upgraded as the archive. 4. Chocolatey: `choco push` with the maintainer's API key. WinGet: one pull request per package against `microsoft/winget-pkgs`, with the three files under `manifests/d/DonislawDev/TestingFilesGenerator//` and diff --git a/packaging/chocolatey/tools/chocolateyinstall.window.ps1.in b/packaging/chocolatey/tools/chocolateyinstall.window.ps1.in index d6687918..ef2f8bbd 100644 --- a/packaging/chocolatey/tools/chocolateyinstall.window.ps1.in +++ b/packaging/chocolatey/tools/chocolateyinstall.window.ps1.in @@ -35,13 +35,41 @@ New-Item -ItemType File -Path "$exe.gui" -Force | Out-Null # stored as written and expanded when the shortcut is started, so it is the # profile of whoever starts it (measured 2026-09-25). Owner's decision the same # day. +# +# A shortcut of another install under the same name is left alone. The +# program's Windows installer makes one with exactly this name, and taking it +# over would hand it to this package - whose uninstall would then delete the +# only Start menu entry the installer has. A shortcut that points at no file - +# a shell item such as File Explorer answers with an empty path - is not ours +# either, and is left alone the way the uninstall leaves it: taking it over +# would let the uninstall delete it (outside review of #164). A shortcut that +# points into this package is the one an earlier version made, and one whose +# target is gone belongs to nobody. Both are replaced. $shortcut = Join-Path ([Environment]::GetFolderPath('CommonPrograms')) '{{TITLE}}.lnk' -Install-ChocolateyShortcut ` - -ShortcutFilePath $shortcut ` - -TargetPath $exe ` - -WorkingDirectory '%USERPROFILE%' ` - -IconLocation $exe ` - -Description '{{SHORT_DESCRIPTION}}' +$keep = '' +if (Test-Path -LiteralPath $shortcut) { + $target = '' + try { + $target = (New-Object -ComObject WScript.Shell).CreateShortcut($shortcut).TargetPath + } catch { + $target = '' + } + if (-not $target) { + $keep = 'It points at no file, so it is not from this package.' + } elseif (-not $target.StartsWith($toolsDir + '\', [StringComparison]::OrdinalIgnoreCase) -and (Test-Path -LiteralPath $target)) { + $keep = "It points at $target, not at this package." + } +} +if ($keep) { + Write-Host "Left the Start menu shortcut alone. $keep" +} else { + Install-ChocolateyShortcut ` + -ShortcutFilePath $shortcut ` + -TargetPath $exe ` + -WorkingDirectory '%USERPROFILE%' ` + -IconLocation $exe ` + -Description '{{SHORT_DESCRIPTION}}' +} Write-Host '' Write-Host 'Start it from the Start menu, or type tfg-gui in a terminal.' diff --git a/packaging/winget/installer.yaml.in b/packaging/winget/installer.cli.yaml.in similarity index 100% rename from packaging/winget/installer.yaml.in rename to packaging/winget/installer.cli.yaml.in diff --git a/packaging/winget/installer.window.yaml.in b/packaging/winget/installer.window.yaml.in new file mode 100644 index 00000000..e936b8b7 --- /dev/null +++ b/packaging/winget/installer.window.yaml.in @@ -0,0 +1,49 @@ +# Rendered by .github/scripts/build_packages.py - do not edit the generated copy. +# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.{{WINGET_SCHEMA}}.schema.json + +PackageIdentifier: {{WINGET_ID}} +PackageVersion: {{VERSION}} +ReleaseDate: {{RELEASE_DATE}} +# Two installers, and their ORDER is load-bearing. +# +# Only the Windows installer puts the window in the Start menu. A portable +# package cannot have a shortcut at all: the manifest schema has no field for +# one, WinGet's portable installer makes none, and microsoft/winget-cli#2299 +# asking for it has been open since 2022. The installer comes FIRST because that +# is what decides a fresh install: recent clients prefer it over portable when +# the user has set no preference (microsoft/winget-cli#6123), and older ones keep +# the first installer that applies when nothing else tells two apart. +# +# The archive stays SECOND. An upgrade only considers installers of the kind +# that is installed, and portable is compatible with nothing but portable, so +# without it an install made before this package had the installer could not +# be upgraded at all. It is also what --scope user installs, for a person +# without administrator rights - the installer is for the whole machine. +Installers: +- Architecture: x64 + InstallerType: wix + Scope: machine + # Windows Installer asks for elevation itself. + ElevationRequirement: elevatesSelf + InstallerUrl: {{MSI_URL}} + InstallerSha256: {{MSI_SHA256}} + # How WinGet recognises the installed program from one release to the next: + # the UpgradeCode never changes, while every build gets a new ProductCode. + AppsAndFeaturesEntries: + - UpgradeCode: '{{UPGRADE_CODE}}' +- Architecture: x64 + InstallerType: zip + NestedInstallerType: portable + NestedInstallerFiles: + - RelativeFilePath: {{EXE}} + # Load-bearing. Without it WinGet reaches the window through a symbolic link + # in its links folder, and the window started that way cannot find the + # software renderer in the opengl folder beside the real file - the program + # looks for it next to the path it was started from. With it, WinGet makes no + # link and puts this package's own folder on PATH instead (PortableInstaller.cpp + # in winget-cli, read 2026-09-25). + ArchiveBinariesDependOnPath: true + InstallerUrl: {{URL_AMD64}} + InstallerSha256: {{WINGET_SHA256_AMD64}} +ManifestType: installer +ManifestVersion: {{WINGET_SCHEMA}}