#!/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()