Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 87 additions & 27 deletions .github/scripts/build_packages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a directory outside the repository>

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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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


Expand All @@ -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,
Expand All @@ -278,14 +330,15 @@ 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"),
"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):
Expand Down Expand Up @@ -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.<kind>.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):
Expand Down Expand Up @@ -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)

Expand All @@ -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)
Expand Down Expand Up @@ -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))
Expand Down
48 changes: 45 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -537,6 +541,44 @@ jobs:
if ($LASTEXITCODE -ne 0) { throw "the third uninstall exited $LASTEXITCODE" }
Check (-not (Test-Path $shortcut)) "removing the window takes its own shortcut"

# A shortcut of another install already there when the package comes
# - the program's Windows installer makes one under this very name -
# is left alone by the install, and so by the uninstall. So is one to
# a shell item, which points at no file. One whose target is gone
# belongs to nobody, and the install replaces it.
$notepad = Join-Path $env:WINDIR 'notepad.exe'
$other = $shell.CreateShortcut($shortcut)
$other.TargetPath = $notepad
$other.Save()
$said = (choco.exe install testing-files-generator --source $feed --yes --no-progress) -join "`n"
if ($LASTEXITCODE -ne 0) { throw "the fourth install exited $LASTEXITCODE" }
Check ($shell.CreateShortcut($shortcut).TargetPath -eq $notepad) "the install leaves a shortcut of another install alone"
Check ($said -match 'It points at .*notepad\.exe, not at this package') "and the install says where it points"
choco.exe uninstall testing-files-generator --yes --no-progress
if ($LASTEXITCODE -ne 0) { throw "the fourth uninstall exited $LASTEXITCODE" }
Check ($shell.CreateShortcut($shortcut).TargetPath -eq $notepad) "and so does the uninstall"
Remove-Item -LiteralPath $shortcut -Force
$other = $shell.CreateShortcut($shortcut)
$other.TargetPath = '::{20D04FE0-3AEA-1069-A2D8-08002B30309D}'
$other.Save()
$said = (choco.exe install testing-files-generator --source $feed --yes --no-progress) -join "`n"
if ($LASTEXITCODE -ne 0) { throw "the shell item install exited $LASTEXITCODE" }
Check ((Test-Path $shortcut) -and $shell.CreateShortcut($shortcut).TargetPath -eq '') "the install leaves a shortcut to a shell item alone"
Check ($said -match 'It points at no file, so it is not from this package') "and the install says so in a whole sentence"
choco.exe uninstall testing-files-generator --yes --no-progress
if ($LASTEXITCODE -ne 0) { throw "the shell item uninstall exited $LASTEXITCODE" }
Check ((Test-Path $shortcut) -and $shell.CreateShortcut($shortcut).TargetPath -eq '') "and so does the uninstall"
Remove-Item -LiteralPath $shortcut -Force
$other = $shell.CreateShortcut($shortcut)
$other.TargetPath = Join-Path $env:RUNNER_TEMP 'gone\tfg-gui.exe'
$other.Save()
choco.exe install testing-files-generator --source $feed --yes --no-progress
if ($LASTEXITCODE -ne 0) { throw "the fifth install exited $LASTEXITCODE" }
Check ($shell.CreateShortcut($shortcut).TargetPath -eq $window) "the install replaces a shortcut whose target is gone"
choco.exe uninstall testing-files-generator --yes --no-progress
if ($LASTEXITCODE -ne 0) { throw "the fifth uninstall exited $LASTEXITCODE" }
Check (-not (Test-Path $shortcut)) "and the uninstall takes the shortcut it made"

if ($failed -ne 0) { throw "$failed check(s) failed - read the FAILED lines above" }
"every check passed"

Expand Down
Loading
Loading