From 64f5707532b855902f963e727700b6e2c75e2db6 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 6 Oct 2026 10:07:18 +0200 Subject: [PATCH 1/2] packaging: the window in WinGet comes with a Start menu shortcut - the installer first, the archive second A portable package cannot have a Start menu shortcut - the manifest has no field for one and WinGet makes none - so a WinGet install of the window could be started from a terminal only. The window's manifest now lists the Windows installer first and the archive second. A fresh install takes the installer: Program Files, the shortcut, the folder on PATH once. The archive stays, without a scope, for --scope user and for an install made before the package had the installer, which WinGet upgrades only as the kind it is. WinGet knows the installed program by the installer's UpgradeCode. The command line keeps its own template, and its manifests and Chocolatey package render byte for byte as before. The renderer takes the installer's name and UpgradeCode from build_msi.py, refuses a checksum file without the installer's line when it renders WinGet, and renders one feed with --only. The packages job renders the latest release --only chocolatey, because releases before the installer have none. The window's Chocolatey install leaves alone a shortcut of the same name that starts a program outside the package - the installer makes exactly that one, and taking it over would let the Chocolatey uninstall delete it - and replaces one whose target is gone. The packages job asks both on a runner. Guards read the WinGet manifests as YAML, each installer with the fields WinGet gives it. The fixture carries an installer line v0.4.0 never had. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/build_packages.py | 114 +++++++--- .github/workflows/ci.yml | 35 ++- internal/guard/packaging_test.go | 212 +++++++++++++++--- internal/guard/packagingrefusal_test.go | 53 ++++- packaging/README.md | 47 +++- .../tools/chocolateyinstall.window.ps1.in | 35 ++- ...nstaller.yaml.in => installer.cli.yaml.in} | 0 packaging/winget/installer.window.yaml.in | 49 ++++ 8 files changed, 464 insertions(+), 81 deletions(-) rename packaging/winget/{installer.yaml.in => installer.cli.yaml.in} (100%) create mode 100644 packaging/winget/installer.window.yaml.in 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..3557a9b9 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,31 @@ 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. 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" + $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..75d9c4d1 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")) } } } -// Every archive a package downloads is one the release workflow builds. +// 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, 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,11 @@ 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 - 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 +498,12 @@ 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, `^ if \(\$target -and -not \$target\.StartsWith\(\$toolsDir \+ '\\', \[StringComparison\]::OrdinalIgnoreCase\) -and \(Test-Path -LiteralPath \$target\)\) \{$`) || + !anyLine(install, `^if \(\$other\) \{$`) || !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") + } 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..5f302ec3 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,10 @@ 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 - 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. @@ -90,8 +113,9 @@ scope and on Windows 11 in user scope: ## 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 +168,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..76f73c5b 100644 --- a/packaging/chocolatey/tools/chocolateyinstall.window.ps1.in +++ b/packaging/chocolatey/tools/chocolateyinstall.window.ps1.in @@ -35,13 +35,36 @@ 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 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}}' +$other = '' +if (Test-Path -LiteralPath $shortcut) { + $target = '' + try { + $target = (New-Object -ComObject WScript.Shell).CreateShortcut($shortcut).TargetPath + } catch { + $target = '' + } + if ($target -and -not $target.StartsWith($toolsDir + '\', [StringComparison]::OrdinalIgnoreCase) -and (Test-Path -LiteralPath $target)) { + $other = $target + } +} +if ($other) { + Write-Host "Left the Start menu shortcut alone. It points at $other, not at this package." +} 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}} From 44643dfc800f4f8c17664e7b8dc274d4d174a996 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 6 Oct 2026 10:21:41 +0200 Subject: [PATCH 2/2] packaging: a shortcut that points at no file is left alone at install too, and the README tells the archive from the installer The window's Chocolatey install took over a Start menu shortcut whose target is empty - a shortcut to a shell item answers with an empty path - and the uninstall then deleted the replacement, so the original was lost. The uninstall already leaves such a shortcut alone, and the install now does the same, with the same sentence. The packages job asks it on a runner. The README said WinGet stops half way when the program runs. That is the archive. The Windows installer goes ahead and asks for a restart, measured with a tfg command running, not with the window open. A semicolon in a guard's comment is a full stop. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 17 ++++++++++++-- internal/guard/packaging_test.go | 15 ++++++++----- packaging/README.md | 22 +++++++++++++------ .../tools/chocolateyinstall.window.ps1.in | 21 +++++++++++------- 4 files changed, 53 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3557a9b9..1650e363 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -543,8 +543,9 @@ jobs: # 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. One whose - # target is gone belongs to nobody, and the install replaces it. + # 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 @@ -556,6 +557,18 @@ jobs: 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() diff --git a/internal/guard/packaging_test.go b/internal/guard/packaging_test.go index 75d9c4d1..ec76ee5b 100644 --- a/internal/guard/packaging_test.go +++ b/internal/guard/packaging_test.go @@ -480,9 +480,10 @@ func TestTheChocolateyIconIsAPinnedCdnAddress(t *testing.T) { // 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. 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 - 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. +// 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"]) @@ -498,12 +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, `^ if \(\$target -and -not \$target\.StartsWith\(\$toolsDir \+ '\\', \[StringComparison\]::OrdinalIgnoreCase\) -and \(Test-Path -LiteralPath \$target\)\) \{$`) || - !anyLine(install, `^if \(\$other\) \{$`) || !anyLine(install, `^ Install-ChocolateyShortcut `+"`"+`$`) { + 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/packaging/README.md b/packaging/README.md index 5f302ec3..6976319f 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -87,8 +87,9 @@ 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 install leaves alone a shortcut under the same name that starts a program outside the package - the Windows installer makes exactly -that one - 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: +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. @@ -104,11 +105,18 @@ 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 diff --git a/packaging/chocolatey/tools/chocolateyinstall.window.ps1.in b/packaging/chocolatey/tools/chocolateyinstall.window.ps1.in index 76f73c5b..ef2f8bbd 100644 --- a/packaging/chocolatey/tools/chocolateyinstall.window.ps1.in +++ b/packaging/chocolatey/tools/chocolateyinstall.window.ps1.in @@ -39,11 +39,14 @@ New-Item -ItemType File -Path "$exe.gui" -Force | Out-Null # 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 into this -# package is the one an earlier version made, and one whose target is gone -# belongs to nobody. Both are replaced. +# 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' -$other = '' +$keep = '' if (Test-Path -LiteralPath $shortcut) { $target = '' try { @@ -51,12 +54,14 @@ if (Test-Path -LiteralPath $shortcut) { } catch { $target = '' } - if ($target -and -not $target.StartsWith($toolsDir + '\', [StringComparison]::OrdinalIgnoreCase) -and (Test-Path -LiteralPath $target)) { - $other = $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 ($other) { - Write-Host "Left the Start menu shortcut alone. It points at $other, not at this package." +if ($keep) { + Write-Host "Left the Start menu shortcut alone. $keep" } else { Install-ChocolateyShortcut ` -ShortcutFilePath $shortcut `