track Python 3.14 updates and latest stable Perl

This commit is contained in:
2026-10-19 21:44:29 -06:00
parent 9b65e5be73
commit 88bd1da5c4
8 changed files with 317 additions and 17 deletions
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""Prepare current Windows scripting dependencies and matching installer metadata."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.request
from urllib.parse import urlsplit
import xml.etree.ElementTree as ET
import zipfile
RELEASES_URL = "https://strawberryperl.com/releases.json"
RELEASE_ROOT = "https://github.com/StrawberryPerl/Perl-Dist-Strawberry/releases/download/"
MSBUILD_NS = "http://schemas.microsoft.com/developer/msbuild/2003"
def select_perl(releases):
"""Compare versions, not publication dates: older branches get newer releases too."""
candidates = []
for release in releases:
version = release.get("version", "")
if not re.fullmatch(r"5\.\d+\.\d+\.\d+", version):
continue
parts = tuple(map(int, version.split(".")))
if parts[1] % 2 or release.get("archname") != "MSWin32-x64-multi-thread":
continue
if not all(kind in release.get("edition", {}) for kind in ("portable", "msi")):
continue
candidates.append((parts, release))
if not candidates:
raise ValueError("No stable x64 Strawberry Perl release with portable ZIP and MSI")
release = max(candidates, key=lambda item: item[0])[1]
for kind, suffix in (("portable", "portable.zip"), ("msi", "msi")):
package = release["edition"][kind]
expected_name = f"strawberry-perl-{release['version']}-64bit-{suffix}"
if kind == "msi":
expected_name = f"strawberry-perl-{release['version']}-64bit.msi"
url = package.get("url", "")
if not url.startswith(RELEASE_ROOT) or urlsplit(url).path.rsplit("/", 1)[-1] != expected_name:
raise ValueError(f"Unexpected Strawberry Perl {kind} URL: {url}")
if not re.fullmatch(r"[0-9a-fA-F]{64}", package.get("sha256", "")):
raise ValueError(f"Missing SHA-256 for Strawberry Perl {kind}")
return release
def download(url, destination):
for attempt in range(4):
try:
request = urllib.request.Request(url, headers={"User-Agent": "ZoiteChat-build"})
with urllib.request.urlopen(request, timeout=120) as response, destination.open("wb") as output:
while chunk := response.read(1024 * 1024):
output.write(chunk)
return
except (OSError, TimeoutError):
if attempt == 3:
raise
time.sleep(2 ** attempt)
def write_metadata(repo, perl_root, release, python_version):
parts = release["version"].split(".")
perl_lib = f"perl{parts[0]}{parts[1]}"
ET.register_namespace("", MSBUILD_NS)
project = ET.Element(f"{{{MSBUILD_NS}}}Project")
group = ET.SubElement(project, "PropertyGroup")
ET.SubElement(group, "PerlPath").text = str(perl_root)
ET.SubElement(group, "PerlLib").text = perl_lib
ET.ElementTree(project).write(repo / "win32/runtimes.generated.props", encoding="utf-8", xml_declaration=True)
# Only numeric versions and validated upstream URLs enter the Inno source.
definitions = {
"PythonRuntimeVersion": python_version,
"PythonRuntimeURL": f"https://www.python.org/ftp/python/{python_version}/python-{python_version}-amd64.exe",
"PerlRuntimeVersion": release["version"],
"PerlRuntimeURL": release["edition"]["msi"]["url"],
"PerlRuntimeDLL": f"{perl_lib}.dll",
}
(repo / "win32/installer/runtimes.generated.iss").write_text(
"// Generated by tools/prepare-windows-runtimes.py\n"
+ "".join(f'#define {key} "{value}"\n' for key, value in definitions.items()),
encoding="utf-8",
)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--deps-root", type=Path, default=Path("C:/gtk-build"))
args = parser.parse_args()
if sys.platform != "win32" or sys.version_info[:2] != (3, 14) or sys.version_info[:3] < (3, 14, 8) or sys.version_info.releaselevel != "final":
parser.error("Run with stable x64 Python 3.14.8 or newer within the 3.14 branch")
if sys.maxsize <= 2 ** 32:
parser.error("The Windows build requires x64 Python")
repo = Path(__file__).resolve().parents[1]
# Do not let an interrupted refresh leave metadata from an older dependency.
for relative in ("win32/runtimes.generated.props", "win32/installer/runtimes.generated.iss"):
(repo / relative).unlink(missing_ok=True)
args.deps_root.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(dir=args.deps_root) as temporary:
temporary = Path(temporary)
metadata = temporary / "releases.json"
download(RELEASES_URL, metadata)
release = select_perl(json.loads(metadata.read_text(encoding="utf-8")))
archive = temporary / "perl.zip"
download(release["edition"]["portable"]["url"], archive)
with archive.open("rb") as source:
digest = hashlib.file_digest(source, "sha256").hexdigest()
if digest.lower() != release["edition"]["portable"]["sha256"].lower():
raise ValueError("Strawberry Perl portable ZIP SHA-256 mismatch")
# Keep the portable distribution intact, including its companion C DLLs.
target = args.deps_root / f"strawberry-perl-{release['version']}" / "x64"
extracted = temporary / "extracted"
with zipfile.ZipFile(archive) as package:
package.extractall(extracted)
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
shutil.rmtree(target)
shutil.move(extracted, target)
perl_root = target / "perl"
environment = os.environ.copy()
paths = [str(perl_root / "bin"), str(target / "c/bin")]
environment["PATH"] = os.pathsep.join(paths + [environment.get("PATH", "")])
perl_lib = "perl" + "".join(release["version"].split(".")[:2])
for required in (perl_root / "bin/perl.exe", perl_root / f"bin/{perl_lib}.dll", perl_root / "lib/CORE/perl.h"):
if not required.is_file():
raise FileNotFoundError(f"Strawberry Perl is missing {required}")
actual = subprocess.check_output(
[str(perl_root / "bin/perl.exe"), "-MConfig", "-e", 'print "$Config{version}|$Config{archname}"'],
env=environment, text=True,
).strip()
expected = ".".join(release["version"].split(".")[:3]) + "|MSWin32-x64-multi-thread"
if actual != expected:
raise ValueError(f"Unexpected Perl runtime: {actual}; expected {expected}")
python_version = ".".join(map(str, sys.version_info[:3]))
write_metadata(repo, perl_root, release, python_version)
if os.environ.get("GITHUB_PATH"):
with open(os.environ["GITHUB_PATH"], "a", encoding="utf-8") as output:
output.write("\n".join(paths) + "\n")
print(f"Windows runtimes: Python {python_version}; Strawberry Perl {release['version']}")
if __name__ == "__main__":
main()
+81
View File
@@ -0,0 +1,81 @@
"""Regression coverage for stable release selection and build/installer alignment."""
import copy
import importlib.util
from pathlib import Path
import tempfile
import unittest
import xml.etree.ElementTree as ET
spec = importlib.util.spec_from_file_location(
"windows_runtimes", Path(__file__).resolve().parents[1] / "prepare-windows-runtimes.py"
)
runtimes = importlib.util.module_from_spec(spec)
spec.loader.exec_module(runtimes)
def release(version, arch="MSWin32-x64-multi-thread"):
prefix = runtimes.RELEASE_ROOT + "SP_" + version.replace(".", "") + "_64bit/"
stem = f"strawberry-perl-{version}-64bit"
return {
"version": version,
"archname": arch,
"edition": {
"portable": {"url": prefix + stem + "-portable.zip", "sha256": "a" * 64},
"msi": {"url": prefix + stem + ".msi", "sha256": "b" * 64},
},
}
class RuntimeTests(unittest.TestCase):
def test_compares_versions_instead_of_feed_order_or_dates(self):
releases = [release("5.40.5.1"), release("5.42.3.1"), release("5.42.0.1")]
self.assertEqual(runtimes.select_perl(releases)["version"], "5.42.3.1")
def test_numeric_patch_order(self):
self.assertEqual(runtimes.select_perl([release("5.42.9.1"), release("5.42.10.1")])["version"], "5.42.10.1")
def test_filters_development_prerelease_and_wrong_architecture(self):
releases = [release("5.43.1.1"), release("5.44.0.1-RC1"), release("5.44.0.1", "MSWin32-x86-multi-thread"), release("5.42.3.1")]
self.assertEqual(runtimes.select_perl(releases)["version"], "5.42.3.1")
def test_accepts_next_stable_branch(self):
self.assertEqual(runtimes.select_perl([release("5.42.3.1"), release("5.44.0.1")])["version"], "5.44.0.1")
def test_requires_both_build_and_installer_packages(self):
newer = release("5.44.0.1")
del newer["edition"]["msi"]
self.assertEqual(runtimes.select_perl([newer, release("5.42.3.1")])["version"], "5.42.3.1")
def test_fails_if_no_suitable_release_exists(self):
with self.assertRaises(ValueError):
runtimes.select_perl([release("5.43.1.1")])
def test_rejects_bad_hashes_untrusted_hosts_and_wrong_packages(self):
for kind in ("portable", "msi"):
for field, value in (("sha256", ""), ("url", "https://example.org/perl.zip"), ("url", runtimes.RELEASE_ROOT + "SP/strawberry-perl-5.40.5.1-64bit.msi")):
with self.subTest(kind=kind, field=field, value=value):
invalid = copy.deepcopy(release("5.42.3.1"))
invalid["edition"][kind][field] = value
with self.assertRaises(ValueError):
runtimes.select_perl([invalid])
def test_generated_dll_and_installer_follow_selected_release(self):
with tempfile.TemporaryDirectory() as directory:
repo = Path(directory)
(repo / "win32/installer").mkdir(parents=True)
selected = release("5.44.1.1")
perl_root = Path("C:/gtk-build/Perl & tools/perl")
runtimes.write_metadata(repo, perl_root, selected, "3.14.8")
props = ET.parse(repo / "win32/runtimes.generated.props")
ns = {"m": runtimes.MSBUILD_NS}
self.assertEqual(props.find("m:PropertyGroup/m:PerlLib", ns).text, "perl544")
self.assertEqual(props.find("m:PropertyGroup/m:PerlPath", ns).text, str(perl_root))
installer = (repo / "win32/installer/runtimes.generated.iss").read_text()
self.assertIn('#define PerlRuntimeDLL "perl544.dll"', installer)
self.assertIn(selected["edition"]["msi"]["url"], installer)
self.assertIn("/3.14.8/python-3.14.8-amd64.exe", installer)
if __name__ == "__main__":
unittest.main()