| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975 |
- #!/usr/bin/env python
- """
- This script is shared between SDL2, SDL2_image, SDL2_mixer and SDL2_ttf.
- Don't specialize this script for doing project-specific modifications.
- Rather, modify release-info.json.
- """
- import argparse
- import collections
- from collections.abc import Callable
- import contextlib
- import datetime
- import fnmatch
- import glob
- import io
- import json
- import logging
- import multiprocessing
- import os
- from pathlib import Path
- import platform
- import re
- import shutil
- import subprocess
- import sys
- import tarfile
- import tempfile
- import textwrap
- import typing
- import zipfile
- logger = logging.getLogger(__name__)
- GIT_HASH_FILENAME = ".git-hash"
- def safe_isotime_to_datetime(str_isotime: str) -> datetime.datetime:
- try:
- return datetime.datetime.fromisoformat(str_isotime)
- except ValueError:
- pass
- logger.warning("Invalid iso time: %s", str_isotime)
- if str_isotime[-6:-5] in ("+", "-"):
- # Commits can have isotime with invalid timezone offset (e.g. "2021-07-04T20:01:40+32:00")
- modified_str_isotime = str_isotime[:-6] + "+00:00"
- try:
- return datetime.datetime.fromisoformat(modified_str_isotime)
- except ValueError:
- pass
- raise ValueError(f"Invalid isotime: {str_isotime}")
- class VsArchPlatformConfig:
- def __init__(self, arch: str, platform: str, configuration: str):
- self.arch = arch
- self.platform = platform
- self.configuration = configuration
- def configure(self, s: str) -> str:
- return s.replace("@ARCH@", self.arch).replace("@PLATFORM@", self.platform).replace("@CONFIGURATION@", self.configuration)
- @contextlib.contextmanager
- def chdir(path):
- original_cwd = os.getcwd()
- try:
- os.chdir(path)
- yield
- finally:
- os.chdir(original_cwd)
- class Executer:
- def __init__(self, root: Path, dry: bool=False):
- self.root = root
- self.dry = dry
- def run(self, cmd, cwd=None, env=None):
- logger.info("Executing args=%r", cmd)
- sys.stdout.flush()
- if not self.dry:
- subprocess.run(cmd, check=True, cwd=cwd or self.root, env=env, text=True)
- def check_output(self, cmd, cwd=None, dry_out=None, env=None, text=True):
- logger.info("Executing args=%r", cmd)
- sys.stdout.flush()
- if self.dry:
- return dry_out
- return subprocess.check_output(cmd, cwd=cwd or self.root, env=env, text=text)
- class SectionPrinter:
- @contextlib.contextmanager
- def group(self, title: str):
- print(f"{title}:")
- yield
- class GitHubSectionPrinter(SectionPrinter):
- def __init__(self):
- super().__init__()
- self.in_group = False
- @contextlib.contextmanager
- def group(self, title: str):
- print(f"::group::{title}")
- assert not self.in_group, "Can enter a group only once"
- self.in_group = True
- yield
- self.in_group = False
- print("::endgroup::")
- class VisualStudio:
- def __init__(self, executer: Executer, year: typing.Optional[str]=None):
- self.executer = executer
- self.vsdevcmd = self.find_vsdevcmd(year)
- self.msbuild = self.find_msbuild()
- @property
- def dry(self) -> bool:
- return self.executer.dry
- VS_YEAR_TO_VERSION = {
- "2022": 17,
- "2019": 16,
- "2017": 15,
- "2015": 14,
- "2013": 12,
- }
- def find_vsdevcmd(self, year: typing.Optional[str]=None) -> typing.Optional[Path]:
- vswhere_spec = ["-latest"]
- if year is not None:
- try:
- version = self.VS_YEAR_TO_VERSION[year]
- except KeyError:
- logger.error("Invalid Visual Studio year")
- return None
- vswhere_spec.extend(["-version", f"[{version},{version+1})"])
- vswhere_cmd = ["vswhere"] + vswhere_spec + ["-property", "installationPath"]
- vs_install_path = Path(self.executer.check_output(vswhere_cmd, dry_out="/tmp").strip())
- logger.info("VS install_path = %s", vs_install_path)
- assert vs_install_path.is_dir(), "VS installation path does not exist"
- vsdevcmd_path = vs_install_path / "Common7/Tools/vsdevcmd.bat"
- logger.info("vsdevcmd path = %s", vsdevcmd_path)
- if self.dry:
- vsdevcmd_path.parent.mkdir(parents=True, exist_ok=True)
- vsdevcmd_path.touch(exist_ok=True)
- assert vsdevcmd_path.is_file(), "vsdevcmd.bat batch file does not exist"
- return vsdevcmd_path
- def find_msbuild(self) -> typing.Optional[Path]:
- vswhere_cmd = ["vswhere", "-latest", "-requires", "Microsoft.Component.MSBuild", "-find", r"MSBuild\**\Bin\MSBuild.exe"]
- msbuild_path = Path(self.executer.check_output(vswhere_cmd, dry_out="/tmp/MSBuild.exe").strip())
- logger.info("MSBuild path = %s", msbuild_path)
- if self.dry:
- msbuild_path.parent.mkdir(parents=True, exist_ok=True)
- msbuild_path.touch(exist_ok=True)
- assert msbuild_path.is_file(), "MSBuild.exe does not exist"
- return msbuild_path
- def build(self, arch_platform: VsArchPlatformConfig, projects: list[Path]):
- assert projects, "Need at least one project to build"
- vsdev_cmd_str = f"\"{self.vsdevcmd}\" -arch={arch_platform.arch}"
- msbuild_cmd_str = " && ".join([f"\"{self.msbuild}\" \"{project}\" /m /p:BuildInParallel=true /p:Platform={arch_platform.platform} /p:Configuration={arch_platform.configuration}" for project in projects])
- bat_contents = f"{vsdev_cmd_str} && {msbuild_cmd_str}\n"
- bat_path = Path(tempfile.gettempdir()) / "cmd.bat"
- with bat_path.open("w") as f:
- f.write(bat_contents)
- logger.info("Running cmd.exe script (%s): %s", bat_path, bat_contents)
- cmd = ["cmd.exe", "/D", "/E:ON", "/V:OFF", "/S", "/C", f"CALL {str(bat_path)}"]
- self.executer.run(cmd)
- class Archiver:
- def __init__(self, zip_path: typing.Optional[Path]=None, tgz_path: typing.Optional[Path]=None, txz_path: typing.Optional[Path]=None):
- self._zip_files = []
- self._tar_files = []
- self._added_files = set()
- if zip_path:
- self._zip_files.append(zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED))
- if tgz_path:
- self._tar_files.append(tarfile.open(tgz_path, "w:gz"))
- if txz_path:
- self._tar_files.append(tarfile.open(txz_path, "w:xz"))
- @property
- def added_files(self) -> set[str]:
- return self._added_files
- def add_file_data(self, arcpath: str, data: bytes, mode: int, time: datetime.datetime):
- for zf in self._zip_files:
- file_data_time = (time.year, time.month, time.day, time.hour, time.minute, time.second)
- zip_info = zipfile.ZipInfo(filename=arcpath, date_time=file_data_time)
- zip_info.external_attr = mode << 16
- zip_info.compress_type = zipfile.ZIP_DEFLATED
- zf.writestr(zip_info, data=data)
- for tf in self._tar_files:
- tar_info = tarfile.TarInfo(arcpath)
- tar_info.type = tarfile.REGTYPE
- tar_info.mode = mode
- tar_info.size = len(data)
- tar_info.mtime = int(time.timestamp())
- tf.addfile(tar_info, fileobj=io.BytesIO(data))
- self._added_files.add(arcpath)
- def add_symlink(self, arcpath: str, target: str, time: datetime.datetime, files_for_zip):
- for zf in self._zip_files:
- file_data_time = (time.year, time.month, time.day, time.hour, time.minute, time.second)
- for f in files_for_zip:
- zip_info = zipfile.ZipInfo(filename=f["arcpath"], date_time=file_data_time)
- zip_info.external_attr = f["mode"] << 16
- zip_info.compress_type = zipfile.ZIP_DEFLATED
- zf.writestr(zip_info, data=f["data"])
- for tf in self._tar_files:
- tar_info = tarfile.TarInfo(arcpath)
- tar_info.type = tarfile.SYMTYPE
- tar_info.mode = 0o777
- tar_info.mtime = int(time.timestamp())
- tar_info.linkname = target
- tf.addfile(tar_info)
- self._added_files.update(f["arcpath"] for f in files_for_zip)
- def add_git_hash(self, commit: str, arcdir: typing.Optional[str]=None, time: typing.Optional[datetime.datetime]=None):
- arcpath = GIT_HASH_FILENAME
- if arcdir and arcdir[-1:] != "/":
- arcpath = f"{arcdir}/{arcpath}"
- if not time:
- time = datetime.datetime(year=2024, month=4, day=1)
- data = f"{commit}\n".encode()
- self.add_file_data(arcpath=arcpath, data=data, mode=0o100644, time=time)
- def add_file_path(self, arcpath: str, path: Path):
- assert path.is_file(), f"{path} should be a file"
- for zf in self._zip_files:
- zf.write(path, arcname=arcpath)
- for tf in self._tar_files:
- tf.add(path, arcname=arcpath)
- def add_file_directory(self, arcdirpath: str, dirpath: Path):
- assert dirpath.is_dir()
- if arcdirpath and arcdirpath[-1:] != "/":
- arcdirpath += "/"
- for f in dirpath.iterdir():
- if f.is_file():
- arcpath = f"{arcdirpath}{f.name}"
- logger.debug("Adding %s to %s", f, arcpath)
- self.add_file_path(arcpath=arcpath, path=f)
- def close(self):
- # Archiver is intentionally made invalid after this function
- del self._zip_files
- self._zip_files = None
- del self._tar_files
- self._tar_files = None
- def __enter__(self):
- return self
- def __exit__(self, type, value, traceback):
- self.close()
- class SourceCollector:
- TreeItem = collections.namedtuple("TreeItem", ("path", "mode", "data", "symtarget", "directory", "time"))
- def __init__(self, root: Path, commit: str, filter: typing.Optional[Callable[[str], bool]], executer: Executer):
- self.root = root
- self.commit = commit
- self.filter = filter
- self.executer = executer
- self._git_contents: typing.Optional[dict[str, SourceCollector.TreeItem]] = None
- def _get_git_contents(self) -> dict[str, TreeItem]:
- contents_tgz = subprocess.check_output(["git", "archive", "--format=tar.gz", self.commit, "-o", "/dev/stdout"], cwd=self.root, text=False)
- tar_archive = tarfile.open(fileobj=io.BytesIO(contents_tgz), mode="r:gz")
- filenames = tuple(m.name for m in tar_archive if (m.isfile() or m.issym()))
- file_times = self._get_file_times(paths=filenames)
- git_contents = {}
- for ti in tar_archive:
- if self.filter and not self.filter(ti.name):
- continue
- data = None
- symtarget = None
- directory = False
- file_time = None
- if ti.isfile():
- contents_file = tar_archive.extractfile(ti.name)
- data = contents_file.read()
- file_time = file_times[ti.name]
- elif ti.issym():
- symtarget = ti.linkname
- file_time = file_times[ti.name]
- elif ti.isdir():
- directory = True
- else:
- raise ValueError(f"{ti.name}: unknown type")
- git_contents[ti.name] = self.TreeItem(path=ti.name, mode=ti.mode, data=data, symtarget=symtarget, directory=directory, time=file_time)
- return git_contents
- @property
- def git_contents(self) -> dict[str, TreeItem]:
- if self._git_contents is None:
- self._git_contents = self._get_git_contents()
- return self._git_contents
- def _get_file_times(self, paths: tuple[str, ...]) -> dict[str, datetime.datetime]:
- dry_out = textwrap.dedent("""\
- time=2024-03-14T15:40:25-07:00
- M\tCMakeLists.txt
- """)
- git_log_out = self.executer.check_output(["git", "log", "--name-status", '--pretty=time=%cI', self.commit], dry_out=dry_out, cwd=self.root).splitlines(keepends=False)
- current_time = None
- set_paths = set(paths)
- path_times: dict[str, datetime.datetime] = {}
- for line in git_log_out:
- if not line:
- continue
- if line.startswith("time="):
- current_time = safe_isotime_to_datetime(line.removeprefix("time="))
- continue
- mod_type, file_paths = line.split(maxsplit=1)
- assert current_time is not None
- for file_path in file_paths.split("\t"):
- if file_path in set_paths and file_path not in path_times:
- path_times[file_path] = current_time
- # FIXME: find out why some files are not shown in "git log"
- # assert set(path_times.keys()) == set_paths
- if set(path_times.keys()) != set_paths:
- found_times = set(path_times.keys())
- paths_without_times = set_paths.difference(found_times)
- logger.warning("No times found for these paths: %s", paths_without_times)
- max_time = max(time for time in path_times.values())
- for path in paths_without_times:
- path_times[path] = max_time
- return path_times
- def add_to_archiver(self, archive_base: str, archiver: Archiver):
- remaining_symlinks = set()
- added_files = dict()
- def calculate_symlink_target(s: SourceCollector.TreeItem) -> str:
- dest_dir = os.path.dirname(s.path)
- if dest_dir:
- dest_dir += "/"
- target = dest_dir + s.symtarget
- while True:
- new_target, n = re.subn(r"([^/]+/+[.]{2}/)", "", target)
- print(f"{target=} {new_target=}")
- target = new_target
- if not n:
- break
- return target
- # Add files in first pass
- for git_file in self.git_contents.values():
- if git_file.data is not None:
- archiver.add_file_data(arcpath=f"{archive_base}/{git_file.path}", data=git_file.data, time=git_file.time, mode=git_file.mode)
- added_files[git_file.path] = git_file
- elif git_file.symtarget is not None:
- remaining_symlinks.add(git_file)
- # Resolve symlinks in second pass: zipfile does not support symlinks, so add files to zip archive
- while True:
- if not remaining_symlinks:
- break
- symlinks_this_time = set()
- extra_added_files = {}
- for symlink in remaining_symlinks:
- symlink_files_for_zip = {}
- symlink_target_path = calculate_symlink_target(symlink)
- if symlink_target_path in added_files:
- symlink_files_for_zip[symlink.path] = added_files[symlink_target_path]
- else:
- symlink_target_path_slash = symlink_target_path + "/"
- for added_file in added_files:
- if added_file.startswith(symlink_target_path_slash):
- path_in_symlink = symlink.path + "/" + added_file.removeprefix(symlink_target_path_slash)
- symlink_files_for_zip[path_in_symlink] = added_files[added_file]
- if symlink_files_for_zip:
- symlinks_this_time.add(symlink)
- extra_added_files.update(symlink_files_for_zip)
- files_for_zip = [{"arcpath": f"{archive_base}/{sym_path}", "data": sym_info.data, "mode": sym_info.mode} for sym_path, sym_info in symlink_files_for_zip.items()]
- archiver.add_symlink(arcpath=f"{archive_base}/{symlink.path}", target=symlink.symtarget, time=symlink.time, files_for_zip=files_for_zip)
- # if not symlinks_this_time:
- # logger.info("files added: %r", set(path for path in added_files.keys()))
- assert symlinks_this_time, f"No targets found for symlinks: {remaining_symlinks}"
- remaining_symlinks.difference_update(symlinks_this_time)
- added_files.update(extra_added_files)
- class Releaser:
- def __init__(self, release_info: dict, commit: str, root: Path, dist_path: Path, section_printer: SectionPrinter, executer: Executer, cmake_generator: str, deps_path: Path, overwrite: bool, github: bool, fast: bool):
- self.release_info = release_info
- self.project = release_info["name"]
- self.version = self.extract_sdl_version(root=root, release_info=release_info)
- self.root = root
- self.commit = commit
- self.dist_path = dist_path
- self.section_printer = section_printer
- self.executer = executer
- self.cmake_generator = cmake_generator
- self.cpu_count = multiprocessing.cpu_count()
- self.deps_path = deps_path
- self.overwrite = overwrite
- self.github = github
- self.fast = fast
- self.artifacts: dict[str, Path] = {}
- @property
- def dry(self) -> bool:
- return self.executer.dry
- def prepare(self):
- logger.debug("Creating dist folder")
- self.dist_path.mkdir(parents=True, exist_ok=True)
- @classmethod
- def _path_filter(cls, path: str) -> bool:
- if ".gitmodules" in path:
- return True
- if path.startswith(".git"):
- return False
- return True
- @classmethod
- def _external_repo_path_filter(cls, path: str) -> bool:
- if not cls._path_filter(path):
- return False
- if path.startswith("test/") or path.startswith("tests/"):
- return False
- return True
- def create_source_archives(self) -> None:
- archive_base = f"{self.project}-{self.version}"
- project_souce_collector = SourceCollector(root=self.root, commit=self.commit, executer=self.executer, filter=self._path_filter)
- latest_mod_time = max(item.time for item in project_souce_collector.git_contents.values() if item.time)
- zip_path = self.dist_path / f"{archive_base}.zip"
- tgz_path = self.dist_path / f"{archive_base}.tar.gz"
- txz_path = self.dist_path / f"{archive_base}.tar.xz"
- logger.info("Creating zip/tgz/txz source archives ...")
- if self.dry:
- zip_path.touch()
- tgz_path.touch()
- txz_path.touch()
- else:
- with Archiver(zip_path=zip_path, tgz_path=tgz_path, txz_path=txz_path) as archiver:
- archiver.add_file_data(arcpath=f"{archive_base}/VERSION.txt", data=f"{self.version}\n".encode(), mode=0o100644, time=latest_mod_time)
- archiver.add_file_data(arcpath=f"{archive_base}/{GIT_HASH_FILENAME}", data=f"{self.commit}\n".encode(), mode=0o100644, time=latest_mod_time)
- print(f"Adding source files of main project ...")
- project_souce_collector.add_to_archiver(archive_base=archive_base, archiver=archiver)
- for extra_repo in self.release_info["source"].get("extra-repos", []):
- extra_repo_root = self.root / extra_repo
- assert (extra_repo_root / ".git").exists(), f"{extra_repo_root} must be a git repo"
- extra_repo_commit = self.executer.check_output(["git", "rev-parse", "HEAD"], dry_out=f"gitsha-extra-repo-{extra_repo}", cwd=extra_repo_root).strip()
- extra_repo_source_collector = SourceCollector(root=extra_repo_root, commit=extra_repo_commit, executer=self.executer, filter=self._external_repo_path_filter)
- print(f"Adding source files of {extra_repo} ...")
- extra_repo_source_collector.add_to_archiver(archive_base=f"{archive_base}/{extra_repo}", archiver=archiver)
- for file in self.release_info["source"]["checks"]:
- assert f"{archive_base}/{file}" in archiver.added_files, f"'{archive_base}/{file}' must exist"
- logger.info("... done")
- self.artifacts["src-zip"] = zip_path
- self.artifacts["src-tar-gz"] = tgz_path
- self.artifacts["src-tar-xz"] = txz_path
- if not self.dry:
- with tgz_path.open("r+b") as f:
- # Zero the embedded timestamp in the gzip'ed tarball
- f.seek(4, 0)
- f.write(b"\x00\x00\x00\x00")
- def create_dmg(self, configuration: str="Release") -> None:
- dmg_in = self.root / self.release_info["dmg"]["path"]
- xcode_project = self.root / self.release_info["dmg"]["project"]
- assert xcode_project.is_dir(), f"{xcode_project} must be a directory"
- assert (xcode_project / "project.pbxproj").is_file, f"{xcode_project} must contain project.pbxproj"
- dmg_in.unlink(missing_ok=True)
- build_xcconfig = self.release_info["dmg"].get("build-xcconfig")
- if build_xcconfig:
- shutil.copy(self.root / build_xcconfig, xcode_project.parent / "build.xcconfig")
- xcode_scheme = self.release_info["dmg"].get("scheme")
- xcode_target = self.release_info["dmg"].get("target")
- assert xcode_scheme or xcode_target, "dmg needs scheme or target"
- assert not (xcode_scheme and xcode_target), "dmg cannot have both scheme and target set"
- if xcode_scheme:
- scheme_or_target = "-scheme"
- target_like = xcode_scheme
- else:
- scheme_or_target = "-target"
- target_like = xcode_target
- self.executer.run(["xcodebuild", "ONLY_ACTIVE_ARCH=NO", "-project", xcode_project, scheme_or_target, target_like, "-configuration", configuration])
- if self.dry:
- dmg_in.parent.mkdir(parents=True, exist_ok=True)
- dmg_in.touch()
- assert dmg_in.is_file(), f"{self.project}.dmg was not created by xcodebuild"
- dmg_out = self.dist_path / f"{self.project}-{self.version}.dmg"
- shutil.copy(dmg_in, dmg_out)
- self.artifacts["dmg"] = dmg_out
- @property
- def git_hash_data(self) -> bytes:
- return f"{self.commit}\n".encode()
- def _tar_add_git_hash(self, tar_object: tarfile.TarFile, root: typing.Optional[str]=None, time: typing.Optional[datetime.datetime]=None):
- if not time:
- time = datetime.datetime(year=2024, month=4, day=1)
- path = GIT_HASH_FILENAME
- if root:
- path = f"{root}/{path}"
- tar_info = tarfile.TarInfo(path)
- tar_info.mode = 0o100644
- tar_info.size = len(self.git_hash_data)
- tar_info.mtime = int(time.timestamp())
- tar_object.addfile(tar_info, fileobj=io.BytesIO(self.git_hash_data))
- def create_mingw_archives(self) -> None:
- build_type = "Release"
- build_parent_dir = self.root / "build-mingw"
- assert "autotools" in self.release_info["mingw"]
- assert "cmake" not in self.release_info["mingw"]
- mingw_archs = self.release_info["mingw"]["autotools"]["archs"]
- ARCH_TO_TRIPLET = {
- "x86": "i686-w64-mingw32",
- "x64": "x86_64-w64-mingw32",
- }
- new_env = dict(os.environ)
- if "dependencies" in self.release_info["mingw"]:
- mingw_deps_path = self.deps_path / "mingw-deps"
- shutil.rmtree(mingw_deps_path, ignore_errors=True)
- mingw_deps_path.mkdir()
- for triplet in ARCH_TO_TRIPLET.values():
- (mingw_deps_path / triplet).mkdir()
- def extract_filter(member: tarfile.TarInfo, path: str, /):
- if member.name.startswith("SDL"):
- member.name = "/".join(Path(member.name).parts[1:])
- return member
- for dep in self.release_info["dependencies"].keys():
- extract_dir = mingw_deps_path / f"extract-{dep}"
- extract_dir.mkdir()
- with chdir(extract_dir):
- tar_path = glob.glob(self.release_info["mingw"]["dependencies"][dep]["artifact"], root_dir=self.deps_path)[0]
- logger.info("Extracting %s to %s", tar_path, mingw_deps_path)
- with tarfile.open(self.deps_path / tar_path, mode="r:gz") as tarf:
- tarf.extractall(filter=extract_filter)
- for triplet in ARCH_TO_TRIPLET.values():
- self.executer.run(["make", f"-j{os.cpu_count()}", "-C", str(extract_dir), "install-package", f"arch={triplet}", f"prefix={str(mingw_deps_path / triplet)}"])
- dep_binpath = mingw_deps_path / triplet / "bin"
- assert dep_binpath.is_dir(), f"{dep_binpath} for PATH should exist"
- dep_pkgconfig = mingw_deps_path / triplet / "lib/pkgconfig"
- assert dep_pkgconfig.is_dir(), f"{dep_pkgconfig} for PKG_CONFIG_PATH should exist"
- new_env["PATH"] = os.pathsep.join([str(dep_binpath), new_env["PATH"]])
- new_env["PKG_CONFIG_PATH"] = str(dep_pkgconfig)
- new_env["CFLAGS"] = f"-O2 -ffile-prefix-map={self.root}=/src/{self.project}"
- new_env["CXXFLAGS"] = f"-O2 -ffile-prefix-map={self.root}=/src/{self.project}"
- arch_install_paths = {}
- arch_files = {}
- for arch in mingw_archs:
- triplet = ARCH_TO_TRIPLET[arch]
- new_env["CC"] = f"{triplet}-gcc"
- new_env["CXX"] = f"{triplet}-g++"
- new_env["RC"] = f"{triplet}-windres"
- build_path = build_parent_dir / f"build-{triplet}"
- install_path = build_parent_dir / f"install-{triplet}"
- arch_install_paths[arch] = install_path
- shutil.rmtree(install_path, ignore_errors=True)
- build_path.mkdir(parents=True, exist_ok=True)
- with self.section_printer.group(f"Configuring MinGW {triplet}"):
- extra_args = [arg.replace("@DEP_PREFIX@", str(mingw_deps_path / triplet)) for arg in self.release_info["mingw"]["autotools"]["args"]]
- assert "@" not in " ".join(extra_args), f"@ should not be present in extra arguments ({extra_args})"
- self.executer.run([
- self.root / "configure",
- f"--prefix={install_path}",
- f"--includedir={install_path}/include",
- f"--libdir={install_path}/lib",
- f"--bindir={install_path}/bin",
- f"--host={triplet}",
- f"--build=x86_64-none-linux-gnu",
- ] + extra_args, cwd=build_path, env=new_env)
- with self.section_printer.group(f"Build MinGW {triplet}"):
- self.executer.run(["make", f"-j{self.cpu_count}"], cwd=build_path, env=new_env)
- with self.section_printer.group(f"Install MinGW {triplet}"):
- self.executer.run(["make", "install"], cwd=build_path, env=new_env)
- arch_files[arch] = list(Path(r) / f for r, _, files in os.walk(install_path) for f in files)
- print("Collecting files for MinGW development archive ...")
- archived_files = {}
- arc_root = f"{self.project}-{self.version}"
- for arch in mingw_archs:
- triplet = ARCH_TO_TRIPLET[arch]
- install_path = arch_install_paths[arch]
- arcname_parent = f"{arc_root}/{triplet}"
- for file in arch_files[arch]:
- arcname = os.path.join(arcname_parent, file.relative_to(install_path))
- logger.debug("Adding %s as %s", file, arcname)
- archived_files[arcname] = file
- for meta_destdir, file_globs in self.release_info["mingw"]["files"].items():
- assert meta_destdir[0] == "/" and meta_destdir[-1] == "/", f"'{meta_destdir}' must begin and end with '/'"
- if "@" in meta_destdir:
- destdirs = list(meta_destdir.replace("@TRIPLET@", triplet) for triplet in ARCH_TO_TRIPLET.values())
- assert not any("A" in d for d in destdirs)
- else:
- destdirs = [meta_destdir]
- assert isinstance(file_globs, list), f"'{file_globs}' in release_info.json must be a list of globs instead"
- for file_glob in file_globs:
- file_paths = glob.glob(file_glob, root_dir=self.root)
- assert file_paths, f"glob '{file_glob}' does not match any file"
- for file_path in file_paths:
- file_path = self.root / file_path
- for destdir in destdirs:
- arcname = f"{arc_root}{destdir}{file_path.name}"
- logger.debug("Adding %s as %s", file_path, arcname)
- archived_files[arcname] = file_path
- print("... done")
- print("Creating zip/tgz/txz development archives ...")
- zip_path = self.dist_path / f"{self.project}-devel-{self.version}-mingw.zip"
- tgz_path = self.dist_path / f"{self.project}-devel-{self.version}-mingw.tar.gz"
- txz_path = self.dist_path / f"{self.project}-devel-{self.version}-mingw.tar.xz"
- with Archiver(zip_path=zip_path, tgz_path=tgz_path, txz_path=txz_path) as archiver:
- for arcpath, path in archived_files.items():
- archiver.add_file_path(arcpath=arcpath, path=path)
- print("... done")
- self.artifacts["mingw-devel-zip"] = zip_path
- self.artifacts["mingw-devel-tar-gz"] = tgz_path
- self.artifacts["mingw-devel-tar-xz"] = txz_path
- def download_dependencies(self):
- shutil.rmtree(self.deps_path, ignore_errors=True)
- self.deps_path.mkdir(parents=True)
- if self.github:
- with open(os.environ["GITHUB_OUTPUT"], "a") as f:
- f.write(f"dep-path={self.deps_path.absolute()}\n")
- for dep, depinfo in self.release_info["dependencies"].items():
- startswith = depinfo["startswith"]
- dep_repo = depinfo["repo"]
- dep_string_data = self.executer.check_output(["gh", "-R", dep_repo, "release", "list", "--exclude-drafts", "--exclude-pre-releases", "--json", "name,createdAt,tagName", "--jq", f'[.[]|select(.name|startswith("{startswith}"))]|max_by(.createdAt)']).strip()
- dep_data = json.loads(dep_string_data)
- dep_tag = dep_data["tagName"]
- dep_version = dep_data["name"]
- logger.info("Download dependency %s version %s (tag=%s) ", dep, dep_version, dep_tag)
- self.executer.run(["gh", "-R", dep_repo, "release", "download", dep_tag], cwd=self.deps_path)
- if self.github:
- with open(os.environ["GITHUB_OUTPUT"], "a") as f:
- f.write(f"dep-{dep.lower()}-version={dep_version}\n")
- def verify_dependencies(self):
- for dep, depinfo in self.release_info.get("dependencies", {}).items():
- mingw_matches = glob.glob(self.release_info["mingw"]["dependencies"][dep]["artifact"], root_dir=self.deps_path)
- assert len(mingw_matches) == 1, f"Exactly one archive matches mingw {dep} dependency: {mingw_matches}"
- dmg_matches = glob.glob(self.release_info["dmg"]["dependencies"][dep]["artifact"], root_dir=self.deps_path)
- assert len(dmg_matches) == 1, f"Exactly one archive matches dmg {dep} dependency: {dmg_matches}"
- msvc_matches = glob.glob(self.release_info["msvc"]["dependencies"][dep]["artifact"], root_dir=self.deps_path)
- assert len(msvc_matches) == 1, f"Exactly one archive matches msvc {dep} dependency: {msvc_matches}"
- def build_vs(self, arch_platform: VsArchPlatformConfig, vs: VisualStudio):
- msvc_deps_path = self.deps_path / "msvc-deps"
- shutil.rmtree(msvc_deps_path, ignore_errors=True)
- if "dependencies" in self.release_info["msvc"]:
- for dep, depinfo in self.release_info["msvc"]["dependencies"].items():
- msvc_zip = self.deps_path / glob.glob(depinfo["artifact"], root_dir=self.deps_path)[0]
- src_globs = [arch_platform.configure(instr["src"]) for instr in depinfo["copy"]]
- with zipfile.ZipFile(msvc_zip, "r") as zf:
- for member in zf.namelist():
- member_path = "/".join(Path(member).parts[1:])
- for src_i, src_glob in enumerate(src_globs):
- if fnmatch.fnmatch(member_path, src_glob):
- dst = (self.root / arch_platform.configure(depinfo["copy"][src_i]["dst"])).resolve() / Path(member_path).name
- zip_data = zf.read(member)
- if dst.exists():
- identical = False
- if dst.is_file():
- orig_bytes = dst.read_bytes()
- if orig_bytes == zip_data:
- identical = True
- if not identical:
- logger.warning("Extracting dependency %s, will cause %s to be overwritten", dep, dst)
- if not self.overwrite:
- raise RuntimeError("Run with --overwrite to allow overwriting")
- logger.debug("Extracting %s -> %s", member, dst)
- dst.parent.mkdir(exist_ok=True, parents=True)
- dst.write_bytes(zip_data)
- assert "msbuild" in self.release_info["msvc"]
- assert "cmake" not in self.release_info["msvc"]
- built_paths = [
- self.root / arch_platform.configure(f) for msbuild_files in self.release_info["msvc"]["msbuild"]["files"] for f in msbuild_files["paths"]
- ]
- for b in built_paths:
- b.unlink(missing_ok=True)
- projects = self.release_info["msvc"]["msbuild"]["projects"]
- with self.section_printer.group(f"Build {arch_platform.arch} VS binary"):
- vs.build(arch_platform=arch_platform, projects=projects)
- if self.dry:
- for b in built_paths:
- b.parent.mkdir(parents=True, exist_ok=True)
- b.touch()
- for b in built_paths:
- assert b.is_file(), f"{b} has not been created"
- b.parent.mkdir(parents=True, exist_ok=True)
- b.touch()
- zip_path = self.dist_path / f"{self.project}-{self.version}-win32-{arch_platform.arch}.zip"
- zip_path.unlink(missing_ok=True)
- logger.info("Creating %s", zip_path)
- with Archiver(zip_path=zip_path) as archiver:
- for msbuild_files in self.release_info["msvc"]["msbuild"]["files"]:
- if "lib" in msbuild_files:
- arcdir = arch_platform.configure(msbuild_files["lib"])
- for p in msbuild_files["paths"]:
- p = arch_platform.configure(p)
- archiver.add_file_path(path=self.root / p, arcpath=f"{arcdir}/{Path(p).name}")
- for extra_files in self.release_info["msvc"]["files"]:
- if "lib" in extra_files:
- arcdir = arch_platform.configure(extra_files["lib"])
- for p in extra_files["paths"]:
- p = arch_platform.configure(p)
- archiver.add_file_path(path=self.root / p, arcpath=f"{arcdir}/{Path(p).name}")
- archiver.add_git_hash(commit=self.commit)
- self.artifacts[f"VC-{arch_platform.arch}"] = zip_path
- for p in built_paths:
- assert p.is_file(), f"{p} should exist"
- def build_vs_devel(self, arch_platforms: list[VsArchPlatformConfig]) -> None:
- zip_path = self.dist_path / f"{self.project}-devel-{self.version}-VC.zip"
- archive_prefix = f"{self.project}-{self.version}"
- with Archiver(zip_path=zip_path) as archiver:
- for msbuild_files in self.release_info["msvc"]["msbuild"]["files"]:
- if "devel" in msbuild_files:
- for meta_glob_path in msbuild_files["paths"]:
- if "@" in meta_glob_path or "@" in msbuild_files["devel"]:
- for arch_platform in arch_platforms:
- glob_path = arch_platform.configure(meta_glob_path)
- paths = glob.glob(glob_path, root_dir=self.root)
- dst_subdirpath = arch_platform.configure(msbuild_files['devel'])
- for path in paths:
- path = self.root / path
- arcpath = f"{archive_prefix}/{dst_subdirpath}/{Path(path).name}"
- archiver.add_file_path(path=path, arcpath=arcpath)
- else:
- paths = glob.glob(meta_glob_path, root_dir=self.root)
- for path in paths:
- path = self.root / path
- arcpath = f"{archive_prefix}/{msbuild_files['devel']}/{Path(path).name}"
- archiver.add_file_path(path=path, arcpath=arcpath)
- for extra_files in self.release_info["msvc"]["files"]:
- if "devel" in extra_files:
- for meta_glob_path in extra_files["paths"]:
- if "@" in meta_glob_path or "@" in extra_files["devel"]:
- for arch_platform in arch_platforms:
- glob_path = arch_platform.configure(meta_glob_path)
- paths = glob.glob(glob_path, root_dir=self.root)
- dst_subdirpath = arch_platform.configure(extra_files['devel'])
- for path in paths:
- path = self.root / path
- arcpath = f"{archive_prefix}/{dst_subdirpath}/{Path(path).name}"
- archiver.add_file_path(path=path, arcpath=arcpath)
- else:
- paths = glob.glob(meta_glob_path, root_dir=self.root)
- for path in paths:
- path = self.root / path
- arcpath = f"{archive_prefix}/{extra_files['devel']}/{Path(path).name}"
- archiver.add_file_path(path=path, arcpath=arcpath)
- archiver.add_git_hash(commit=self.commit, arcdir=archive_prefix)
- self.artifacts["VC-devel"] = zip_path
- @classmethod
- def extract_sdl_version(cls, root: Path, release_info: dict) -> str:
- with open(root / release_info["version"]["file"], "r") as f:
- text = f.read()
- major = next(re.finditer(release_info["version"]["re_major"], text, flags=re.M)).group(1)
- minor = next(re.finditer(release_info["version"]["re_minor"], text, flags=re.M)).group(1)
- micro = next(re.finditer(release_info["version"]["re_micro"], text, flags=re.M)).group(1)
- return f"{major}.{minor}.{micro}"
- def main(argv=None) -> int:
- if sys.version_info < (3, 11):
- logger.error("This script needs at least python 3.11")
- return 1
- parser = argparse.ArgumentParser(allow_abbrev=False, description="Create SDL release artifacts")
- parser.add_argument("--root", metavar="DIR", type=Path, default=Path(__file__).absolute().parents[1], help="Root of project")
- parser.add_argument("--release-info", metavar="JSON", dest="path_release_info", type=Path, default=Path(__file__).absolute().parent / "release-info.json", help="Path of release-info.json")
- parser.add_argument("--dependency-folder", metavar="FOLDER", dest="deps_path", type=Path, default="deps", help="Directory containing pre-built archives of dependencies (will be removed when downloading archives)")
- parser.add_argument("--out", "-o", metavar="DIR", dest="dist_path", type=Path, default="dist", help="Output directory")
- parser.add_argument("--github", action="store_true", help="Script is running on a GitHub runner")
- parser.add_argument("--commit", default="HEAD", help="Git commit/tag of which a release should be created")
- parser.add_argument("--actions", choices=["download", "source", "mingw", "msvc", "dmg"], required=True, nargs="+", dest="actions", help="What to do?")
- parser.set_defaults(loglevel=logging.INFO)
- parser.add_argument('--vs-year', dest="vs_year", help="Visual Studio year")
- parser.add_argument('--cmake-generator', dest="cmake_generator", default="Ninja", help="CMake Generator")
- parser.add_argument('--debug', action='store_const', const=logging.DEBUG, dest="loglevel", help="Print script debug information")
- parser.add_argument('--dry-run', action='store_true', dest="dry", help="Don't execute anything")
- parser.add_argument('--force', action='store_true', dest="force", help="Ignore a non-clean git tree")
- parser.add_argument('--overwrite', action='store_true', dest="overwrite", help="Allow potentially overwriting other projects")
- parser.add_argument('--fast', action='store_true', dest="fast", help="Don't do a rebuild")
- args = parser.parse_args(argv)
- logging.basicConfig(level=args.loglevel, format='[%(levelname)s] %(message)s')
- args.deps_path = args.deps_path.absolute()
- args.dist_path = args.dist_path.absolute()
- args.root = args.root.absolute()
- args.dist_path = args.dist_path.absolute()
- if args.dry:
- args.dist_path = args.dist_path / "dry"
- if args.github:
- section_printer: SectionPrinter = GitHubSectionPrinter()
- else:
- section_printer = SectionPrinter()
- if args.github and "GITHUB_OUTPUT" not in os.environ:
- os.environ["GITHUB_OUTPUT"] = "/tmp/github_output.txt"
- executer = Executer(root=args.root, dry=args.dry)
- root_git_hash_path = args.root / GIT_HASH_FILENAME
- root_is_maybe_archive = root_git_hash_path.is_file()
- if root_is_maybe_archive:
- logger.warning("%s detected: Building from archive", GIT_HASH_FILENAME)
- archive_commit = root_git_hash_path.read_text().strip()
- if args.commit != archive_commit:
- logger.warning("Commit argument is %s, but archive commit is %s. Using %s.", args.commit, archive_commit, archive_commit)
- args.commit = archive_commit
- else:
- args.commit = executer.check_output(["git", "rev-parse", args.commit], dry_out="e5812a9fd2cda317b503325a702ba3c1c37861d9").strip()
- logger.info("Using commit %s", args.commit)
- try:
- with args.path_release_info.open() as f:
- release_info = json.load(f)
- except FileNotFoundError:
- logger.error(f"Could not find {args.path_release_info}")
- releaser = Releaser(
- release_info=release_info,
- commit=args.commit,
- root=args.root,
- dist_path=args.dist_path,
- executer=executer,
- section_printer=section_printer,
- cmake_generator=args.cmake_generator,
- deps_path=args.deps_path,
- overwrite=args.overwrite,
- github=args.github,
- fast=args.fast,
- )
- if root_is_maybe_archive:
- logger.warning("Building from archive. Skipping clean git tree check.")
- else:
- porcelain_status = executer.check_output(["git", "status", "--ignored", "--porcelain"], dry_out="\n").strip()
- if porcelain_status:
- print(porcelain_status)
- logger.warning("The tree is dirty! Do not publish any generated artifacts!")
- if not args.force:
- raise Exception("The git repo contains modified and/or non-committed files. Run with --force to ignore.")
- if args.fast:
- logger.warning("Doing fast build! Do not publish generated artifacts!")
- with section_printer.group("Arguments"):
- print(f"project = {releaser.project}")
- print(f"version = {releaser.version}")
- print(f"commit = {args.commit}")
- print(f"out = {args.dist_path}")
- print(f"actions = {args.actions}")
- print(f"dry = {args.dry}")
- print(f"force = {args.force}")
- print(f"overwrite = {args.overwrite}")
- print(f"cmake_generator = {args.cmake_generator}")
- releaser.prepare()
- if "download" in args.actions:
- releaser.download_dependencies()
- if set(args.actions).intersection({"msvc", "mingw"}):
- print("Verifying presence of dependencies (run 'download' action to download) ...")
- releaser.verify_dependencies()
- print("... done")
- if "source" in args.actions:
- if root_is_maybe_archive:
- raise Exception("Cannot build source archive from source archive")
- with section_printer.group("Create source archives"):
- releaser.create_source_archives()
- if "dmg" in args.actions:
- if platform.system() != "Darwin" and not args.dry:
- parser.error("framework artifact(s) can only be built on Darwin")
- releaser.create_dmg()
- if "msvc" in args.actions:
- if platform.system() != "Windows" and not args.dry:
- parser.error("msvc artifact(s) can only be built on Windows")
- with section_printer.group("Find Visual Studio"):
- vs = VisualStudio(executer=executer)
- arch_platforms = [
- VsArchPlatformConfig(arch="x86", platform="Win32", configuration="Release"),
- VsArchPlatformConfig(arch="x64", platform="x64", configuration="Release"),
- ]
- for arch_platform in arch_platforms:
- releaser.build_vs(arch_platform=arch_platform, vs=vs)
- with section_printer.group("Create SDL VC development zip"):
- releaser.build_vs_devel(arch_platforms)
- if "mingw" in args.actions:
- releaser.create_mingw_archives()
- with section_printer.group("Summary"):
- print(f"artifacts = {releaser.artifacts}")
- if args.github:
- with open(os.environ["GITHUB_OUTPUT"], "a") as f:
- f.write(f"project={releaser.project}\n")
- f.write(f"version={releaser.version}\n")
- for k, v in releaser.artifacts.items():
- f.write(f"{k}={v.name}\n")
- return 0
- if __name__ == "__main__":
- raise SystemExit(main())
|