releaser.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641
  1. #!/usr/bin/env python
  2. import argparse
  3. import collections
  4. import contextlib
  5. import datetime
  6. import io
  7. import json
  8. import logging
  9. import os
  10. from pathlib import Path
  11. import platform
  12. import re
  13. import shutil
  14. import subprocess
  15. import sys
  16. import tarfile
  17. import tempfile
  18. import textwrap
  19. import typing
  20. import zipfile
  21. logger = logging.getLogger(__name__)
  22. VcArchDevel = collections.namedtuple("VcArchDevel", ("dll", "imp", "test"))
  23. GIT_HASH_FILENAME = ".git-hash"
  24. def itertools_batched(iterator: typing.Iterable, count: int):
  25. iterator = iter(iterator)
  26. while True:
  27. items = []
  28. for _ in range(count):
  29. obj = next(iterator, None)
  30. if obj is None:
  31. yield tuple(items)
  32. return
  33. items.append(obj)
  34. yield tuple(items)
  35. class Executer:
  36. def __init__(self, root: Path, dry: bool=False):
  37. self.root = root
  38. self.dry = dry
  39. def run(self, cmd, stdout=False, dry_out=None, force=False):
  40. sys.stdout.flush()
  41. logger.info("Executing args=%r", cmd)
  42. if self.dry and not force:
  43. if stdout:
  44. return subprocess.run(["echo", "-E", dry_out or ""], stdout=subprocess.PIPE if stdout else None, text=True, check=True, cwd=self.root)
  45. else:
  46. return subprocess.run(cmd, stdout=subprocess.PIPE if stdout else None, text=True, check=True, cwd=self.root)
  47. class SectionPrinter:
  48. @contextlib.contextmanager
  49. def group(self, title: str):
  50. print(f"{title}:")
  51. yield
  52. class GitHubSectionPrinter(SectionPrinter):
  53. def __init__(self):
  54. super().__init__()
  55. self.in_group = False
  56. @contextlib.contextmanager
  57. def group(self, title: str):
  58. print(f"::group::{title}")
  59. assert not self.in_group, "Can enter a group only once"
  60. self.in_group = True
  61. yield
  62. self.in_group = False
  63. print("::endgroup::")
  64. class VisualStudio:
  65. def __init__(self, executer: Executer, year: typing.Optional[str]=None):
  66. self.executer = executer
  67. self.vsdevcmd = self.find_vsdevcmd(year)
  68. self.msbuild = self.find_msbuild()
  69. @property
  70. def dry(self):
  71. return self.executer.dry
  72. VS_YEAR_TO_VERSION = {
  73. "2022": 17,
  74. "2019": 16,
  75. "2017": 15,
  76. "2015": 14,
  77. "2013": 12,
  78. }
  79. def find_vsdevcmd(self, year: typing.Optional[str]=None) -> typing.Optional[Path]:
  80. vswhere_spec = ["-latest"]
  81. if year is not None:
  82. try:
  83. version = cls.VS_YEAR_TO_VERSION[year]
  84. except KeyError:
  85. logger.error("Invalid Visual Studio year")
  86. return None
  87. vswhere_spec.extend(["-version", f"[{version},{version+1})"])
  88. vswhere_cmd = ["vswhere"] + vswhere_spec + ["-property", "installationPath"]
  89. vs_install_path = Path(self.executer.run(vswhere_cmd, stdout=True, dry_out="/tmp").stdout.strip())
  90. logger.info("VS install_path = %s", vs_install_path)
  91. assert vs_install_path.is_dir(), "VS installation path does not exist"
  92. vsdevcmd_path = vs_install_path / "Common7/Tools/vsdevcmd.bat"
  93. logger.info("vsdevcmd path = %s", vsdevcmd_path)
  94. if self.dry:
  95. vsdevcmd_path.parent.mkdir(parents=True, exist_ok=True)
  96. vsdevcmd_path.touch(exist_ok=True)
  97. assert vsdevcmd_path.is_file(), "vsdevcmd.bat batch file does not exist"
  98. return vsdevcmd_path
  99. def find_msbuild(self) -> typing.Optional[Path]:
  100. vswhere_cmd = ["vswhere", "-latest", "-requires", "Microsoft.Component.MSBuild", "-find", "MSBuild\**\Bin\MSBuild.exe"]
  101. msbuild_path = Path(self.executer.run(vswhere_cmd, stdout=True, dry_out="/tmp/MSBuild.exe").stdout.strip())
  102. logger.info("MSBuild path = %s", msbuild_path)
  103. if self.dry:
  104. msbuild_path.parent.mkdir(parents=True, exist_ok=True)
  105. msbuild_path.touch(exist_ok=True)
  106. assert msbuild_path.is_file(), "MSBuild.exe does not exist"
  107. return msbuild_path
  108. def build(self, arch: str, platform: str, configuration: str, projects: list[Path]):
  109. assert projects, "Need at least one project to build"
  110. vsdev_cmd_str = f"\"{self.vsdevcmd}\" -arch={arch}"
  111. msbuild_cmd_str = " && ".join([f"\"{self.msbuild}\" \"{project}\" /m /p:BuildInParallel=true /p:Platform={platform} /p:Configuration={configuration}" for project in projects])
  112. bat_contents = f"{vsdev_cmd_str} && {msbuild_cmd_str}\n"
  113. bat_path = Path(tempfile.gettempdir()) / "cmd.bat"
  114. with bat_path.open("w") as f:
  115. f.write(bat_contents)
  116. logger.info("Running cmd.exe script (%s): %s", bat_path, bat_contents)
  117. cmd = ["cmd.exe", "/D", "/E:ON", "/V:OFF", "/S", "/C", f"CALL {str(bat_path)}"]
  118. self.executer.run(cmd)
  119. class Releaser:
  120. def __init__(self, project: str, commit: str, root: Path, dist_path: Path, section_printer: SectionPrinter, executer: Executer, cmake_generator: str):
  121. self.project = project
  122. self.version = self.extract_sdl_version(root=root, project=project)
  123. self.root = root
  124. self.commit = commit
  125. self.dist_path = dist_path
  126. self.section_printer = section_printer
  127. self.executer = executer
  128. self.cmake_generator = cmake_generator
  129. self.artifacts = {}
  130. @property
  131. def dry(self):
  132. return self.executer.dry
  133. def prepare(self):
  134. logger.debug("Creating dist folder")
  135. self.dist_path.mkdir(parents=True, exist_ok=True)
  136. GitLsTreeResult = collections.namedtuple("GitLsTreeResult", ("path", "mode", "object_type", "object_name"))
  137. def _git_ls_tree(self, commit) -> dict[str, GitLsTreeResult]:
  138. logger.debug("Getting source listing from git")
  139. dry_out = textwrap.dedent("""\
  140. "CMakeLists.txt": {"object_name": "9e5e4bcf094bfbde94f19c3f314808031ec8f141", "mode": "100644", "type": "blob"},
  141. """)
  142. last_key = "zzzzzz"
  143. dict_tree_items = "{" + self.executer.run(["git", "ls-tree", "-r", """--format="%(path)": {"object_name": "%(objectname)", "mode": "%(objectmode)", "type": "%(objecttype)"},""", commit], stdout=True, dry_out=dry_out).stdout + f'"{last_key}": null' + "}"
  144. with open("/tmp/a.txt", "w") as f:
  145. f.write(dict_tree_items)
  146. f.write("\n")
  147. dict_tree_items = json.loads(dict_tree_items)
  148. del dict_tree_items[last_key]
  149. tree_items = {path: self.GitLsTreeResult(path=path, mode=int(v["mode"], 8), object_type=v["type"], object_name=v["object_name"]) for path, v in dict_tree_items.items()}
  150. assert all(item.object_type == "blob" for item in tree_items.values())
  151. return tree_items
  152. def _git_cat_file(self, tree_items: dict[str, GitLsTreeResult]) -> dict[str, bytes]:
  153. logger.debug("Getting source binary data from git")
  154. if self.dry:
  155. return {
  156. "CMakeLists.txt": b"cmake_minimum_required(VERSION 3.20)\nproject(SDL)\n",
  157. }
  158. git_cat = subprocess.Popen(["git", "cat-file", "--batch"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=False, bufsize=50 * 1024 * 1024)
  159. data_tree = {}
  160. batch_size = 60
  161. for batch in itertools_batched(tree_items.items(), batch_size):
  162. for object_path, tree_item in batch:
  163. logger.debug("Requesting data of file '%s' (object=%s)...", object_path, tree_item.object_name)
  164. git_cat.stdin.write(f"{tree_item.object_name}\n".encode())
  165. git_cat.stdin.flush()
  166. for object_path, tree_item in batch:
  167. header = git_cat.stdout.readline().decode()
  168. object_name, object_type, obj_size = header.strip().split(maxsplit=3)
  169. assert tree_item.object_name == object_name
  170. assert tree_item.object_type == object_type
  171. obj_size = int(obj_size)
  172. data_tree[object_path] = git_cat.stdout.read(obj_size)
  173. logger.debug("File data received '%s'", object_path)
  174. assert git_cat.stdout.readline() == b"\n"
  175. assert len(data_tree) == len(tree_items)
  176. logger.debug("No more file!")
  177. git_cat.stdin.close()
  178. git_cat.wait()
  179. assert git_cat.returncode == 0
  180. logger.debug("All data received!")
  181. return data_tree
  182. def _get_file_times(self, tree_items: dict[str, GitLsTreeResult]) -> dict[str, datetime.datetime]:
  183. dry_out = textwrap.dedent("""\
  184. time=2024-03-14T15:40:25-07:00
  185. M\tCMakeLists.txt
  186. """)
  187. git_log_out = self.executer.run(["git", "log", "--name-status", '--pretty=time=%cI'], stdout=True, dry_out=dry_out).stdout.splitlines(keepends=False)
  188. current_time = None
  189. tree_paths = {item.path for item in tree_items.values()}
  190. path_times = {}
  191. for line in git_log_out:
  192. if not line:
  193. continue
  194. if line.startswith("time="):
  195. current_time = datetime.datetime.fromisoformat(line.removeprefix("time="))
  196. continue
  197. mod_type, paths = line.split(maxsplit=1)
  198. assert current_time is not None
  199. for path in paths.split():
  200. if path in tree_paths and path not in path_times:
  201. path_times[path] = current_time
  202. assert set(path_times.keys()) == tree_paths
  203. return path_times
  204. @staticmethod
  205. def _path_filter(path: str):
  206. if path.startswith(".git"):
  207. return False
  208. return True
  209. TreeItem = collections.namedtuple("TreeItem", ("path", "mode", "data", "time"))
  210. def _get_git_contents(self) -> dict[str, (TreeItem, bytes, datetime.datetime)]:
  211. commit_file_tree = self._git_ls_tree(self.commit)
  212. git_datas = self._git_cat_file(commit_file_tree)
  213. git_times = self._get_file_times(commit_file_tree)
  214. git_contents = {path: self.TreeItem(path=path, data=git_datas[path], mode=item.mode, time=git_times[path]) for path, item in commit_file_tree.items() if self._path_filter(path)}
  215. return git_contents
  216. def create_source_archives(self):
  217. archive_base = f"{self.project}-{self.version}"
  218. git_contents = self._get_git_contents()
  219. git_files = list(git_contents.values())
  220. assert len(git_contents) == len(git_files)
  221. latest_mod_time = max(item.time for item in git_files)
  222. git_files.append(self.TreeItem(path="VERSION.txt", data=f"{self.version}\n".encode(), mode=0o100644, time=latest_mod_time))
  223. git_files.append(self.TreeItem(path=GIT_HASH_FILENAME, data=f"{self.commit}\n".encode(), mode=0o100644, time=latest_mod_time))
  224. git_files.sort(key=lambda v: v.time)
  225. zip_path = self.dist_path / f"{archive_base}.zip"
  226. logger.info("Creating .zip source archive (%s)...", zip_path)
  227. if self.dry:
  228. zip_path.touch()
  229. else:
  230. with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zip_object:
  231. for git_file in git_files:
  232. file_data_time = (git_file.time.year, git_file.time.month, git_file.time.day, git_file.time.hour, git_file.time.minute, git_file.time.second)
  233. zip_info = zipfile.ZipInfo(filename=f"{archive_base}/{git_file.path}", date_time=file_data_time)
  234. zip_info.external_attr = git_file.mode << 16
  235. zip_info.compress_type = zipfile.ZIP_DEFLATED
  236. zip_object.writestr(zip_info, data=git_file.data)
  237. self.artifacts["src-zip"] = zip_path
  238. tar_types = (
  239. (".tar.gz", "gz"),
  240. (".tar.xz", "xz"),
  241. )
  242. for ext, comp in tar_types:
  243. tar_path = self.dist_path / f"{archive_base}{ext}"
  244. logger.info("Creating %s source archive (%s)...", ext, tar_path)
  245. if self.dry:
  246. tar_path.touch()
  247. else:
  248. with tarfile.open(tar_path, f"w:{comp}") as tar_object:
  249. for git_file in git_files:
  250. tar_info = tarfile.TarInfo(f"{archive_base}/{git_file.path}")
  251. tar_info.mode = git_file.mode
  252. tar_info.size = len(git_file.data)
  253. tar_info.mtime = git_file.time.timestamp()
  254. tar_object.addfile(tar_info, fileobj=io.BytesIO(git_file.data))
  255. if tar_path.suffix == ".gz":
  256. # Zero the embedded timestamp in the gzip'ed tarball
  257. with open(tar_path, "r+b") as f:
  258. f.seek(4, 0)
  259. f.write(b"\x00\x00\x00\x00")
  260. self.artifacts[f"src-tar-{comp}"] = tar_path
  261. def create_xcframework(self, configuration: str="Release"):
  262. dmg_in = self.root / f"Xcode/SDL/build/SDL3.dmg"
  263. dmg_in.unlink(missing_ok=True)
  264. self.executer.run(["xcodebuild", "-project", self.root / "Xcode/SDL/SDL.xcodeproj", "-target", "SDL3.dmg", "-configuration", configuration])
  265. if self.dry:
  266. dmg_in.parent.mkdir(parents=True, exist_ok=True)
  267. dmg_in.touch()
  268. assert dmg_in.is_file(), "SDL3.dmg was not created by xcodebuild"
  269. dmg_out = self.dist_path / f"{self.project}-{self.version}.dmg"
  270. shutil.copy(dmg_in, dmg_out)
  271. self.artifacts["dmg"] = dmg_out
  272. @property
  273. def git_hash_data(self):
  274. return f"{self.commit}\n".encode()
  275. def _tar_add_git_hash(self, tar_object: tarfile.TarFile, root: typing.Optional[str]=None, time: typing.Optional[datetime.datetime]=None):
  276. if not time:
  277. time = datetime.datetime(year=2024, month=4, day=1)
  278. path = GIT_HASH_FILENAME
  279. if root:
  280. path = f"{root}/{path}"
  281. tar_info = tarfile.TarInfo(path)
  282. tar_info.mode = 0o100644
  283. tar_info.size = len(self.git_hash_data)
  284. tar_info.mtime = time.timestamp()
  285. tar_object.addfile(tar_info, fileobj=io.BytesIO(self.git_hash_data))
  286. def _zip_add_git_hash(self, zip_file: zipfile.ZipFile, root: typing.Optional[str]=None, time: typing.Optional[datetime.datetime]=None):
  287. if not time:
  288. time = datetime.datetime(year=2024, month=4, day=1)
  289. path = GIT_HASH_FILENAME
  290. if root:
  291. path = f"{root}/{path}"
  292. file_data_time = (time.year, time.month, time.day, time.hour, time.minute, time.second)
  293. zip_info = zipfile.ZipInfo(filename=path, date_time=file_data_time)
  294. zip_info.external_attr = 0o100644 << 16
  295. zip_info.compress_type = zipfile.ZIP_DEFLATED
  296. zip_file.writestr(zip_info, data=self.git_hash_data)
  297. def create_mingw_archives(self):
  298. build_type = "Release"
  299. mingw_archs = ("i686", "x86_64")
  300. build_parent_dir = self.root / "build-mingw"
  301. zip_path = self.dist_path / f"{self.project}-devel-{self.version}-mingw.zip"
  302. tar_exts = ("gz", "xz")
  303. tar_paths = { ext: self.dist_path / f"{self.project}-devel-{self.version}-mingw.tar.{ext}" for ext in tar_exts}
  304. arch_install_paths = {}
  305. arch_files = {}
  306. for arch in mingw_archs:
  307. build_path = build_parent_dir / f"build-{arch}"
  308. install_path = build_parent_dir / f"install-{arch}"
  309. arch_install_paths[arch] = install_path
  310. shutil.rmtree(install_path, ignore_errors=True)
  311. build_path.mkdir(parents=True, exist_ok=True)
  312. with self.section_printer.group(f"Configuring MinGW {arch}"):
  313. self.executer.run([
  314. "cmake", "-S", str(self.root), "-B", str(build_path),
  315. "--fresh",
  316. "-DSDL_SHARED=ON",
  317. "-DSDL_STATIC=ON",
  318. "-DSDL_DISABLE_INSTALL_DOCS=ON",
  319. "-DSDL_TEST_LIBRARY=ON",
  320. "-DSDL_TESTS=OFF",
  321. "-DCMAKE_INSTALL_BINDIR=bin",
  322. "-DCMAKE_INSTALL_DATAROOTDIR=share",
  323. "-DCMAKE_INSTALL_INCLUDEDIR=include",
  324. "-DCMAKE_INSTALL_LIBDIR=lib",
  325. f"-DCMAKE_BUILD_TYPE={build_type}",
  326. f"-DCMAKE_TOOLCHAIN_FILE={self.root}/build-scripts/cmake-toolchain-mingw64-{arch}.cmake",
  327. f"-G{self.cmake_generator}",
  328. f"-DCMAKE_INSTALL_PREFIX={install_path}",
  329. ])
  330. with self.section_printer.group(f"Build MinGW {arch}"):
  331. self.executer.run(["cmake", "--build", str(build_path), "--verbose", "--config", build_type])
  332. with self.section_printer.group(f"Install MinGW {arch}"):
  333. self.executer.run(["cmake", "--install", str(build_path), "--strip", "--config", build_type])
  334. arch_files[arch] = list(Path(r) / f for r, _, files in os.walk(install_path) for f in files)
  335. extra_files = [
  336. ("mingw/pkg-support/INSTALL.txt", ""),
  337. ("mingw/pkg-support/Makefile", ""),
  338. ("mingw/pkg-support/cmake/sdl3-config.cmake", "cmake/"),
  339. ("mingw/pkg-support/cmake/sdl3-config-version.cmake", "cmake/"),
  340. ("BUGS.txt", ""),
  341. ("CREDITS.md", ""),
  342. ("README-SDL.txt", ""),
  343. ("WhatsNew.txt", ""),
  344. ("LICENSE.txt", ""),
  345. ("README.md", ""),
  346. ]
  347. test_files = list(Path(r) / f for r, _, files in os.walk(self.root / "test") for f in files)
  348. # FIXME: split SDL3.dll debug information into debug library
  349. # objcopy --only-keep-debug SDL3.dll SDL3.debug.dll
  350. # objcopy --add-gnu-debuglink=SDL3.debug.dll SDL3.dll
  351. # objcopy --strip-debug SDL3.dll
  352. for comp in tar_exts:
  353. logger.info("Creating %s...", tar_paths[comp])
  354. with tarfile.open(tar_paths[comp], f"w:{comp}") as tar_object:
  355. arc_root = f"{self.project}-{self.version}"
  356. for file_path, arcdirname in extra_files:
  357. assert not arcdirname or arcdirname[-1] == "/"
  358. arcname = f"{arc_root}/{arcdirname}{Path(file_path).name}"
  359. tar_object.add(self.root / file_path, arcname=arcname)
  360. for arch in mingw_archs:
  361. install_path = arch_install_paths[arch]
  362. arcname_parent = f"{arc_root}/{arch}-w64-mingw32"
  363. for file in arch_files[arch]:
  364. arcname = os.path.join(arcname_parent, file.relative_to(install_path))
  365. tar_object.add(file, arcname=arcname)
  366. for test_file in test_files:
  367. arcname = f"{arc_root}/test/{test_file.relative_to(self.root/'test')}"
  368. tar_object.add(test_file, arcname=arcname)
  369. self._tar_add_git_hash(tar_object=tar_object, root=arc_root)
  370. self.artifacts[f"mingw-devel-tar-{comp}"] = tar_paths[comp]
  371. def build_vs(self, arch: str, platform: str, vs: VisualStudio, configuration: str="Release"):
  372. dll_path = self.root / f"VisualC/SDL/{platform}/{configuration}/{self.project}.dll"
  373. imp_path = self.root / f"VisualC/SDL/{platform}/{configuration}/{self.project}.lib"
  374. test_path = self.root / f"VisualC/SDL_test/{platform}/{configuration}/{self.project}_test.lib"
  375. dll_path.unlink(missing_ok=True)
  376. imp_path.unlink(missing_ok=True)
  377. test_path.unlink(missing_ok=True)
  378. projects = [
  379. self.root / "VisualC/SDL/SDL.vcxproj",
  380. self.root / "VisualC/SDL_test/SDL_test.vcxproj",
  381. ]
  382. vs.build(arch=arch, platform=platform, configuration=configuration, projects=projects)
  383. if self.dry:
  384. dll_path.parent.mkdir(parents=True, exist_ok=True)
  385. dll_path.touch()
  386. imp_path.touch()
  387. test_path.parent.mkdir(parents=True, exist_ok=True)
  388. test_path.touch()
  389. assert dll_path.is_file(), "SDL3.dll has not been created"
  390. assert imp_path.is_file(), "SDL3.lib has not been created"
  391. assert test_path.is_file(), "SDL3_test.lib has not been created"
  392. zip_path = self.dist_path / f"{self.project}-{self.version}-win32-{arch}.zip"
  393. zip_path.unlink(missing_ok=True)
  394. logger.info("Creating %s", zip_path)
  395. with zipfile.ZipFile(zip_path, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
  396. logger.debug("Adding %s", dll_path.name)
  397. zf.write(dll_path, arcname=dll_path.name)
  398. logger.debug("Adding %s", "README-SDL.txt")
  399. zf.write(self.root / "README-SDL.txt", arcname="README-SDL.txt")
  400. self._zip_add_git_hash(zip_file=zf)
  401. self.artifacts[f"VC-{arch}"] = zip_path
  402. return VcArchDevel(dll=dll_path, imp=imp_path, test=test_path)
  403. def build_vs_devel(self, arch_vc: dict[str, VcArchDevel]):
  404. zip_path = self.dist_path / f"{self.project}-devel-{self.version}-VC.zip"
  405. archive_prefix = f"{self.project}-{self.version}"
  406. def zip_file(zf: zipfile.ZipFile, path: Path, arcrelpath: str):
  407. arcname = f"{archive_prefix}/{arcrelpath}"
  408. logger.debug("Adding %s to %s", path, arcname)
  409. zf.write(path, arcname=arcname)
  410. def zip_directory(zf: zipfile.ZipFile, directory: Path, arcrelpath: str):
  411. for f in directory.iterdir():
  412. if f.is_file():
  413. arcname = f"{archive_prefix}/{arcrelpath}/{f.name}"
  414. logger.debug("Adding %s to %s", f, arcname)
  415. zf.write(f, arcname=arcname)
  416. with zipfile.ZipFile(zip_path, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
  417. for arch, binaries in arch_vc.items():
  418. zip_file(zf, path=binaries.dll, arcrelpath=f"lib/{arch}/{binaries.dll.name}")
  419. zip_file(zf, path=binaries.imp, arcrelpath=f"lib/{arch}/{binaries.imp.name}")
  420. zip_file(zf, path=binaries.test, arcrelpath=f"lib/{arch}/{binaries.test.name}")
  421. zip_directory(zf, directory=self.root / "include/SDL3", arcrelpath="include/SDL3")
  422. zip_directory(zf, directory=self.root / "docs", arcrelpath="docs")
  423. zip_directory(zf, directory=self.root / "VisualC/pkg-support/cmake", arcrelpath="cmake")
  424. for txt in ("BUGS.txt", "README-SDL.txt", "WhatsNew.txt"):
  425. zip_file(zf, path=self.root / txt, arcrelpath=txt)
  426. zip_file(zf, path=self.root / "LICENSE.txt", arcrelpath="COPYING.txt")
  427. zip_file(zf, path=self.root / "README.md", arcrelpath="README.txt")
  428. self._zip_add_git_hash(zip_file=zf, root=archive_prefix)
  429. self.artifacts["VC-devel"] = zip_path
  430. @classmethod
  431. def extract_sdl_version(cls, root: Path, project: str):
  432. with open(root / f"include/{project}/SDL_version.h", "r") as f:
  433. text = f.read()
  434. major = next(re.finditer(r"^#define SDL_MAJOR_VERSION\s+([0-9]+)$", text, flags=re.M)).group(1)
  435. minor = next(re.finditer(r"^#define SDL_MINOR_VERSION\s+([0-9]+)$", text, flags=re.M)).group(1)
  436. patch = next(re.finditer(r"^#define SDL_PATCHLEVEL\s+([0-9]+)$", text, flags=re.M)).group(1)
  437. return f"{major}.{minor}.{patch}"
  438. def main(argv=None):
  439. parser = argparse.ArgumentParser(allow_abbrev=False, description="Create SDL release artifacts")
  440. parser.add_argument("--root", metavar="DIR", type=Path, default=Path(__file__).resolve().parents[1], help="Root of SDL")
  441. parser.add_argument("--out", "-o", metavar="DIR", dest="dist_path", type=Path, default="dist", help="Output directory")
  442. parser.add_argument("--github", action="store_true", help="Script is running on a GitHub runner")
  443. parser.add_argument("--commit", default="HEAD", help="Git commit/tag of which a release should be created")
  444. parser.add_argument("--project", required=True, help="Name of the project")
  445. parser.add_argument("--create", choices=["source", "mingw", "win32", "xcframework"], required=True,action="append", dest="actions", help="SDL version")
  446. parser.set_defaults(loglevel=logging.INFO)
  447. parser.add_argument('--vs-year', dest="vs_year", help="Visual Studio year")
  448. parser.add_argument('--cmake-generator', dest="cmake_generator", default="Ninja", help="CMake Generator")
  449. parser.add_argument('--debug', action='store_const', const=logging.DEBUG, dest="loglevel", help="Print script debug information")
  450. parser.add_argument('--dry-run', action='store_true', dest="dry", help="Don't execute anything")
  451. parser.add_argument('--force', action='store_true', dest="force", help="Ignore a non-clean git tree")
  452. args = parser.parse_args(argv)
  453. logging.basicConfig(level=args.loglevel, format='[%(levelname)s] %(message)s')
  454. args.actions = set(args.actions)
  455. args.dist_path = args.dist_path.resolve()
  456. args.root = args.root.resolve()
  457. args.dist_path = args.dist_path.resolve()
  458. if args.dry:
  459. args.dist_path = args.dist_path / "dry"
  460. if args.github:
  461. section_printer = GitHubSectionPrinter()
  462. else:
  463. section_printer = SectionPrinter()
  464. executer = Executer(root=args.root, dry=args.dry)
  465. root_git_hash_path = args.root / GIT_HASH_FILENAME
  466. root_is_maybe_archive = root_git_hash_path.is_file()
  467. if root_is_maybe_archive:
  468. logger.warning("%s detected: Building from archive", GIT_HASH_FILENAME)
  469. archive_commit = root_git_hash_path.read_text().strip()
  470. if args.commit != archive_commit:
  471. logger.warn("Commit argument is %s, but archive commit is %s. Using %s.", args.commit, archive_commit, archive_commit)
  472. args.commit = archive_commit
  473. else:
  474. args.commit = executer.run(["git", "rev-parse", args.commit], stdout=True, dry_out="e5812a9fd2cda317b503325a702ba3c1c37861d9").stdout.strip()
  475. logger.info("Using commit %s", args.commit)
  476. releaser = Releaser(
  477. project=args.project,
  478. commit=args.commit,
  479. root=args.root,
  480. dist_path=args.dist_path,
  481. executer=executer,
  482. section_printer=section_printer,
  483. cmake_generator=args.cmake_generator,
  484. )
  485. if root_is_maybe_archive:
  486. logger.warn("Building from archive. Skipping clean git tree check.")
  487. else:
  488. porcelain_status = executer.run(["git", "status", "--ignored", "--porcelain"], stdout=True, dry_out="\n").stdout.strip()
  489. if porcelain_status:
  490. print(porcelain_status)
  491. logger.warning("The tree is dirty! Do not publish any generated artifacts!")
  492. if not args.force:
  493. raise Exception("The git repo contains modified and/or non-committed files. Run with --force to ignore.")
  494. with section_printer.group("Arguments"):
  495. print(f"project = {args.project}")
  496. print(f"version = {releaser.version}")
  497. print(f"commit = {args.commit}")
  498. print(f"out = {args.dist_path}")
  499. print(f"actions = {args.actions}")
  500. print(f"dry = {args.dry}")
  501. print(f"force = {args.force}")
  502. print(f"cmake_generator = {args.cmake_generator}")
  503. releaser.prepare()
  504. if "source" in args.actions:
  505. if root_is_maybe_archive:
  506. raise Exception("Cannot build source archive from source archive")
  507. with section_printer.group("Create source archives"):
  508. releaser.create_source_archives()
  509. if "xcframework" in args.actions:
  510. if platform.system() != "Darwin" and not args.dry:
  511. parser.error("xcframework artifact(s) can only be built on Darwin")
  512. releaser.create_xcframework()
  513. if "win32" in args.actions:
  514. if platform.system() != "Windows" and not args.dry:
  515. parser.error("win32 artifact(s) can only be built on Windows")
  516. with section_printer.group("Find Visual Studio"):
  517. vs = VisualStudio(executer=executer)
  518. with section_printer.group("Build x86 VS binary"):
  519. x86 = releaser.build_vs(arch="x86", platform="Win32", vs=vs)
  520. with section_printer.group("Build x64 VS binary"):
  521. x64 = releaser.build_vs(arch="x64", platform="x64", vs=vs)
  522. with section_printer.group("Create SDL VC development zip"):
  523. arch_vc = {
  524. "x86": x86,
  525. "x64": x64,
  526. }
  527. releaser.build_vs_devel(arch_vc)
  528. if "mingw" in args.actions:
  529. releaser.create_mingw_archives()
  530. with section_printer.group("Summary"):
  531. print(f"artifacts = {releaser.artifacts}")
  532. if args.github:
  533. if args.dry:
  534. os.environ["GITHUB_OUTPUT"] = "/tmp/github_output.txt"
  535. with open(os.environ["GITHUB_OUTPUT"], "a") as f:
  536. f.write(f"project={releaser.project}\n")
  537. f.write(f"version={releaser.version}\n")
  538. for k, v in releaser.artifacts.items():
  539. f.write(f"{k}={v.name}\n")
  540. return 0
  541. if __name__ == "__main__":
  542. raise SystemExit(main())