diff --git a/.github/scripts/build_packages.py b/.github/scripts/build_packages.py new file mode 100644 index 0000000..5e32861 --- /dev/null +++ b/.github/scripts/build_packages.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 +"""Render the WinGet and Chocolatey packages for one published release. + +Two programs ship, the window and the command line, each in an archive of its +own, and each becomes a package of its own in both feeds - decided by the owner +on 2026-09-25, so that a build agent can take the command line without the +window and one package waiting in moderation does not hold the other. + +The templates in packaging/ hold the shape. This fills them from the one place +each value lives: the version from the tag, the checksums from the release's +own checksum file, the addresses from go.mod and web/public/CNAME, the date +from CHANGELOG.md. Nothing is typed twice, and the two values that are - the +product's name and its licence - are held to their Go originals by a guard. + +Usage: + python .github/scripts/build_packages.py --tag v0.4.0 \\ + --sums verify-SHA256SUMS.txt --out + +Exit codes: + 0 every package was rendered into --out + 1 refused, and the message says which input and why + +Nothing here submits anything. Submitting is a person's step: a package is +published under the project's name to a feed somebody else moderates. +""" +import argparse +import os +import re +import shutil +import sys +import tempfile +from collections import namedtuple + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +TEMPLATES = os.path.join(ROOT, "packaging") +TEMPLATE_SUFFIX = ".in" +PLACEHOLDER = re.compile(r"\{\{([A-Z0-9_]+)\}\}") + +# The manifest schema WinGet's submission pipeline accepts TODAY. The number +# documented in the winget-pkgs tree is not the same question: the sibling +# project was refused on 1.28.0, which validated and installed locally. Read +# from freshly merged manifests on 2026-09-25 - 7 of 7 carried this one. +WINGET_SCHEMA = "1.12.0" + +# Held by a guard to internal/gui/run_cgo.go and internal/legal/spdx.go. +APP_NAME = "Testing Files Generator" +LICENCE = "GPL-3.0-only" + +# The name both feeds show as the author, and the first part of the WinGet +# identifiers - the same publisher folder the sibling project already has in +# winget-pkgs. +PUBLISHER = "DonislawDev" +ICON = "internal/gui/icon/chickpea.png" + +# WinGet's name for an architecture, keyed by the one the archive names use. +WINGET_ARCH = {"amd64": "x64", "arm64": "arm64"} + +Package = namedtuple("Package", "kind winget_id choco_id title program archive arches moniker") + +PACKAGES = ( + Package("window", "DonislawDev.TestingFilesGenerator", "testing-files-generator", + APP_NAME, "tfg-gui", "tfg-gui_{version}_windows_{arch}.zip", ("amd64",), "tfg-gui"), + Package("cli", "DonislawDev.TestingFilesGenerator.CLI", "testing-files-generator-cli", + APP_NAME + " CLI", "tfg", "tfg_{version}_windows_{arch}.zip", ("amd64", "arm64"), "tfg"), +) + +DESCRIPTION = ( + "Testing Files Generator makes real files to test against - an upload form, a parser, " + "anything that takes a file. Pick a format and a size and you get a file of exactly " + "that size, to the byte, that a real reader opens. Every run also writes a manifest " + "saying what your system should do with each file. It runs entirely on your machine." +) + +SHORT = { + "window": "Real test files at any exact size, with a manifest saying how your system " + "should react. The desktop window.", + "cli": "Real test files at any exact size, with a manifest saying how your system " + "should react. The command line, for scripts and pipelines.", +} + +TAGS = ("qa", "testing", "test-data", "test-files", "file-generator", "fixtures") +KIND_TAG = {"window": "gui", "cli": "cli"} + + +def how_to_start(package, feed): + """The paragraph that differs by feed: what the package gives and how to start it.""" + other = next(p for p in PACKAGES if p is not package) + other_id = other.winget_id if feed == "winget" else other.choco_id + if package.kind == "cli": + return ("This package is the command line, for scripts and pipelines. The desktop " + "window is the package %s. Type tfg help to see the commands." % other_id) + 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." % 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) + + +def refuse(message): + raise SystemExit("build_packages: %s" % message) + + +def read_text(path): + with open(path, encoding="utf-8-sig") as handle: + return handle.read().replace("\r\n", "\n") + + +def repository(): + """owner/name, from the module path - the one place the address is written.""" + found = re.search(r"^module github\.com/([^/\s]+/[^/\s]+)\s*$", + read_text(os.path.join(ROOT, "go.mod")), re.M) + if not found: + refuse("go.mod does not name a github.com module, so there is no address to point at") + return found.group(1) + + +def site(): + return "https://%s/" % read_text(os.path.join(ROOT, "web", "public", "CNAME")).strip() + + +def parse_tag(tag): + """The version a tag names, or a refusal saying why it names none.""" + if re.fullmatch(r"v\d+\.\d+\.\d+-.+", tag): + refuse("%s is a release candidate. The feeds get published releases only, " + "because a person installing from them has no way to see the difference" % tag) + found = re.fullmatch(r"v(\d+)\.(\d+)\.(\d+)", tag) + if not found: + refuse("%r is not a release tag. Pass it the way the release is tagged, for " + "example v0.4.0" % tag) + return "%s.%s.%s" % found.groups() + + +def release_date(version): + """The date the changelog gives the version - one reader, not a second answer.""" + found = re.search(r"^## \[%s\] - (\d{4}-\d{2}-\d{2})\s*$" % re.escape(version), + read_text(os.path.join(ROOT, "CHANGELOG.md")), re.M) + if not found: + refuse("CHANGELOG.md has no dated section for %s. Package a version that has " + "been released" % version) + return found.group(1) + + +def read_sums(path): + """{file name: lowercase sha256} from a checksum file, in any of its spellings. + + sha256sum writes ' ' in text mode and ' *' in binary + mode, and that star is not part of the name. A file saved on Windows may + carry a byte order mark and CRLF. None of that may reach an address. + """ + sums = {} + for number, line in enumerate(read_text(path).split("\n"), 1): + if not line.strip(): + continue + found = re.fullmatch(r"([0-9A-Fa-f]{64}) [ *](\S.*)", line.strip()) + if not found: + refuse("%s line %d is not ' ': %r" % (path, number, line)) + sums[found.group(2).strip()] = found.group(1).lower() + return sums + + +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.""" + 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)) + return found + + +def values(package, version, tag, sums): + """Every placeholder a template may use, for one package.""" + repo = repository() + repo_url = "https://github.com/%s" % repo + names = archives(package, version) + installers = [] + for arch, name in names.items(): + installers += ["- Architecture: %s" % WINGET_ARCH[arch], + " InstallerUrl: %s/releases/download/%s/%s" % (repo_url, tag, name), + " InstallerSha256: %s" % sums[name].upper()] + return { + "VERSION": version, + "WINGET_SCHEMA": WINGET_SCHEMA, + "WINGET_ID": package.winget_id, + "CHOCO_ID": package.choco_id, + "TITLE": package.title, + "PUBLISHER": PUBLISHER, + "PROGRAM": package.program, + "EXE": package.program + ".exe", + "MONIKER": package.moniker, + "LICENSE": LICENCE, + "REPO_URL": repo_url, + "PROJECT_URL": site(), + "DOCS_URL": site() + "docs/", + "LICENSE_URL": "%s/blob/%s/LICENSE" % (repo_url, tag), + "RELEASE_NOTES_URL": "%s/releases/tag/%s" % (repo_url, tag), + "PACKAGE_SOURCE_URL": "%s/tree/main/packaging/chocolatey" % repo_url, + # A CDN pinned to the tag, both halves load-bearing: Chocolatey's moderation + # refuses raw.githubusercontent.com and github.com/.../raw alike, and an icon + # on a branch keeps changing under a package that is already approved. + "ICON_URL": "https://cdn.jsdelivr.net/gh/%s@%s/%s" % (repo, tag, ICON), + "RELEASE_DATE": release_date(version), + "URL_AMD64": "%s/releases/download/%s/%s" % (repo_url, tag, names["amd64"]), + "SHA256_AMD64": sums[names["amd64"]], + "SHORT_DESCRIPTION": SHORT[package.kind], + "DESCRIPTION": DESCRIPTION, + "WINGET_HOW_TO_START": how_to_start(package, "winget"), + "CHOCO_HOW_TO_START": how_to_start(package, "chocolatey"), + "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): + """Fill every placeholder, or refuse. An unknown one is an error, never an empty string. + + A placeholder alone on its line may hold several lines, and each takes the + indentation the placeholder had - that is how a YAML block stays a block. A + placeholder inside a line takes one line only. + """ + out = [] + for line in text.split("\n"): + for key in PLACEHOLDER.findall(line): + if key not in table: + refuse("%s uses {{%s}}, which the renderer does not know" % (name, key)) + alone = re.fullmatch(r"( *)\{\{([A-Z0-9_]+)\}\}", line) + if alone: + out += [alone.group(1) + part if part else "" for part in table[alone.group(2)].split("\n")] + continue + for key in PLACEHOLDER.findall(line): + if "\n" in table[key]: + refuse("%s puts the several lines of {{%s}} inside a line" % (name, key)) + breaks = breaking(name, table[key]) + if breaks: + refuse("{{%s}} holds %r, which breaks %s: %s" % (key, table[key], name, breaks)) + out.append(PLACEHOLDER.sub(lambda m: table[m.group(1)], line)) + return "\n".join(out) + + +# What a value substituted inside a line must not hold, by the file it lands in. +# Each would still render, and each would hand the feed a file that means +# something else: a quote ends a PowerShell string early, a bracket or an +# ampersand is markup in the nuspec, and a colon followed by a space or a space +# followed by a hash turns the rest of a plain YAML value into a key or a +# comment. +BREAKS = ( + (".ps1", "'", "a single quote ends the PowerShell string it sits in"), + (".nuspec", "<", "the nuspec reads it as markup"), + (".nuspec", ">", "the nuspec reads it as markup"), + (".nuspec", "&", "the nuspec reads it as markup"), + (".yaml", ": ", "YAML reads the rest as a key"), + (".yaml", " #", "YAML reads the rest as a comment"), +) + + +def breaking(name, value): + for suffix, text, why in BREAKS: + if name.endswith(suffix) and text in value: + return why + return "" + + +def check_script(text, name): + """A package script is read by Windows PowerShell 5.1, which takes a file without a + byte order mark as the machine's ANSI code page - so a script that is not ASCII + reaches it changed.""" + for number, line in enumerate(text.split("\n"), 1): + if any(ord(c) > 127 for c in line): + refuse("%s line %d is not ASCII: %r" % (name, number, line)) + + +def templates(kind): + """(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 base, _, files in os.walk(os.path.join(TEMPLATES, feed)): + for file in sorted(files): + if not file.endswith(TEMPLATE_SUFFIX): + continue + parts = file[:-len(TEMPLATE_SUFFIX)].split(".") + owner = parts[1] if len(parts) > 2 and parts[1] in kinds else None + if owner not in (None, kind): + continue + target = ".".join(p for i, p in enumerate(parts) if not (i == 1 and owner)) + relative = os.path.relpath(os.path.join(base, target), TEMPLATES) + yield os.path.join(base, file), relative.replace(os.sep, "/") + + +def destination(package, relative, version): + """Where a rendered file goes: WinGet's in the folder layout winget-pkgs uses, so + the three files can be copied across as they are.""" + feed, _, rest = relative.partition("/") + if feed == "winget": + kind = rest[:-len(".yaml")] + name = package.winget_id + ("" if kind == "version" else "." + kind) + ".yaml" + parts = package.winget_id.split(".") + return "/".join(["winget", "manifests", parts[0][0].lower()] + parts + [version, name]) + if rest == "package.nuspec": + rest = package.choco_id + ".nuspec" + return "/".join(["chocolatey", package.choco_id, rest]) + + +def check_out(out): + """--out is outside the repository and empty or absent, or a refusal.""" + out = os.path.abspath(out) + root = os.path.normcase(ROOT) + if os.path.normcase(out) == root or os.path.normcase(out).startswith(root + os.sep): + refuse("--out %s is inside the repository. Rendered packages are not source - put " + "them somewhere outside it" % out) + if os.path.exists(out) and (not os.path.isdir(out) or os.listdir(out)): + refuse("--out %s already holds something. Nothing is overwritten - pass an empty " + "or new directory" % out) + return out + + +def build(tag, sums_path, out): + """Render every package into out, all or nothing.""" + version = parse_tag(tag) + out = check_out(out) + sums = checked_archives(read_sums(sums_path), version, sums_path) + if not os.path.isfile(os.path.join(ROOT, ICON)): + refuse("the icon %s is not in the repository" % ICON) + + parent = os.path.dirname(out) + os.makedirs(parent, exist_ok=True) + work = tempfile.mkdtemp(prefix=".packages-", dir=parent) + try: + used = set() + known = set() + for package in PACKAGES: + table = values(package, version, tag, sums) + known |= set(table) + for source, relative in templates(package.kind): + text = read_text(source) + used |= set(PLACEHOLDER.findall(text)) + rendered = render(text, table, relative) + if relative.endswith(".ps1"): + check_script(rendered, relative) + target = os.path.join(work, *destination(package, relative, version).split("/")) + os.makedirs(os.path.dirname(target), exist_ok=True) + with open(target, "w", encoding="utf-8", newline="\n") as handle: + handle.write(rendered) + if known - used: + refuse("no template uses %s any more - take it out of the renderer" + % ", ".join(sorted(known - used))) + if os.path.isdir(out): + os.rmdir(out) + os.rename(work, out) + finally: + if os.path.isdir(work): + shutil.rmtree(work) + return out + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + 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") + args = parser.parse_args(argv) + out = build(args.tag, args.sums, args.out) + for base, _, files in sorted(os.walk(out)): + for file in sorted(files): + print(os.path.join(base, file)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5dce56b..6f5f721 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -381,6 +381,153 @@ jobs: grep -q -- '--- PASS: TestTheWindowBinaryDoesNotImportOpenGLAtLoadTime' import-table.log shell: bash + packages: + name: the Chocolatey packages install and leave + # What the packages DO, asked on a clean Windows machine rather than read + # off their text. The guards in internal/guard/packaging_test.go hold the + # lines that matter, and they check that a line is there - which is not the + # same as the install working (docs/PACKAGING-2026-09-25.md section 8.5). + # This job renders both Chocolatey packages from the latest published + # release, installs them the way a person does - the archives come from + # the release page and are checked against its checksums - and asks the + # machine what happened: the command answers with the version, the window's + # 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. + # + # 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. + # + # 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, + # read 2026-09-25) - so both WinGet manifests are validated and installed on + # a virtual machine before every submission instead. + runs-on: windows-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + + - name: render the packages for the latest release + id: render + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference = 'Stop' + $tag = gh release view --json tagName --jq .tagName + if ($LASTEXITCODE -ne 0 -or -not $tag) { throw "could not read the latest release" } + gh release download $tag --pattern verify-SHA256SUMS.txt --dir $env:RUNNER_TEMP + 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') + if ($LASTEXITCODE -ne 0) { throw "the renderer refused $tag" } + "version=$($tag.TrimStart('v'))" >> $env:GITHUB_OUTPUT + + - name: install both, ask the machine, remove both, ask again + shell: pwsh + env: + VERSION: ${{ steps.render.outputs.version }} + run: | + $ErrorActionPreference = 'Stop' + $failed = 0 + function Check($ok, $what) { + if ($ok) { "ok $what" } else { "FAILED $what"; $script:failed++ } + } + + $feed = Join-Path $env:RUNNER_TEMP 'feed' + New-Item -ItemType Directory -Force $feed | Out-Null + foreach ($id in 'testing-files-generator', 'testing-files-generator-cli') { + $nuspec = Join-Path $env:RUNNER_TEMP "packages\chocolatey\$id\$id.nuspec" + choco.exe pack $nuspec --outputdirectory $feed + if ($LASTEXITCODE -ne 0) { throw "choco pack $id exited $LASTEXITCODE" } + } + choco.exe install testing-files-generator testing-files-generator-cli --source $feed --yes --no-progress + if ($LASTEXITCODE -ne 0) { throw "choco install exited $LASTEXITCODE" } + + $bin = Join-Path $env:ChocolateyInstall 'bin' + $lib = Join-Path $env:ChocolateyInstall 'lib' + $window = Join-Path $lib 'testing-files-generator\tools\tfg-gui\tfg-gui.exe' + $cli = Join-Path $lib 'testing-files-generator-cli\tools\tfg\tfg.exe' + $shortcut = Join-Path ([Environment]::GetFolderPath('CommonPrograms')) 'Testing Files Generator.lnk' + + $installed = (choco.exe list --limit-output) -join "`n" + Check ($installed -match "(?m)^testing-files-generator\|$([regex]::Escape($env:VERSION))$") "the window package is installed at $env:VERSION" + Check ($installed -match "(?m)^testing-files-generator-cli\|$([regex]::Escape($env:VERSION))$") "the command line package is installed at $env:VERSION" + + $said = (& (Join-Path $bin 'tfg.exe') version) -join '' + Check ($LASTEXITCODE -eq 0 -and $said.Trim() -eq $env:VERSION) "tfg version answers $env:VERSION through the shim (said '$said')" + + # The shim describes itself without starting anything. Measured + # 2026-09-25: it names its target and says GUI 'True' only when the + # .gui file lay beside the program when the shim was made. + $cliShim = (& (Join-Path $bin 'tfg.exe') --shimgen-help 2>&1) -join "`n" + Check ($cliShim -match "GUI: 'False'" -and $cliShim.Contains($cli)) "the tfg shim waits for the command line, so a script reads its exit code" + $windowShim = (& (Join-Path $bin 'tfg-gui.exe') --shimgen-help 2>&1) -join "`n" + Check ($windowShim -match "GUI: 'True'" -and $windowShim.Contains($window)) "the tfg-gui shim does not hold the terminal" + + foreach ($file in 'opengl32.dll', 'libgallium_wgl.dll') { + Check (Test-Path (Join-Path (Split-Path $window) "opengl\$file")) "the software renderer's $file lies beside the window" + } + + $shell = New-Object -ComObject WScript.Shell + Check (Test-Path $shortcut) "the Start menu shortcut is there" + $link = $shell.CreateShortcut($shortcut) + Check ($link.TargetPath -eq $window) "the shortcut starts the window (it starts '$($link.TargetPath)')" + Check ($link.WorkingDirectory -eq '%USERPROFILE%') "the shortcut starts in the user's profile (it starts in '$($link.WorkingDirectory)')" + + choco.exe uninstall testing-files-generator-cli --yes --no-progress + if ($LASTEXITCODE -ne 0) { throw "choco uninstall of the command line exited $LASTEXITCODE" } + Check (-not (Test-Path (Join-Path $bin 'tfg.exe'))) "removing the command line takes its shim" + Check (-not (Test-Path (Split-Path $cli))) "removing the command line takes its files" + + # A shortcut of somebody else's under the same name, made after the + # install, must survive the uninstall - one pointing at a file, and + # one pointing at a shell item, which answers with an empty path. + $other = $shell.CreateShortcut($shortcut) + $other.TargetPath = Join-Path $env:WINDIR 'notepad.exe' + $other.Save() + $said = (choco.exe uninstall testing-files-generator --yes --no-progress) -join "`n" + if ($LASTEXITCODE -ne 0) { throw "choco uninstall of the window exited $LASTEXITCODE" } + Check (Test-Path $shortcut) "a shortcut that points at another file is left alone" + Check ($said -match 'It points at .*notepad\.exe, not at this package') "and the uninstall says where it points" + Remove-Item -LiteralPath $shortcut -Force + Check (-not (Test-Path (Join-Path $bin 'tfg-gui.exe'))) "removing the window takes its shim" + Check (-not (Test-Path (Split-Path $window))) "removing the window takes its files" + + choco.exe install testing-files-generator --source $feed --yes --no-progress + if ($LASTEXITCODE -ne 0) { throw "the second install exited $LASTEXITCODE" } + $other = $shell.CreateShortcut($shortcut) + $other.TargetPath = '::{20D04FE0-3AEA-1069-A2D8-08002B30309D}' + $other.Save() + Check ($shell.CreateShortcut($shortcut).TargetPath -eq '') "a shortcut to a shell item answers with an empty path" + $said = (choco.exe uninstall testing-files-generator --yes --no-progress) -join "`n" + if ($LASTEXITCODE -ne 0) { throw "the second uninstall exited $LASTEXITCODE" } + Check (Test-Path $shortcut) "a shortcut that points at a shell item is left alone" + Check ($said -match 'It points at no file, so it is not from this package') "and the uninstall says so in a whole sentence" + Remove-Item -LiteralPath $shortcut -Force + + # And the ordinary case: install again, uninstall, and the package's + # own shortcut goes with it. + choco.exe install testing-files-generator --source $feed --yes --no-progress + if ($LASTEXITCODE -ne 0) { throw "the third install exited $LASTEXITCODE" } + Check (Test-Path $shortcut) "installing again makes the shortcut again" + choco.exe uninstall testing-files-generator --yes --no-progress + if ($LASTEXITCODE -ne 0) { throw "the third uninstall exited $LASTEXITCODE" } + Check (-not (Test-Path $shortcut)) "removing the window takes its own shortcut" + + if ($failed -ne 0) { throw "$failed check(s) failed - read the FAILED lines above" } + "every check passed" + govulncheck: name: known vulnerabilities runs-on: ubuntu-latest diff --git a/README.md b/README.md index 240a6f0..1369f96 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ **Testing Files Generator** is a tool for QA engineers and developers who need real files to test against - an upload form, a parser, anything that takes a file and -has an opinion about it. You pick one of its 24 formats and the size you want, +has an opinion about it. You pick one of its 26 formats and the size you want, and you get **exactly that**: ask for a 10 MB PDF and you get a PDF that a reader will open, at 10 MB to the byte. Every run also leaves a manifest saying **what your system should do with each file**, which is the part other generators leave @@ -23,7 +23,7 @@ needs it finds out it exists. - **Hit an exact size, to the byte** - ask for 10485761 bytes and get exactly that, never a silently rounded file. -- **Write 24 real formats** - a generated PNG opens in an image viewer, a DOCX +- **Write 26 real formats** - a generated PNG opens in an image viewer, a DOCX opens in Word, a ZIP extracts. Not padded zeros with an extension. - **Say what should happen to each file** - the manifest carries an expected outcome, so your test reads the assertion instead of you writing it out. diff --git a/internal/guard/mutationcoverage_test.go b/internal/guard/mutationcoverage_test.go index 1257fde..470ccd1 100644 --- a/internal/guard/mutationcoverage_test.go +++ b/internal/guard/mutationcoverage_test.go @@ -114,6 +114,8 @@ var notProvenByMutation = map[string]bool{ // "proven another way" are different states and lumping them together would // send a later session to re-prove what is already proven. var provenByProbe = map[string]string{ + "TestThePackageSourcesAreTrackedByGit": "broken by hand on 2026-09-25 and put back: git rm --cached on packaging/chocolatey/tools/chocolateyuninstall.window.ps1.in made it red, naming that file, and git add made it green again. " + + "A probe rather than a mutation entry because what it reads is git's index, not the text of a file - no substitution in any file untracks one.", "TestAManifestCarryingACredentialIsWrittenForItsOwner": "broken by hand on 2026-09-06 and put back, because the mutation is expressible and the OBSERVATION is not - Windows has no permission bits, Go maps only the owner write bit onto its read only attribute, and this machine is the one the mutation runner runs on. Changed internal/manifest mode() from 0o600 to 0o666, cross compiled the guard binary for linux/amd64 and ran it in a debian container against the real repository: red, naming the case - \"a manifest with a password came out 0644 and should be 0600\" - while the two cases that must stay 0644 stayed green. A probe rather than a mutation entry because a runner on Windows would score this NOT CAUGHT about a healthy guard, which is the worst answer of the three. The half of this pair that runs everywhere is TestTheRecordAndTheRegistryAgreeOnWhatIsACredential, and that one has a mutation.", "TestTheEncoderSurvivesTheSizeThatCrashedItsAssembly": "proved by tools/probes/avifasm on 2026-08-29, which is the sweep that found the fault in the first place. Run with the assembly, a 640x256 picture killed the process inside cflAcMain8AVX2 at av1/cfl_amd64.s:281 with an access violation - in two runs out of three, so it turns on what the heap looks like rather than on the input alone. One size out of 240 crashed. Run with the tag this project ships, 240 out of 240 encoded and the bytes were identical either way. A probe rather than a mutation entry because what would have to be broken is a BUILD FLAG, not a line of code: the substitution that removes the tag lives in .github/build-tags, and a run without it does not fail this guard, it takes the whole test binary down with it. That is loud, and it is the honest shape for a guard against memory read outside its buffer, but it is not something the runner can score.", "TestTheIconMacOSReadsCarriesEverySizeItIsAskedFor": "broken by hand on 2026-08-28, three ways, and put back byte for byte - the file is untracked in git, so the restore was checked by hash rather than by a clean diff. " + diff --git a/internal/guard/packaging_test.go b/internal/guard/packaging_test.go new file mode 100644 index 0000000..a9b05ff --- /dev/null +++ b/internal/guard/packaging_test.go @@ -0,0 +1,471 @@ +package guard + +import ( + "errors" + "io/fs" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +// The WinGet and Chocolatey packages, and the renderer that fills them. +// +// These files are published under the project's name to feeds somebody else +// moderates, so a mistake costs a stranger's review time, and for the few +// lines that decide whether an install works at all, a person whose install +// does nothing. What is guarded here is what a reviewer cannot catch for us. +// What the packages DO on a real machine is not a text question - the job in +// ci.yml that installs them on a Windows runner answers that, and the +// measurement on a virtual machine before each submission +// (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. + +const packagingTag = "v0.4.0" + +var packagingSums = []string{ + "e744f4ff793407218fac9eec139dd49c03264ab636db2057ba2963d0515625ad tfg-gui_0.4.0_windows_amd64.zip", + "16fe56c7b3f2a13d22385a6b428ed6f0c9fb98c2103076b8c7c876401eeaaf79 tfg_0.4.0_windows_amd64.zip", + "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", +} + +// 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 ( + windowWinget = "winget/manifests/d/DonislawDev/TestingFilesGenerator/0.4.0/DonislawDev.TestingFilesGenerator" + cliWinget = "winget/manifests/d/DonislawDev/TestingFilesGenerator/CLI/0.4.0/DonislawDev.TestingFilesGenerator.CLI" + windowChoco = "chocolatey/testing-files-generator/" + cliChoco = "chocolatey/testing-files-generator-cli/" +) + +// rendering is one run of the renderer: where it was told to write, what it +// said, and how it exited. +type rendering struct { + out string + said string + code int +} + +func packagingScript(t *testing.T) string { + t.Helper() + return filepath.Join(repoRoot(t), ".github", "scripts", "build_packages.py") +} + +// sumsFile writes a checksum file and returns its path. +func sumsFile(t *testing.T, body []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), "verify-SHA256SUMS.txt") + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatalf("writing the checksum file: %v", err) + } + return path +} + +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 { + 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", sumsFile(t, sums), "--out", out) + cmd.Dir = repoRoot(t) + said, err := cmd.CombinedOutput() + code := 0 + var exit *exec.ExitError + if errors.As(err, &exit) { + code = exit.ExitCode() + } else if err != nil { + t.Fatalf("the renderer could not be started: %v", err) + } + return rendering{out: out, said: string(said), code: code} +} + +// renderedPackages renders the fixture release and returns every file it +// 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")) + if r.code != 0 { + t.Fatalf("the renderer refused a real release (exit %d):\n%s", r.code, r.said) + } + files := map[string]string{} + err := filepath.WalkDir(r.out, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + body, err := os.ReadFile(path) + rel, _ := filepath.Rel(r.out, path) + files[filepath.ToSlash(rel)] = string(body) + return err + }) + if err != nil || len(files) == 0 { + t.Fatalf("reading what the renderer wrote: %v (%d files)", err, len(files)) + } + return files +} + +// scriptLines drops blank lines and the comments of a PowerShell or YAML file, +// so a guard reads what runs rather than what explains it. The first version +// of a guard like this in the sibling project passed on a script whose only +// mention of the flag was the comment saying why it was there. +func scriptLines(text string) []string { + var code []string + for _, line := range strings.Split(text, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed != "" && !strings.HasPrefix(trimmed, "#") { + code = append(code, line) + } + } + return code +} + +func anyLine(lines []string, pattern string) bool { + re := regexp.MustCompile(pattern) + for _, line := range lines { + if re.MatchString(line) { + return true + } + } + return false +} + +// packagingTemplates returns every template in packaging/, by its path under +// the repository. +func packagingTemplates(t *testing.T) map[string]string { + t.Helper() + root := repoRoot(t) + found := map[string]string{} + err := filepath.WalkDir(filepath.Join(root, "packaging"), func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(path, ".in") { + return err + } + body, err := os.ReadFile(path) + rel, _ := filepath.Rel(root, path) + found[filepath.ToSlash(rel)] = string(body) + return err + }) + if err != nil || len(found) == 0 { + t.Fatalf("reading packaging/: %v (%d templates)", err, len(found)) + } + return found +} + +// The renderer writes these files and no others. The identifiers in them are +// public for good: a package renamed is a new package, and everybody who has +// the old one keeps it. +func TestThePackagesRenderFromAReleasesChecksums(t *testing.T) { + files := renderedPackages(t) + want := []string{ + windowChoco + "testing-files-generator.nuspec", + windowChoco + "tools/chocolateybeforemodify.ps1", + windowChoco + "tools/chocolateyinstall.ps1", + windowChoco + "tools/chocolateyuninstall.ps1", + cliChoco + "testing-files-generator-cli.nuspec", + cliChoco + "tools/chocolateybeforemodify.ps1", + cliChoco + "tools/chocolateyinstall.ps1", + windowWinget + ".installer.yaml", + windowWinget + ".locale.en-US.yaml", + windowWinget + ".yaml", + cliWinget + ".installer.yaml", + cliWinget + ".locale.en-US.yaml", + cliWinget + ".yaml", + } + var got []string + for name := range files { + got = append(got, name) + } + sort.Strings(got) + sort.Strings(want) + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("the renderer wrote a different set of files than the two packages in two "+ + "feeds are made of\n got: %v\nwant: %v", got, want) + } +} + +// A version typed into a template is a version that stays behind when the +// next release is rendered, and a manifest with a stale version still parses. +// The one number allowed is the manifest schema, which is not a release. +func TestNoPackageSourceCarriesAVersionNumber(t *testing.T) { + version := regexp.MustCompile(`\b\d+\.\d+\.\d+\b`) + for name, text := range packagingTemplates(t) { + if found := version.FindString(text); found != "" { + t.Errorf("%s carries the version %s. Versions come from the tag being packaged", name, found) + } + } +} + +// Both WinGet packages keep the program beside the files it came with. +// +// Without ArchiveBinariesDependOnPath WinGet reaches the program through a +// symbolic link, and the window started that way looks for its software +// renderer next to the link, where it is not. With it, the package's folder +// 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", + } { + 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) + } + if !anyLine(code, `^- RelativeFilePath: `+regexp.QuoteMeta(program)+`$`) { + t.Errorf("%s does not install %s, the program the archive holds", manifest, program) + } + if anyLine(code, `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. +func TestEachWingetPackageOffersTheArchitecturesTheReleaseBuilds(t *testing.T) { + files := renderedPackages(t) + for manifest, want := range map[string][]string{ + windowWinget + ".installer.yaml": {"x64 E744F4FF793407218FAC9EEC139DD49C03264AB636DB2057BA2963D0515625AD"}, + cliWinget + ".installer.yaml": { + "x64 16FE56C7B3F2A13D22385A6B428ED6F0C9FB98C2103076B8C7C876401EEAAF79", + "arm64 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]) + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("%s offers %v, and the release built %v", manifest, got, want) + } + } +} + +// Every archive a package downloads is one the release workflow builds. +// +// 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. +func TestThePackagesDownloadWhatTheReleaseBuilds(t *testing.T) { + release := withoutYamlComments(workflowText(t, "release.yml")) + bases := regexp.MustCompile(`base="(tfg(?:-gui)?)_\$\{version\}_\$\{label\}_\$\{arch\}"`). + FindAllStringSubmatch(release, -1) + if len(bases) != 2 { + 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$`) + address := regexp.MustCompile(`https://github\.com/[^/\s']+/[^/\s']+/releases/download/v0\.4\.0/([^\s']+)`) + seen := 0 + for name, text := range renderedPackages(t) { + for _, m := range address.FindAllStringSubmatch(text, -1) { + seen++ + if !built.MatchString(m[1]) { + t.Errorf("%s downloads %s, which is not a name release.yml builds", name, m[1]) + } + } + } + if seen < 5 { + t.Errorf("found %d download address(es) in the packages, and they hold five - this "+ + "guard is not reading what it thinks it reads", seen) + } +} + +// A Chocolatey package that downloads has to check what it downloaded against +// the release's own checksum, or it installs whatever the address answers. +func TestTheChocolateyPackagesCheckWhatTheyDownload(t *testing.T) { + files := renderedPackages(t) + for script, sum := range map[string]string{ + windowChoco + "tools/chocolateyinstall.ps1": "e744f4ff793407218fac9eec139dd49c03264ab636db2057ba2963d0515625ad", + cliChoco + "tools/chocolateyinstall.ps1": "16fe56c7b3f2a13d22385a6b428ed6f0c9fb98c2103076b8c7c876401eeaaf79", + } { + code := scriptLines(files[script]) + if !anyLine(code, `^\s*-Checksum64 '`+sum+`' `) { + t.Errorf("%s does not check the download against the release's checksum %s", script, sum) + } + if !anyLine(code, `^\s*-ChecksumType64 'sha256' `) { + t.Errorf("%s does not say the checksum is sha256", script) + } + } +} + +// Not raw.githubusercontent.com, not github.com/.../raw, not a branch. +// +// Chocolatey's moderation refuses the first two alike - the sibling project's +// 0.5.0 was held over it - and an icon on a branch keeps changing under a +// package that is already approved and out of reach. +func TestTheChocolateyIconIsAPinnedCdnAddress(t *testing.T) { + files := renderedPackages(t) + for _, nuspec := range []string{ + windowChoco + "testing-files-generator.nuspec", + cliChoco + "testing-files-generator-cli.nuspec", + } { + found := regexp.MustCompile(`(.*?)`).FindStringSubmatch(files[nuspec]) + if found == nil { + t.Errorf("%s carries no icon", nuspec) + continue + } + if !strings.HasPrefix(found[1], "https://cdn.jsdelivr.net/gh/") { + t.Errorf("%s serves its icon from %s, which is not a CDN moderation accepts", nuspec, found[1]) + } + if !strings.Contains(found[1], "@"+packagingTag+"/") { + t.Errorf("%s does not pin its icon to %s: %s", nuspec, packagingTag, found[1]) + } + } +} + +// The window's Chocolatey package starts it without holding the terminal, and +// its shortcut starts it where the person can write. +// +// 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. +func TestTheWindowPackageStartsWhereAPersonCanWrite(t *testing.T) { + files := renderedPackages(t) + install := scriptLines(files[windowChoco+"tools/chocolateyinstall.ps1"]) + if !anyLine(install, `New-Item -ItemType File -Path "\$exe\.gui"`) { + t.Error("the window package makes no .gui file, so typing tfg-gui blocks the terminal " + + "until the window closes") + } + if anyLine(scriptLines(files[cliChoco+"tools/chocolateyinstall.ps1"]), `\.gui`) { + t.Error("the command line package makes a .gui file, so its shim would return before " + + "the program finished and a script would read no exit code") + } + if !anyLine(install, `^\s*-WorkingDirectory '%USERPROFILE%' `) { + 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") + } + 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 " + + "into this package, so it can take a shortcut that is somebody else's") + } +} + +// No package script ends the program. Owner's decision of 2026-09-25: a run in +// progress may be halfway through a set of files, and cutting it leaves files +// with no manifest to say what they are. +func TestNoPackageScriptEndsTheProgram(t *testing.T) { + kill := regexp.MustCompile(`(?i)\b(stop-process|taskkill|kill)\b|\.Kill\(`) + checked := 0 + for name, text := range packagingTemplates(t) { + if !strings.HasSuffix(name, ".ps1.in") { + continue + } + checked++ + for _, line := range scriptLines(text) { + if kill.MatchString(line) { + t.Errorf("%s ends a process: %s", name, strings.TrimSpace(line)) + } + } + } + if checked < 4 { + t.Errorf("read %d package script(s), and there are four", checked) + } +} + +// Every package script is ASCII. Chocolatey runs them with Windows PowerShell +// 5.1, which reads a file without a byte order mark in the machine's ANSI code +// page, so anything else arrives changed. +func TestEveryPackageScriptIsASCII(t *testing.T) { + for name, text := range renderedPackages(t) { + if !strings.HasSuffix(name, ".ps1") { + continue + } + for number, line := range strings.Split(text, "\n") { + for _, r := range line { + if r > 127 { + t.Errorf("%s line %d is not ASCII: %q", name, number+1, line) + break + } + } + } + } +} + +// What a person reads in the feeds follows the punctuation rule (D17): a flat +// hyphen, and no semicolons. The guard over the program's own text reads Go +// files only, so this one reads the packages. +func TestThePackageTextFollowsThePunctuationRule(t *testing.T) { + forbidden := []rune{';', rune(0x2013), rune(0x2014)} + read := 0 + for name, text := range renderedPackages(t) { + var shown []string + switch { + case strings.HasSuffix(name, ".locale.en-US.yaml"), strings.HasSuffix(name, ".nuspec"): + shown = strings.Split(text, "\n") + case strings.HasSuffix(name, ".ps1"): + for _, line := range scriptLines(text) { + if regexp.MustCompile(`^\s*Write-(Host|Warning) `).MatchString(line) { + shown = append(shown, line) + } + } + } + read += len(shown) + for _, line := range shown { + if strings.ContainsAny(line, string(forbidden)) { + t.Errorf("%s shows a person %q, which breaks the punctuation rule", name, strings.TrimSpace(line)) + } + } + } + if read == 0 { + t.Error("read no line a person sees - this guard is reading nothing") + } +} + +// The two values the renderer holds a copy of agree with the Go originals. +func TestThePackagesNameTheProductAndLicenceTheProgramDoes(t *testing.T) { + script := readRepoFile(t, ".github/scripts/build_packages.py") + for _, pair := range []struct{ what, ours, theirs, file string }{ + {"the product name", `(?m)^APP_NAME = "([^"]+)"$`, `(?m)^\s+Name:\s+"([^"]+)",$`, "internal/gui/run_cgo.go"}, + {"the licence", `(?m)^LICENCE = "([^"]+)"$`, `(?m)^const ourLicence = "([^"]+)"$`, "internal/legal/spdx.go"}, + } { + ours := regexp.MustCompile(pair.ours).FindStringSubmatch(script) + theirs := regexp.MustCompile(pair.theirs).FindStringSubmatch(readRepoFile(t, pair.file)) + if ours == nil || theirs == nil { + t.Errorf("could not read %s from the renderer and %s", pair.what, pair.file) + continue + } + if ours[1] != theirs[1] { + t.Errorf("the packages give %s as %q and %s as %q", pair.what, ours[1], pair.file, theirs[1]) + } + } +} + +// The package sources are in git. Chocolatey's moderation asks packageSourceUrl +// to point at them, the job in ci.yml renders them on a fresh clone, and the +// point of a package source is that somebody else can see what the package +// does to their machine. A missing file shows up on somebody else's clone. +func TestThePackageSourcesAreTrackedByGit(t *testing.T) { + tracked := map[string]bool{} + for _, name := range strings.Fields(gitOutput(t, "ls-files", "packaging", ".github/scripts/build_packages.py")) { + tracked[name] = true + } + want := []string{".github/scripts/build_packages.py", "packaging/README.md"} + for name := range packagingTemplates(t) { + want = append(want, name) + } + for _, name := range want { + if !tracked[name] { + t.Errorf("%s is not tracked by git, so a fresh clone and the moderators both find nothing", name) + } + } +} diff --git a/internal/guard/packagingrefusal_test.go b/internal/guard/packagingrefusal_test.go new file mode 100644 index 0000000..3929198 --- /dev/null +++ b/internal/guard/packagingrefusal_test.go @@ -0,0 +1,205 @@ +package guard + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// The inputs that would render, look fine, and be wrong - and the renderer +// has to refuse each one out loud rather than hand a feed a package that +// installs the wrong thing. Every refusal leaves nothing behind: the packages +// are written into a directory beside the destination and renamed onto it +// only when every one of them rendered, so a refusal or a Ctrl+C half way +// 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") + otherRelease := []byte(strings.ReplaceAll(string(fixtureSums()), "0.4.0", "0.3.0")) + unreleased := []byte(strings.ReplaceAll(string(fixtureSums()), "0.4.0", "9.9.9")) + + // In a directory of its own, because the guard lists the destination's + // parent before and after - and the parent of t.TempDir() is the system's + // temporary directory, which other processes change while this runs. + occupied := filepath.Join(t.TempDir(), "packages") + if err := os.MkdirAll(occupied, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(occupied, "keep.txt"), []byte("somebody's"), 0o600); err != nil { + t.Fatal(err) + } + + // A destination inside the repository must not exist before this runs, + // and whatever a broken renderer writes there is taken away again - under + // a mutation that removes the refusal, the renderer writes into the real + // tree, and the tree is not this guard's to leave changed. + inside := filepath.Join(repoRoot(t), "packaging-guard-out") + if _, err := os.Stat(inside); err == nil { + t.Fatalf("%s already exists, so this guard cannot tell what the renderer wrote there", inside) + } + t.Cleanup(func() { _ = os.RemoveAll(inside) }) + + for _, c := range []struct { + what, tag string + sums []byte + out string + says string + }{ + {"the checksum file of another release", packagingTag, otherRelease, "", "tfg-gui_0.3.0_windows_amd64.zip"}, + {"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 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"}, + } { + out := c.out + if out == "" { + out = filepath.Join(t.TempDir(), "packages") + } + before := entriesOf(t, filepath.Dir(out)) + r := renderPackages(t, c.tag, c.sums, out) + if r.code != 1 { + t.Errorf("%s: the renderer exited %d, and a refusal exits 1:\n%s", c.what, r.code, r.said) + continue + } + // A crash exits 1 too, and its traceback may well contain the file + // name this looks for - so a refusal is only a refusal when the + // renderer said it on purpose. + if !strings.HasPrefix(r.said, "build_packages: ") || strings.Contains(r.said, "Traceback") { + t.Errorf("%s: the renderer crashed instead of refusing, and a crash tells a person "+ + "nothing about what to fix:\n%s", c.what, r.said) + continue + } + if !strings.Contains(r.said, c.says) { + t.Errorf("%s: the refusal does not say %q, so a person reading it cannot tell what "+ + "to fix:\n%s", c.what, c.says, r.said) + } + if after := entriesOf(t, filepath.Dir(out)); after != before { + t.Errorf("%s: the refusal left something behind beside the destination\nbefore: %s\n after: %s", + c.what, before, after) + } + } + + // And the same inputs, put right, render - so every refusal above is about + // its one input and not about something the fixture always gets wrong. + if r := renderPackages(t, packagingTag, fixtureSums(), filepath.Join(t.TempDir(), "packages")); r.code != 0 { + t.Errorf("the fixture itself is refused (exit %d), so the refusals above prove nothing:\n%s", r.code, r.said) + } +} + +// 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 { + t.Helper() + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return "(absent)" + } + if err != nil { + t.Fatalf("reading %s: %v", dir, err) + } + var names []string + for _, e := range entries { + names = append(names, e.Name()) + } + return strings.Join(names, ", ") +} + +// sha256sum writes ' ' in text mode and ' *' in +// binary mode, and a file saved on Windows may carry a byte order mark and +// CRLF. The star is not part of the name, and none of it may reach an address +// or a checksum the feed compares with. +func TestEverySpellingOfAChecksumFileRendersTheSamePackages(t *testing.T) { + var lines []string + for _, line := range packagingSums { + hash, name, _ := strings.Cut(line, " ") + lines = append(lines, strings.ToUpper(hash)+" *"+name) + } + bom := []byte{0xEF, 0xBB, 0xBF} + sums := append(bom, []byte(strings.Join(lines, "\r\n")+"\r\n")...) + + r := renderPackages(t, packagingTag, sums, filepath.Join(t.TempDir(), "packages")) + if r.code != 0 { + t.Fatalf("a checksum file written in binary mode on Windows is refused (exit %d):\n%s", r.code, r.said) + } + plain := renderedPackages(t) + for name, want := range plain { + got, err := os.ReadFile(filepath.Join(r.out, filepath.FromSlash(name))) + if err != nil { + t.Errorf("%s was not written from the other spelling: %v", name, err) + continue + } + if string(got) != want { + t.Errorf("%s differs between two spellings of the same checksums", name) + } + } +} + +// The checks the renderer makes on every value it puts into a file, asked +// directly. Each case would render, and each would hand the feed a file that +// means something else - so each has to be refused, and the one clean case +// has to render, or the probe cannot tell the two apart. +func TestTheRendererRefusesAValueThatWouldBreakItsFile(t *testing.T) { + probe := ` +import sys +sys.path.insert(0, sys.argv[1]) +import build_packages as bp + +table = {"OK": "fine", "QUOTE": "it's", "MARKUP": "a & b", "COLON": "key: value", + "HASH": "a #b", "LINES": "one\ntwo"} +for label, text, name in [ + ("unknown placeholder", "x {{NOT_A_KEY}} y", "chocolatey/tools/a.ps1"), + ("quote in a script", "Write-Host '{{QUOTE}}'", "chocolatey/tools/a.ps1"), + ("markup in the nuspec", "{{MARKUP}}", "chocolatey/package.nuspec"), + ("colon in YAML", "Short: {{COLON}}", "winget/locale.en-US.yaml"), + ("comment in YAML", "Short: {{HASH}}", "winget/locale.en-US.yaml"), + ("several lines inside a line", "x {{LINES}} y", "winget/locale.en-US.yaml"), + ("clean", "Short: {{OK}}", "winget/locale.en-US.yaml"), +]: + try: + bp.render(text, table, name) + print(label + ": RENDERED") + except SystemExit: + print(label + ": REFUSED") + +orig = bp.values +bp.values = lambda *a: dict(orig(*a), NOBODY_USES_THIS="x") +try: + bp.build(sys.argv[2], sys.argv[3], sys.argv[4]) + print("unused value: RENDERED") +except SystemExit as refusal: + print("unused value: REFUSED" if "NOBODY_USES_THIS" in str(refusal) else "unused value: " + str(refusal)) +` + dir := t.TempDir() + script := filepath.Join(dir, "probe.py") + if err := os.WriteFile(script, []byte(probe), 0o600); err != nil { + t.Fatal(err) + } + // The interpreter is the one found on PATH, the script is the probe this + // guard just wrote, and every argument is a value it chose. + // nosemgrep: go.lang.security.audit.dangerous-exec-command.dangerous-exec-command + cmd := exec.Command(pythonForGate(t), script, filepath.Dir(packagingScript(t)), packagingTag, + sumsFile(t, fixtureSums()), filepath.Join(dir, "packages")) + said, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("the probe failed: %v\n%s", err, said) + } + answers := map[string]string{} + for _, m := range regexp.MustCompile(`(?m)^(.+): (RENDERED|REFUSED)\r?$`).FindAllStringSubmatch(string(said), -1) { + answers[m[1]] = m[2] + } + for _, label := range []string{"unknown placeholder", "quote in a script", "markup in the nuspec", + "colon in YAML", "comment in YAML", "several lines inside a line", "unused value"} { + if answers[label] != "REFUSED" { + t.Errorf("%s: the renderer answered %q, and it has to refuse:\n%s", label, answers[label], said) + } + } + if answers["clean"] != "RENDERED" { + t.Errorf("the clean case was not rendered (%q), so the probe cannot tell a refusal from "+ + "a failure:\n%s", answers["clean"], said) + } +} diff --git a/internal/guard/readmesettings_test.go b/internal/guard/readmesettings_test.go index 946ebf0..3903005 100644 --- a/internal/guard/readmesettings_test.go +++ b/internal/guard/readmesettings_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" "testing" @@ -105,7 +106,9 @@ func TestTheReadmeSettingsTableAgreesWithTheRegistry(t *testing.T) { // numbers guard measured the general form of it on 2026-08-05, raised 43 // findings and found most of them false, because "24 formats" and "25 formats" // and "150 formats" answer three different questions in this repository. The -// count in that one sentence stays on the reader. +// count in that one sentence stays on the reader. The counts the README writes +// in DIGITS are a narrower question with one answer, and the guard below asks +// it. func TestTheReadmeListsEveryFormatItShips(t *testing.T) { body, err := os.ReadFile(filepath.Join(repoRoot(t), "README.md")) if err != nil { @@ -143,6 +146,37 @@ func TestTheReadmeListsEveryFormatItShips(t *testing.T) { } } +// Every count of formats the README writes in digits is the number the +// program registers. +// +// Measured 2026-09-25: the README said "24 formats" and "24 real formats" +// while the binary shipped 26, a release after yaml and toml arrived. The +// same release had moved the count in words above the table to "Twenty six", +// by hand, and nothing compared the other two with anything - the site gets +// its number from the registry, the README cannot. So this asks the one +// question those phrases answer, and only for the README, where a digit next +// to "formats" means one thing. +func TestTheReadmeCountsTheFormatsItShips(t *testing.T) { + body, err := os.ReadFile(filepath.Join(repoRoot(t), "README.md")) + if err != nil { + t.Fatalf("reading the README: %v", err) + } + shipped := len(format.All()) + if shipped == 0 { + t.Fatal("no format is registered - this guard would pass without checking anything") + } + counts := regexp.MustCompile(`\b(\d+) (real )?formats\b`).FindAllStringSubmatch(string(body), -1) + if len(counts) == 0 { + t.Fatal("the README states no count of formats in digits, so this guard reads nothing - " + + "if the counts were reworded on purpose, retire it") + } + for _, c := range counts { + if c[1] != strconv.Itoa(shipped) { + t.Errorf("the README says %q and the program ships %d formats", c[0], shipped) + } + } +} + // listOrNone words a list the way the table does, so a failure can be compared // against the line it is about without translating between two spellings. func listOrNone(names []string) string { diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..cc7acb4 --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,75 @@ +# Package sources: WinGet and Chocolatey + +These are **templates**, not packages. Every `{{PLACEHOLDER}}` is filled by +`.github/scripts/build_packages.py` from the one place that owns the value: the +version from the release tag, the checksums from that release's +`verify-SHA256SUMS.txt`, the addresses from `go.mod` and `web/public/CNAME`, the +release date from `CHANGELOG.md`. The two values the renderer keeps a copy of, +the product name and the licence, are held to their Go originals by a guard. + + python .github/scripts/build_packages.py --tag v0.4.0 \ + --sums verify-SHA256SUMS.txt --out + +## Four packages, two per feed + +| | the window | the command line | +|---|---|---| +| 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 | +| command | `tfg-gui` | `tfg` | + +The window does not need the command line - it carries the same engine - so +each package stands alone. A build agent takes the command line without the +window, and one package waiting in moderation does not hold the other. + +A template named `name..ext.in` belongs to one kind of package only +(`window` or `cli`). One named `name.ext.in` belongs to every package. + +## What each package has to get right + +**WinGet.** `ArchiveBinariesDependOnPath: true`, in both. Without it WinGet reaches +the program through a symbolic link, and the window started that way looks for +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. + +**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 +unpacked into a folder of its own under `tools`. The window gets an empty +`tfg-gui.exe.gui` beside it, without which Chocolatey's shim waits for the window +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: +moderation refuses `raw.githubusercontent.com` and `github.com/.../raw` alike, +and an icon on a branch would keep changing under an approved package. + +**Neither package ends a running program.** `chocolateybeforemodify.ps1` says when +the program is still running from the package, and leaves closing it to the +person - a run in progress may be halfway through a set of files, and cutting it +would leave files with no manifest to say what they are. + +## Submitting + +Submitting is a person's step and stays one. Nothing here is wired into a +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. +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. +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 + `manifests/d/DonislawDev/TestingFilesGenerator/CLI//`. + +A published release asset is never replaced. Every package version names its +archive by address and checksum, so a replaced file breaks every install of it. diff --git a/packaging/chocolatey/package.nuspec.in b/packaging/chocolatey/package.nuspec.in new file mode 100644 index 0000000..7a9febb --- /dev/null +++ b/packaging/chocolatey/package.nuspec.in @@ -0,0 +1,32 @@ + + + + + {{CHOCO_ID}} + {{VERSION}} + {{PACKAGE_SOURCE_URL}} + {{PUBLISHER}} + {{TITLE}} + {{PUBLISHER}} + {{PROJECT_URL}} + {{ICON_URL}} + {{LICENSE_URL}} + false + {{REPO_URL}} + {{DOCS_URL}} + {{REPO_URL}}/issues + {{CHOCO_TAGS}} + {{SHORT_DESCRIPTION}} + + {{RELEASE_NOTES_URL}} + + + + + diff --git a/packaging/chocolatey/tools/chocolateybeforemodify.ps1.in b/packaging/chocolatey/tools/chocolateybeforemodify.ps1.in new file mode 100644 index 0000000..88b79ba --- /dev/null +++ b/packaging/chocolatey/tools/chocolateybeforemodify.ps1.in @@ -0,0 +1,32 @@ +# Rendered by .github/scripts/build_packages.py - do not edit the generated copy. +# +# Chocolatey runs this from the INSTALLED package before an upgrade or an +# uninstall. It says so when the program is still running from this package, +# because Windows does not let the files of a running program be replaced, and +# a person should hear why before the operation that follows complains. +# +# It never ends the program. That is the owner's decision of 2026-09-25: a run +# in progress may be halfway through writing a set of files, and cutting it +# leaves files without the manifest that describes them. A person closing it +# is the only safe way. And nothing here may throw - this runs BEFORE the +# operation the person asked for, which a failed check must not stop. +# +# Windows PowerShell 5.1 is what Chocolatey runs package scripts with, so this +# file is ASCII and uses no PowerShell 7 syntax. +$ErrorActionPreference = 'Continue' +$toolsDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$exe = Join-Path $toolsDir '{{PROGRAM}}\{{EXE}}' + +$running = @() +try { + # A process of another account answers with no path, so it cannot be told + # apart and is left out. This is a courtesy, not a lock. + $running = @(Get-Process -Name '{{PROGRAM}}' -ErrorAction SilentlyContinue | + Where-Object { $_.Path -and ($_.Path -ieq $exe) }) +} catch { + $running = @() +} + +if ($running.Count -gt 0) { + Write-Warning '{{TITLE}} is running from this package. Close it before you upgrade or remove the package. Windows does not let the files of a running program be replaced.' +} diff --git a/packaging/chocolatey/tools/chocolateyinstall.cli.ps1.in b/packaging/chocolatey/tools/chocolateyinstall.cli.ps1.in new file mode 100644 index 0000000..b3d2045 --- /dev/null +++ b/packaging/chocolatey/tools/chocolateyinstall.cli.ps1.in @@ -0,0 +1,23 @@ +# Rendered by .github/scripts/build_packages.py - do not edit the generated copy. +# +# The package DOWNLOADS the release archive instead of carrying it: the archive +# is the same file, checksum and all, that the release page publishes, and a +# package with no binaries inside owes the moderators no VERIFICATION.txt. +# +# Windows PowerShell 5.1 is what Chocolatey runs package scripts with, so this +# file is ASCII and uses no PowerShell 7 syntax. +$ErrorActionPreference = 'Stop' +$toolsDir = Split-Path -Parent $MyInvocation.MyCommand.Definition + +# Into a folder of its own, the same shape the window package uses, so the two +# packages read alike. Chocolatey then makes a shim for the program, which is +# what puts the command on PATH. +Install-ChocolateyZipPackage ` + -PackageName '{{CHOCO_ID}}' ` + -Url64bit '{{URL_AMD64}}' ` + -Checksum64 '{{SHA256_AMD64}}' ` + -ChecksumType64 'sha256' ` + -UnzipLocation (Join-Path $toolsDir '{{PROGRAM}}') + +Write-Host '' +Write-Host 'Type tfg help to see the commands.' diff --git a/packaging/chocolatey/tools/chocolateyinstall.window.ps1.in b/packaging/chocolatey/tools/chocolateyinstall.window.ps1.in new file mode 100644 index 0000000..d668791 --- /dev/null +++ b/packaging/chocolatey/tools/chocolateyinstall.window.ps1.in @@ -0,0 +1,48 @@ +# Rendered by .github/scripts/build_packages.py - do not edit the generated copy. +# +# The package DOWNLOADS the release archive instead of carrying it: the archive +# is the same file, checksum and all, that the release page publishes, and a +# package with no binaries inside owes the moderators no VERIFICATION.txt. +# +# Windows PowerShell 5.1 is what Chocolatey runs package scripts with, so this +# file is ASCII and uses no PowerShell 7 syntax. +$ErrorActionPreference = 'Stop' +$toolsDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$programDir = Join-Path $toolsDir '{{PROGRAM}}' + +# Into a folder of its own, exactly as the archive holds it: the window looks +# for the software renderer in the opengl folder beside itself, and finds it +# only when the two stay together. +Install-ChocolateyZipPackage ` + -PackageName '{{CHOCO_ID}}' ` + -Url64bit '{{URL_AMD64}}' ` + -Checksum64 '{{SHA256_AMD64}}' ` + -ChecksumType64 'sha256' ` + -UnzipLocation $programDir + +$exe = Join-Path $programDir '{{EXE}}' + +# Chocolatey makes a shim for every program in the package, and the shim waits +# for the program to exit unless an empty file named after it with .gui on the +# end lies beside it (ShimGenerationService.cs, read 2026-09-25). Without this +# the terminal that typed the command stays blocked until the window closes. +New-Item -ItemType File -Path "$exe.gui" -Force | Out-Null + +# A Start menu entry for everyone on the machine. The working directory is the +# person's own profile, spelled as the variable, because the window offers a +# tfg-out folder under the directory it was started from - and the program's +# own folder here is one an ordinary account cannot write to. The variable is +# 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. +$shortcut = Join-Path ([Environment]::GetFolderPath('CommonPrograms')) '{{TITLE}}.lnk' +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.' +Write-Host 'Started from the Start menu, it offers a tfg-out folder in your user profile.' diff --git a/packaging/chocolatey/tools/chocolateyuninstall.window.ps1.in b/packaging/chocolatey/tools/chocolateyuninstall.window.ps1.in new file mode 100644 index 0000000..0df8213 --- /dev/null +++ b/packaging/chocolatey/tools/chocolateyuninstall.window.ps1.in @@ -0,0 +1,41 @@ +# Rendered by .github/scripts/build_packages.py - do not edit the generated copy. +# +# Chocolatey removes the files and the shims it made itself. The Start menu +# shortcut was made by the install script, so Chocolatey does not know about +# it, and removing it is this script's job. +# +# Only a shortcut that points into THIS package is removed. A person may keep a +# shortcut of their own under the same name, pointing somewhere else, and an +# uninstall does not get to decide it is ours. Nothing here throws either: a +# missing shortcut is not a reason to leave the package half removed. +# +# Windows PowerShell 5.1 is what Chocolatey runs package scripts with, so this +# file is ASCII and uses no PowerShell 7 syntax. +$ErrorActionPreference = 'Continue' +$toolsDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$shortcut = Join-Path ([Environment]::GetFolderPath('CommonPrograms')) '{{TITLE}}.lnk' + +if (-not (Test-Path -LiteralPath $shortcut)) { + Write-Host 'There was no Start menu shortcut to remove.' + return +} + +$target = '' +try { + $target = (New-Object -ComObject WScript.Shell).CreateShortcut($shortcut).TargetPath +} catch { + $target = '' +} + +# A shortcut to a shell item - File Explorer, the Recycle Bin - answers with an +# empty path rather than none: 8 of the 447 Start menu shortcuts on the machine +# this was written on, and 0 answered with no value at all (measured +# 2026-09-25). So the empty answer is the one that needs its own sentence. +if ($target.StartsWith($toolsDir + '\', [StringComparison]::OrdinalIgnoreCase)) { + Remove-Item -LiteralPath $shortcut -Force + Write-Host 'Removed the Start menu shortcut.' +} elseif ($target) { + Write-Host "Left the Start menu shortcut alone. It points at $target, not at this package." +} else { + Write-Host 'Left the Start menu shortcut alone. It points at no file, so it is not from this package.' +} diff --git a/packaging/winget/installer.yaml.in b/packaging/winget/installer.yaml.in new file mode 100644 index 0000000..5f264db --- /dev/null +++ b/packaging/winget/installer.yaml.in @@ -0,0 +1,24 @@ +# 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}} +InstallerType: zip +NestedInstallerType: portable +NestedInstallerFiles: +- RelativeFilePath: {{EXE}} +# Load-bearing, in both packages. Without it WinGet reaches the program 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). The command line does +# not need it, but without it the shape depends on the machine: a link where +# symbolic links are allowed, the folder on PATH where they are not. One shape +# is one thing to test. +ArchiveBinariesDependOnPath: true +ReleaseDate: {{RELEASE_DATE}} +Installers: +{{WINGET_INSTALLERS}} +ManifestType: installer +ManifestVersion: {{WINGET_SCHEMA}} diff --git a/packaging/winget/locale.en-US.yaml.in b/packaging/winget/locale.en-US.yaml.in new file mode 100644 index 0000000..eee61d6 --- /dev/null +++ b/packaging/winget/locale.en-US.yaml.in @@ -0,0 +1,24 @@ +# Rendered by .github/scripts/build_packages.py - do not edit the generated copy. +# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.{{WINGET_SCHEMA}}.schema.json + +PackageIdentifier: {{WINGET_ID}} +PackageVersion: {{VERSION}} +PackageLocale: en-US +Publisher: {{PUBLISHER}} +PublisherUrl: {{REPO_URL}} +PublisherSupportUrl: {{REPO_URL}}/issues +PackageName: {{TITLE}} +PackageUrl: {{PROJECT_URL}} +License: {{LICENSE}} +LicenseUrl: {{LICENSE_URL}} +ShortDescription: {{SHORT_DESCRIPTION}} +Description: |- + {{DESCRIPTION}} + + {{WINGET_HOW_TO_START}} +Moniker: {{MONIKER}} +Tags: +{{WINGET_TAGS}} +ReleaseNotesUrl: {{RELEASE_NOTES_URL}} +ManifestType: defaultLocale +ManifestVersion: {{WINGET_SCHEMA}} diff --git a/packaging/winget/version.yaml.in b/packaging/winget/version.yaml.in new file mode 100644 index 0000000..38e4d83 --- /dev/null +++ b/packaging/winget/version.yaml.in @@ -0,0 +1,8 @@ +# Rendered by .github/scripts/build_packages.py - do not edit the generated copy. +# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.{{WINGET_SCHEMA}}.schema.json + +PackageIdentifier: {{WINGET_ID}} +PackageVersion: {{VERSION}} +DefaultLocale: en-US +ManifestType: version +ManifestVersion: {{WINGET_SCHEMA}}