build-release.py 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891
  1. #!/usr/bin/env python
  2. import argparse
  3. import collections
  4. import contextlib
  5. import datetime
  6. import glob
  7. import io
  8. import json
  9. import logging
  10. import os
  11. from pathlib import Path
  12. import platform
  13. import re
  14. import shutil
  15. import subprocess
  16. import sys
  17. import tarfile
  18. import tempfile
  19. import textwrap
  20. import typing
  21. import zipfile
  22. logger = logging.getLogger(__name__)
  23. VcArchDevel = collections.namedtuple("VcArchDevel", ("dll", "pdb", "imp", "test"))
  24. GIT_HASH_FILENAME = ".git-hash"
  25. ANDROID_AVAILABLE_ABIS = [
  26. "armeabi-v7a",
  27. "arm64-v8a",
  28. "x86",
  29. "x86_64",
  30. ]
  31. ANDROID_MINIMUM_API = 19
  32. ANDROID_TARGET_API = 29
  33. ANDROID_MINIMUM_NDK = 21
  34. class Executer:
  35. def __init__(self, root: Path, dry: bool=False):
  36. self.root = root
  37. self.dry = dry
  38. def run(self, cmd, stdout=False, dry_out=None, force=False):
  39. sys.stdout.flush()
  40. logger.info("Executing args=%r", cmd)
  41. if self.dry and not force:
  42. if stdout:
  43. return subprocess.run(["echo", "-E", dry_out or ""], stdout=subprocess.PIPE if stdout else None, text=True, check=True, cwd=self.root)
  44. else:
  45. return subprocess.run(cmd, stdout=subprocess.PIPE if stdout else None, text=True, check=True, cwd=self.root)
  46. class SectionPrinter:
  47. @contextlib.contextmanager
  48. def group(self, title: str):
  49. print(f"{title}:")
  50. yield
  51. class GitHubSectionPrinter(SectionPrinter):
  52. def __init__(self):
  53. super().__init__()
  54. self.in_group = False
  55. @contextlib.contextmanager
  56. def group(self, title: str):
  57. print(f"::group::{title}")
  58. assert not self.in_group, "Can enter a group only once"
  59. self.in_group = True
  60. yield
  61. self.in_group = False
  62. print("::endgroup::")
  63. class VisualStudio:
  64. def __init__(self, executer: Executer, year: typing.Optional[str]=None):
  65. self.executer = executer
  66. self.vsdevcmd = self.find_vsdevcmd(year)
  67. self.msbuild = self.find_msbuild()
  68. @property
  69. def dry(self) -> bool:
  70. return self.executer.dry
  71. VS_YEAR_TO_VERSION = {
  72. "2022": 17,
  73. "2019": 16,
  74. "2017": 15,
  75. "2015": 14,
  76. "2013": 12,
  77. }
  78. def find_vsdevcmd(self, year: typing.Optional[str]=None) -> typing.Optional[Path]:
  79. vswhere_spec = ["-latest"]
  80. if year is not None:
  81. try:
  82. version = self.VS_YEAR_TO_VERSION[year]
  83. except KeyError:
  84. logger.error("Invalid Visual Studio year")
  85. return None
  86. vswhere_spec.extend(["-version", f"[{version},{version+1})"])
  87. vswhere_cmd = ["vswhere"] + vswhere_spec + ["-property", "installationPath"]
  88. vs_install_path = Path(self.executer.run(vswhere_cmd, stdout=True, dry_out="/tmp").stdout.strip())
  89. logger.info("VS install_path = %s", vs_install_path)
  90. assert vs_install_path.is_dir(), "VS installation path does not exist"
  91. vsdevcmd_path = vs_install_path / "Common7/Tools/vsdevcmd.bat"
  92. logger.info("vsdevcmd path = %s", vsdevcmd_path)
  93. if self.dry:
  94. vsdevcmd_path.parent.mkdir(parents=True, exist_ok=True)
  95. vsdevcmd_path.touch(exist_ok=True)
  96. assert vsdevcmd_path.is_file(), "vsdevcmd.bat batch file does not exist"
  97. return vsdevcmd_path
  98. def find_msbuild(self) -> typing.Optional[Path]:
  99. vswhere_cmd = ["vswhere", "-latest", "-requires", "Microsoft.Component.MSBuild", "-find", r"MSBuild\**\Bin\MSBuild.exe"]
  100. msbuild_path = Path(self.executer.run(vswhere_cmd, stdout=True, dry_out="/tmp/MSBuild.exe").stdout.strip())
  101. logger.info("MSBuild path = %s", msbuild_path)
  102. if self.dry:
  103. msbuild_path.parent.mkdir(parents=True, exist_ok=True)
  104. msbuild_path.touch(exist_ok=True)
  105. assert msbuild_path.is_file(), "MSBuild.exe does not exist"
  106. return msbuild_path
  107. def build(self, arch: str, platform: str, configuration: str, projects: list[Path]):
  108. assert projects, "Need at least one project to build"
  109. vsdev_cmd_str = f"\"{self.vsdevcmd}\" -arch={arch}"
  110. msbuild_cmd_str = " && ".join([f"\"{self.msbuild}\" \"{project}\" /m /p:BuildInParallel=true /p:Platform={platform} /p:Configuration={configuration}" for project in projects])
  111. bat_contents = f"{vsdev_cmd_str} && {msbuild_cmd_str}\n"
  112. bat_path = Path(tempfile.gettempdir()) / "cmd.bat"
  113. with bat_path.open("w") as f:
  114. f.write(bat_contents)
  115. logger.info("Running cmd.exe script (%s): %s", bat_path, bat_contents)
  116. cmd = ["cmd.exe", "/D", "/E:ON", "/V:OFF", "/S", "/C", f"CALL {str(bat_path)}"]
  117. self.executer.run(cmd)
  118. class Releaser:
  119. def __init__(self, project: str, commit: str, root: Path, dist_path: Path, section_printer: SectionPrinter, executer: Executer, cmake_generator: str):
  120. self.project = project
  121. self.version = self.extract_sdl_version(root=root, project=project)
  122. self.root = root
  123. self.commit = commit
  124. self.dist_path = dist_path
  125. self.section_printer = section_printer
  126. self.executer = executer
  127. self.cmake_generator = cmake_generator
  128. self.artifacts: dict[str, Path] = {}
  129. @property
  130. def dry(self) -> bool:
  131. return self.executer.dry
  132. def prepare(self):
  133. logger.debug("Creating dist folder")
  134. self.dist_path.mkdir(parents=True, exist_ok=True)
  135. TreeItem = collections.namedtuple("TreeItem", ("path", "mode", "data", "time"))
  136. def _get_file_times(self, paths: tuple[str, ...]) -> dict[str, datetime.datetime]:
  137. dry_out = textwrap.dedent("""\
  138. time=2024-03-14T15:40:25-07:00
  139. M\tCMakeLists.txt
  140. """)
  141. git_log_out = self.executer.run(["git", "log", "--name-status", '--pretty=time=%cI', self.commit], stdout=True, dry_out=dry_out).stdout.splitlines(keepends=False)
  142. current_time = None
  143. set_paths = set(paths)
  144. path_times: dict[str, datetime.datetime] = {}
  145. for line in git_log_out:
  146. if not line:
  147. continue
  148. if line.startswith("time="):
  149. current_time = datetime.datetime.fromisoformat(line.removeprefix("time="))
  150. continue
  151. mod_type, file_paths = line.split(maxsplit=1)
  152. assert current_time is not None
  153. for file_path in file_paths.split():
  154. if file_path in set_paths and file_path not in path_times:
  155. path_times[file_path] = current_time
  156. assert set(path_times.keys()) == set_paths
  157. return path_times
  158. @staticmethod
  159. def _path_filter(path: str):
  160. if path.startswith(".git"):
  161. return False
  162. return True
  163. def _get_git_contents(self) -> dict[str, TreeItem]:
  164. contents_tgz = subprocess.check_output(["git", "archive", "--format=tar.gz", self.commit, "-o", "/dev/stdout"], text=False)
  165. contents = tarfile.open(fileobj=io.BytesIO(contents_tgz), mode="r:gz")
  166. filenames = tuple(m.name for m in contents if m.isfile())
  167. assert "src/SDL.c" in filenames
  168. assert "include/SDL3/SDL.h" in filenames
  169. file_times = self._get_file_times(filenames)
  170. git_contents = {}
  171. for ti in contents:
  172. if not ti.isfile():
  173. continue
  174. if not self._path_filter(ti.name):
  175. continue
  176. contents_file = contents.extractfile(ti.name)
  177. assert contents_file, f"{ti.name} is not a file"
  178. git_contents[ti.name] = self.TreeItem(path=ti.name, mode=ti.mode, data=contents_file.read(), time=file_times[ti.name])
  179. return git_contents
  180. def create_source_archives(self) -> None:
  181. archive_base = f"{self.project}-{self.version}"
  182. git_contents = self._get_git_contents()
  183. git_files = list(git_contents.values())
  184. assert len(git_contents) == len(git_files)
  185. latest_mod_time = max(item.time for item in git_files)
  186. git_files.append(self.TreeItem(path="VERSION.txt", data=f"{self.version}\n".encode(), mode=0o100644, time=latest_mod_time))
  187. git_files.append(self.TreeItem(path=GIT_HASH_FILENAME, data=f"{self.commit}\n".encode(), mode=0o100644, time=latest_mod_time))
  188. git_files.sort(key=lambda v: v.time)
  189. zip_path = self.dist_path / f"{archive_base}.zip"
  190. logger.info("Creating .zip source archive (%s)...", zip_path)
  191. if self.dry:
  192. zip_path.touch()
  193. else:
  194. with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zip_object:
  195. for git_file in git_files:
  196. 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)
  197. zip_info = zipfile.ZipInfo(filename=f"{archive_base}/{git_file.path}", date_time=file_data_time)
  198. zip_info.external_attr = git_file.mode << 16
  199. zip_info.compress_type = zipfile.ZIP_DEFLATED
  200. zip_object.writestr(zip_info, data=git_file.data)
  201. self.artifacts["src-zip"] = zip_path
  202. tar_types = (
  203. (".tar.gz", "gz"),
  204. (".tar.xz", "xz"),
  205. )
  206. for ext, comp in tar_types:
  207. tar_path = self.dist_path / f"{archive_base}{ext}"
  208. logger.info("Creating %s source archive (%s)...", ext, tar_path)
  209. if self.dry:
  210. tar_path.touch()
  211. else:
  212. with tarfile.open(tar_path, f"w:{comp}") as tar_object:
  213. for git_file in git_files:
  214. tar_info = tarfile.TarInfo(f"{archive_base}/{git_file.path}")
  215. tar_info.mode = git_file.mode
  216. tar_info.size = len(git_file.data)
  217. tar_info.mtime = git_file.time.timestamp()
  218. tar_object.addfile(tar_info, fileobj=io.BytesIO(git_file.data))
  219. if tar_path.suffix == ".gz":
  220. # Zero the embedded timestamp in the gzip'ed tarball
  221. with open(tar_path, "r+b") as f:
  222. f.seek(4, 0)
  223. f.write(b"\x00\x00\x00\x00")
  224. self.artifacts[f"src-tar-{comp}"] = tar_path
  225. def create_xcframework(self, configuration: str="Release") -> None:
  226. dmg_in = self.root / f"Xcode/SDL/build/SDL3.dmg"
  227. dmg_in.unlink(missing_ok=True)
  228. self.executer.run(["xcodebuild", "-project", str(self.root / "Xcode/SDL/SDL.xcodeproj"), "-target", "SDL3.dmg", "-configuration", configuration])
  229. if self.dry:
  230. dmg_in.parent.mkdir(parents=True, exist_ok=True)
  231. dmg_in.touch()
  232. assert dmg_in.is_file(), "SDL3.dmg was not created by xcodebuild"
  233. dmg_out = self.dist_path / f"{self.project}-{self.version}.dmg"
  234. shutil.copy(dmg_in, dmg_out)
  235. self.artifacts["dmg"] = dmg_out
  236. @property
  237. def git_hash_data(self) -> bytes:
  238. return f"{self.commit}\n".encode()
  239. def _tar_add_git_hash(self, tar_object: tarfile.TarFile, root: typing.Optional[str]=None, time: typing.Optional[datetime.datetime]=None):
  240. if not time:
  241. time = datetime.datetime(year=2024, month=4, day=1)
  242. path = GIT_HASH_FILENAME
  243. if root:
  244. path = f"{root}/{path}"
  245. tar_info = tarfile.TarInfo(path)
  246. tar_info.mode = 0o100644
  247. tar_info.size = len(self.git_hash_data)
  248. tar_info.mtime = int(time.timestamp())
  249. tar_object.addfile(tar_info, fileobj=io.BytesIO(self.git_hash_data))
  250. def _zip_add_git_hash(self, zip_file: zipfile.ZipFile, root: typing.Optional[str]=None, time: typing.Optional[datetime.datetime]=None):
  251. if not time:
  252. time = datetime.datetime(year=2024, month=4, day=1)
  253. path = GIT_HASH_FILENAME
  254. if root:
  255. path = f"{root}/{path}"
  256. file_data_time = (time.year, time.month, time.day, time.hour, time.minute, time.second)
  257. zip_info = zipfile.ZipInfo(filename=path, date_time=file_data_time)
  258. zip_info.external_attr = 0o100644 << 16
  259. zip_info.compress_type = zipfile.ZIP_DEFLATED
  260. zip_file.writestr(zip_info, data=self.git_hash_data)
  261. def create_mingw_archives(self) -> None:
  262. build_type = "Release"
  263. mingw_archs = ("i686", "x86_64")
  264. build_parent_dir = self.root / "build-mingw"
  265. zip_path = self.dist_path / f"{self.project}-devel-{self.version}-mingw.zip"
  266. tar_exts = ("gz", "xz")
  267. tar_paths = { ext: self.dist_path / f"{self.project}-devel-{self.version}-mingw.tar.{ext}" for ext in tar_exts}
  268. arch_install_paths = {}
  269. arch_files = {}
  270. for arch in mingw_archs:
  271. build_path = build_parent_dir / f"build-{arch}"
  272. install_path = build_parent_dir / f"install-{arch}"
  273. arch_install_paths[arch] = install_path
  274. shutil.rmtree(install_path, ignore_errors=True)
  275. build_path.mkdir(parents=True, exist_ok=True)
  276. with self.section_printer.group(f"Configuring MinGW {arch}"):
  277. self.executer.run([
  278. "cmake", "-S", str(self.root), "-B", str(build_path),
  279. "--fresh",
  280. f'''-DCMAKE_C_FLAGS="-ffile-prefix-map={self.root}=/src/{self.project}"''',
  281. f'''-DCMAKE_CXX_FLAGS="-ffile-prefix-map={self.root}=/src/{self.project}"''',
  282. "-DSDL_SHARED=ON",
  283. "-DSDL_STATIC=ON",
  284. "-DSDL_DISABLE_INSTALL_DOCS=ON",
  285. "-DSDL_TEST_LIBRARY=ON",
  286. "-DSDL_TESTS=OFF",
  287. "-DCMAKE_INSTALL_BINDIR=bin",
  288. "-DCMAKE_INSTALL_DATAROOTDIR=share",
  289. "-DCMAKE_INSTALL_INCLUDEDIR=include",
  290. "-DCMAKE_INSTALL_LIBDIR=lib",
  291. f"-DCMAKE_BUILD_TYPE={build_type}",
  292. f"-DCMAKE_TOOLCHAIN_FILE={self.root}/build-scripts/cmake-toolchain-mingw64-{arch}.cmake",
  293. f"-G{self.cmake_generator}",
  294. f"-DCMAKE_INSTALL_PREFIX={install_path}",
  295. ])
  296. with self.section_printer.group(f"Build MinGW {arch}"):
  297. self.executer.run(["cmake", "--build", str(build_path), "--verbose", "--config", build_type])
  298. with self.section_printer.group(f"Install MinGW {arch}"):
  299. self.executer.run(["cmake", "--install", str(build_path), "--strip", "--config", build_type])
  300. arch_files[arch] = list(Path(r) / f for r, _, files in os.walk(install_path) for f in files)
  301. extra_files = (
  302. ("build-scripts/pkg-support/mingw/INSTALL.txt", ""),
  303. ("build-scripts/pkg-support/mingw/Makefile", ""),
  304. ("build-scripts/pkg-support/mingw/cmake/SDL3Config.cmake", "cmake/"),
  305. ("build-scripts/pkg-support/mingw/cmake/SDL3ConfigVersion.cmake", "cmake/"),
  306. ("BUGS.txt", ""),
  307. ("CREDITS.md", ""),
  308. ("README-SDL.txt", ""),
  309. ("WhatsNew.txt", ""),
  310. ("LICENSE.txt", ""),
  311. ("README.md", ""),
  312. )
  313. test_files = list(Path(r) / f for r, _, files in os.walk(self.root / "test") for f in files)
  314. # FIXME: split SDL3.dll debug information into debug library
  315. # objcopy --only-keep-debug SDL3.dll SDL3.debug.dll
  316. # objcopy --add-gnu-debuglink=SDL3.debug.dll SDL3.dll
  317. # objcopy --strip-debug SDL3.dll
  318. for comp in tar_exts:
  319. logger.info("Creating %s...", tar_paths[comp])
  320. with tarfile.open(tar_paths[comp], f"w:{comp}") as tar_object:
  321. arc_root = f"{self.project}-{self.version}"
  322. for file_path, arcdirname in extra_files:
  323. assert not arcdirname or arcdirname[-1] == "/"
  324. arcname = f"{arc_root}/{arcdirname}{Path(file_path).name}"
  325. tar_object.add(self.root / file_path, arcname=arcname)
  326. for arch in mingw_archs:
  327. install_path = arch_install_paths[arch]
  328. arcname_parent = f"{arc_root}/{arch}-w64-mingw32"
  329. for file in arch_files[arch]:
  330. arcname = os.path.join(arcname_parent, file.relative_to(install_path))
  331. tar_object.add(file, arcname=arcname)
  332. for test_file in test_files:
  333. arcname = f"{arc_root}/test/{test_file.relative_to(self.root/'test')}"
  334. tar_object.add(test_file, arcname=arcname)
  335. self._tar_add_git_hash(tar_object=tar_object, root=arc_root)
  336. self.artifacts[f"mingw-devel-tar-{comp}"] = tar_paths[comp]
  337. def build_vs(self, arch: str, platform: str, vs: VisualStudio, configuration: str="Release") -> VcArchDevel:
  338. dll_path = self.root / f"VisualC/SDL/{platform}/{configuration}/{self.project}.dll"
  339. pdb_path = self.root / f"VisualC/SDL/{platform}/{configuration}/{self.project}.pdb"
  340. imp_path = self.root / f"VisualC/SDL/{platform}/{configuration}/{self.project}.lib"
  341. test_path = self.root / f"VisualC/SDL_test/{platform}/{configuration}/{self.project}_test.lib"
  342. dll_path.unlink(missing_ok=True)
  343. pdb_path.unlink(missing_ok=True)
  344. imp_path.unlink(missing_ok=True)
  345. test_path.unlink(missing_ok=True)
  346. projects = [
  347. self.root / "VisualC/SDL/SDL.vcxproj",
  348. self.root / "VisualC/SDL_test/SDL_test.vcxproj",
  349. ]
  350. with self.section_printer.group(f"Build {arch} VS binary"):
  351. vs.build(arch=arch, platform=platform, configuration=configuration, projects=projects)
  352. if self.dry:
  353. dll_path.parent.mkdir(parents=True, exist_ok=True)
  354. dll_path.touch()
  355. pdb_path.touch()
  356. imp_path.touch()
  357. test_path.parent.mkdir(parents=True, exist_ok=True)
  358. test_path.touch()
  359. assert dll_path.is_file(), "SDL3.dll has not been created"
  360. assert pdb_path.is_file(), "SDL3.pdb has not been created"
  361. assert imp_path.is_file(), "SDL3.lib has not been created"
  362. assert test_path.is_file(), "SDL3_test.lib has not been created"
  363. zip_path = self.dist_path / f"{self.project}-{self.version}-win32-{arch}.zip"
  364. zip_path.unlink(missing_ok=True)
  365. logger.info("Creating %s", zip_path)
  366. with zipfile.ZipFile(zip_path, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
  367. logger.debug("Adding %s", dll_path.name)
  368. zf.write(dll_path, arcname=dll_path.name)
  369. logger.debug("Adding %s", "README-SDL.txt")
  370. zf.write(self.root / "README-SDL.txt", arcname="README-SDL.txt")
  371. self._zip_add_git_hash(zip_file=zf)
  372. self.artifacts[f"VC-{arch}"] = zip_path
  373. return VcArchDevel(dll=dll_path, pdb=pdb_path, imp=imp_path, test=test_path)
  374. def build_vs_cmake(self, arch: str, arch_cmake: str) -> VcArchDevel:
  375. build_path = self.root / f"build-vs-{arch}"
  376. install_path = build_path / "prefix"
  377. dll_path = install_path / f"bin/{self.project}.dll"
  378. pdb_path = install_path / f"bin/{self.project}.pdb"
  379. imp_path = install_path / f"lib/{self.project}.lib"
  380. test_path = install_path / f"lib/{self.project}_test.lib"
  381. dll_path.unlink(missing_ok=True)
  382. pdb_path.unlink(missing_ok=True)
  383. imp_path.unlink(missing_ok=True)
  384. test_path.unlink(missing_ok=True)
  385. build_type = "Release"
  386. shutil.rmtree(install_path, ignore_errors=True)
  387. build_path.mkdir(parents=True, exist_ok=True)
  388. with self.section_printer.group(f"Configure VC CMake project for {arch}"):
  389. self.executer.run([
  390. "cmake", "-S", str(self.root), "-B", str(build_path),
  391. "--fresh",
  392. "-A", arch_cmake,
  393. "-DSDL_SHARED=ON",
  394. "-DSDL_STATIC=OFF",
  395. "-DSDL_DISABLE_INSTALL_DOCS=ON",
  396. "-DSDL_TEST_LIBRARY=ON",
  397. "-DSDL_TESTS=OFF",
  398. "-DCMAKE_INSTALL_BINDIR=bin",
  399. "-DCMAKE_INSTALL_DATAROOTDIR=share",
  400. "-DCMAKE_INSTALL_INCLUDEDIR=include",
  401. "-DCMAKE_INSTALL_LIBDIR=lib",
  402. f"-DCMAKE_BUILD_TYPE={build_type}",
  403. f"-DCMAKE_INSTALL_PREFIX={install_path}",
  404. # MSVC debug information format flags are selected by an abstraction
  405. "-DCMAKE_POLICY_DEFAULT_CMP0141=NEW",
  406. # MSVC debug information format
  407. "-DCMAKE_MSVC_DEBUG_INFORMATION_FORMAT=ProgramDatabase",
  408. # Linker flags for executables
  409. "-DCMAKE_EXE_LINKER_FLAGS=-DEBUG",
  410. # Linker flag for shared libraries
  411. "-DCMAKE_SHARED_LINKER_FLAGS=-INCREMENTAL:NO -DEBUG -OPT:REF -OPT:ICF",
  412. # MSVC runtime library flags are selected by an abstraction
  413. "-DCMAKE_POLICY_DEFAULT_CMP0091=NEW",
  414. # Use statically linked runtime (-MT) (ideally, should be "MultiThreaded$<$<CONFIG:Debug>:Debug>")
  415. "-DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded",
  416. ])
  417. with self.section_printer.group(f"Build VC CMake project for {arch}"):
  418. self.executer.run(["cmake", "--build", str(build_path), "--verbose", "--config", build_type])
  419. with self.section_printer.group(f"Install VC CMake project for {arch}"):
  420. self.executer.run(["cmake", "--install", str(build_path), "--config", build_type])
  421. assert dll_path.is_file(), "SDL3.dll has not been created"
  422. assert pdb_path.is_file(), "SDL3.pdb has not been created"
  423. assert imp_path.is_file(), "SDL3.lib has not been created"
  424. assert test_path.is_file(), "SDL3_test.lib has not been created"
  425. zip_path = self.dist_path / f"{self.project}-{self.version}-win32-{arch}.zip"
  426. zip_path.unlink(missing_ok=True)
  427. logger.info("Creating %s", zip_path)
  428. with zipfile.ZipFile(zip_path, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
  429. logger.debug("Adding %s", dll_path.name)
  430. zf.write(dll_path, arcname=dll_path.name)
  431. logger.debug("Adding %s", "README-SDL.txt")
  432. zf.write(self.root / "README-SDL.txt", arcname="README-SDL.txt")
  433. self._zip_add_git_hash(zip_file=zf)
  434. self.artifacts[f"VC-{arch}"] = zip_path
  435. return VcArchDevel(dll=dll_path, pdb=pdb_path, imp=imp_path, test=test_path)
  436. def build_vs_devel(self, arch_vc: dict[str, VcArchDevel]) -> None:
  437. zip_path = self.dist_path / f"{self.project}-devel-{self.version}-VC.zip"
  438. archive_prefix = f"{self.project}-{self.version}"
  439. def zip_file(zf: zipfile.ZipFile, path: Path, arcrelpath: str):
  440. arcname = f"{archive_prefix}/{arcrelpath}"
  441. logger.debug("Adding %s to %s", path, arcname)
  442. zf.write(path, arcname=arcname)
  443. def zip_directory(zf: zipfile.ZipFile, directory: Path, arcrelpath: str):
  444. for f in directory.iterdir():
  445. if f.is_file():
  446. arcname = f"{archive_prefix}/{arcrelpath}/{f.name}"
  447. logger.debug("Adding %s to %s", f, arcname)
  448. zf.write(f, arcname=arcname)
  449. with zipfile.ZipFile(zip_path, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
  450. for arch, binaries in arch_vc.items():
  451. zip_file(zf, path=binaries.dll, arcrelpath=f"lib/{arch}/{binaries.dll.name}")
  452. zip_file(zf, path=binaries.imp, arcrelpath=f"lib/{arch}/{binaries.imp.name}")
  453. zip_file(zf, path=binaries.pdb, arcrelpath=f"lib/{arch}/{binaries.pdb.name}")
  454. zip_file(zf, path=binaries.test, arcrelpath=f"lib/{arch}/{binaries.test.name}")
  455. zip_directory(zf, directory=self.root / "include/SDL3", arcrelpath="include/SDL3")
  456. zip_directory(zf, directory=self.root / "docs", arcrelpath="docs")
  457. zip_directory(zf, directory=self.root / "VisualC/pkg-support/cmake", arcrelpath="cmake")
  458. zip_file(zf, path=self.root / "cmake/sdlcpu.cmake", arcrelpath="cmake/sdlcpu.cmake")
  459. for txt in ("BUGS.txt", "README-SDL.txt", "WhatsNew.txt"):
  460. zip_file(zf, path=self.root / txt, arcrelpath=txt)
  461. zip_file(zf, path=self.root / "LICENSE.txt", arcrelpath="COPYING.txt")
  462. zip_file(zf, path=self.root / "README.md", arcrelpath="README.txt")
  463. self._zip_add_git_hash(zip_file=zf, root=archive_prefix)
  464. self.artifacts["VC-devel"] = zip_path
  465. def detect_android_api(self, android_home: str) -> typing.Optional[int]:
  466. platform_dirs = list(Path(p) for p in glob.glob(f"{android_home}/platforms/android-*"))
  467. re_platform = re.compile("android-([0-9]+)")
  468. platform_versions = []
  469. for platform_dir in platform_dirs:
  470. logger.debug("Found Android Platform SDK: %s", platform_dir)
  471. if m:= re_platform.match(platform_dir.name):
  472. platform_versions.append(int(m.group(1)))
  473. platform_versions.sort()
  474. logger.info("Available platform versions: %s", platform_versions)
  475. platform_versions = list(filter(lambda v: v >= ANDROID_MINIMUM_API, platform_versions))
  476. logger.info("Valid platform versions (>=%d): %s", ANDROID_MINIMUM_API, platform_versions)
  477. if not platform_versions:
  478. return None
  479. android_api = platform_versions[0]
  480. logger.info("Selected API version %d", android_api)
  481. return android_api
  482. def get_prefab_json_text(self) -> str:
  483. return textwrap.dedent(f"""\
  484. {{
  485. "schema_version": 2,
  486. "name": "{self.project}",
  487. "version": "{self.version}",
  488. "dependencies": []
  489. }}
  490. """)
  491. def get_prefab_module_json_text(self, library_name: str, extra_libs: list[str]) -> str:
  492. export_libraries_str = ", ".join(f"\"-l{lib}\"" for lib in extra_libs)
  493. return textwrap.dedent(f"""\
  494. {{
  495. "export_libraries": [{export_libraries_str}],
  496. "library_name": "lib{library_name}"
  497. }}
  498. """)
  499. def get_prefab_abi_json_text(self, abi: str, cpp: bool, shared: bool) -> str:
  500. return textwrap.dedent(f"""\
  501. {{
  502. "abi": "{abi}",
  503. "api": {ANDROID_MINIMUM_API},
  504. "ndk": {ANDROID_MINIMUM_NDK},
  505. "stl": "{'c++_shared' if cpp else 'none'}",
  506. "static": {'true' if not shared else 'false'}
  507. }}
  508. """)
  509. def get_android_manifest_text(self) -> str:
  510. return textwrap.dedent(f"""\
  511. <manifest
  512. xmlns:android="http://schemas.android.com/apk/res/android"
  513. package="org.libsdl.android.{self.project}" android:versionCode="1"
  514. android:versionName="1.0">
  515. <uses-sdk android:minSdkVersion="{ANDROID_MINIMUM_API}"
  516. android:targetSdkVersion="{ANDROID_TARGET_API}" />
  517. </manifest>
  518. """)
  519. def create_android_archives(self, android_api: int, android_home: Path, android_ndk_home: Path, android_abis: list[str]) -> None:
  520. cmake_toolchain_file = Path(android_ndk_home) / "build/cmake/android.toolchain.cmake"
  521. if not cmake_toolchain_file.exists():
  522. logger.error("CMake toolchain file does not exist (%s)", cmake_toolchain_file)
  523. raise SystemExit(1)
  524. aar_path = self.dist_path / f"{self.project}-{self.version}.aar"
  525. added_global_files = False
  526. with zipfile.ZipFile(aar_path, "w", compression=zipfile.ZIP_DEFLATED) as zip_object:
  527. def configure_file(path: Path) -> str:
  528. text = path.read_text()
  529. text = text.replace("@PROJECT_VERSION@", self.version)
  530. text = text.replace("@PROJECT_NAME@", self.project)
  531. return text
  532. install_txt = configure_file(self.root / "build-scripts/pkg-support/android/INSTALL.md.in")
  533. zip_object.writestr("INSTALL.md", install_txt)
  534. project_description = {
  535. "name": self.project,
  536. "version": self.version,
  537. "git-hash": self.commit,
  538. }
  539. zip_object.writestr("description.json", json.dumps(project_description, indent=0))
  540. main_py = configure_file(self.root / "build-scripts/pkg-support/android/__main__.py.in")
  541. zip_object.writestr("__main__.py", main_py)
  542. zip_object.writestr("AndroidManifest.xml", self.get_android_manifest_text())
  543. zip_object.write(self.root / "android-project/app/proguard-rules.pro", arcname="proguard.txt")
  544. zip_object.write(self.root / "LICENSE.txt", arcname="META-INF/LICENSE.txt")
  545. zip_object.write(self.root / "cmake/sdlcpu.cmake", arcname="cmake/sdlcpu.cmake")
  546. zip_object.write(self.root / "build-scripts/pkg-support/android/cmake/SDL3Config.cmake", arcname="cmake/SDL3Config.cmake")
  547. zip_object.write(self.root / "build-scripts/pkg-support/android/cmake/SDL3ConfigVersion.cmake", arcname="cmake/SDL3ConfigVersion.cmake")
  548. zip_object.writestr("prefab/prefab.json", self.get_prefab_json_text())
  549. self._zip_add_git_hash(zip_file=zip_object)
  550. for android_abi in android_abis:
  551. with self.section_printer.group(f"Building for Android {android_api} {android_abi}"):
  552. build_dir = self.root / "build-android" / f"{android_abi}-build"
  553. install_dir = self.root / "install-android" / f"{android_abi}-install"
  554. shutil.rmtree(install_dir, ignore_errors=True)
  555. assert not install_dir.is_dir(), f"{install_dir} should not exist prior to build"
  556. cmake_args = [
  557. "cmake",
  558. "-S", str(self.root),
  559. "-B", str(build_dir),
  560. "--fresh",
  561. f'''-DCMAKE_C_FLAGS="-ffile-prefix-map={self.root}=/src/{self.project}"''',
  562. f'''-DCMAKE_CXX_FLAGS="-ffile-prefix-map={self.root}=/src/{self.project}"''',
  563. "-DCMAKE_BUILD_TYPE=RelWithDebInfo",
  564. f"-DCMAKE_TOOLCHAIN_FILE={cmake_toolchain_file}",
  565. f"-DANDROID_PLATFORM={android_api}",
  566. f"-DANDROID_ABI={android_abi}",
  567. "-DCMAKE_POSITION_INDEPENDENT_CODE=ON",
  568. "-DSDL_SHARED=ON",
  569. "-DSDL_STATIC=OFF",
  570. "-DSDL_TEST_LIBRARY=ON",
  571. "-DSDL_DISABLE_ANDROID_JAR=OFF",
  572. "-DSDL_TESTS=OFF",
  573. f"-DCMAKE_INSTALL_PREFIX={install_dir}",
  574. "-DSDL_DISABLE_INSTALL=OFF",
  575. "-DSDL_DISABLE_INSTALL_DOCS=OFF",
  576. "-DCMAKE_INSTALL_INCLUDEDIR=include ",
  577. "-DCMAKE_INSTALL_LIBDIR=lib",
  578. "-DCMAKE_INSTALL_DATAROOTDIR=share",
  579. "-DCMAKE_BUILD_TYPE=Release",
  580. f"-DSDL_ANDROID_HOME={android_home}",
  581. f"-G{self.cmake_generator}",
  582. ]
  583. build_args = [
  584. "cmake",
  585. "--build", str(build_dir),
  586. "--config", "RelWithDebInfo",
  587. ]
  588. install_args = [
  589. "cmake",
  590. "--install", str(build_dir),
  591. "--config", "RelWithDebInfo",
  592. ]
  593. self.executer.run(cmake_args)
  594. self.executer.run(build_args)
  595. self.executer.run(install_args)
  596. main_so_library = install_dir / "lib" / f"lib{self.project}.so"
  597. logger.debug("Expecting library %s", main_so_library)
  598. assert main_so_library.is_file(), "CMake should have built a shared library (e.g. libSDL3.so)"
  599. test_library = install_dir / "lib" / f"lib{self.project}_test.a"
  600. logger.debug("Expecting library %s", test_library)
  601. assert test_library.is_file(), "CMake should have built a static test library (e.g. libSDL3_test.a)"
  602. java_jar = install_dir / f"share/java/{self.project}/{self.project}-{self.version}.jar"
  603. logger.debug("Expecting java archive: %s", java_jar)
  604. assert java_jar.is_file(), "CMake should have compiled the java sources and archived them into a JAR"
  605. javasources_jar = install_dir / f"share/java/{self.project}/{self.project}-{self.version}-sources.jar"
  606. logger.debug("Expecting java sources archive %s", javasources_jar)
  607. assert javasources_jar.is_file(), "CMake should have archived the java sources into a JAR"
  608. javadoc_dir = install_dir / "share/javadoc" / self.project
  609. logger.debug("Expecting javadoc archive %s", javadoc_dir)
  610. assert javadoc_dir.is_dir(), "CMake should have built javadoc documentation for the java sources"
  611. if not added_global_files:
  612. zip_object.write(java_jar, arcname="classes.jar")
  613. zip_object.write(javasources_jar, arcname="classes-sources.jar", )
  614. doc_jar_path = install_dir / "classes-doc.jar"
  615. javadoc_jar_args = ["jar", "--create", "--file", str(doc_jar_path)]
  616. for fn in javadoc_dir.iterdir():
  617. javadoc_jar_args.extend(["-C", str(javadoc_dir), fn.name])
  618. self.executer.run(javadoc_jar_args)
  619. zip_object.write(doc_jar_path, arcname="classes-doc.jar")
  620. for header in (install_dir / "include" / self.project).iterdir():
  621. zip_object.write(header, arcname=f"prefab/modules/{self.project}/include/{self.project}/{header.name}")
  622. zip_object.writestr(f"prefab/modules/{self.project}/module.json", self.get_prefab_module_json_text(library_name=self.project, extra_libs=[]))
  623. zip_object.writestr(f"prefab/modules/{self.project}_test/module.json", self.get_prefab_module_json_text(library_name=f"{self.project}_test", extra_libs=list()))
  624. added_global_files = True
  625. zip_object.write(main_so_library, arcname=f"prefab/modules/{self.project}/libs/android.{android_abi}/lib{self.project}.so")
  626. zip_object.writestr(f"prefab/modules/{self.project}/libs/android.{android_abi}/abi.json", self.get_prefab_abi_json_text(abi=android_abi, cpp=False, shared=True))
  627. zip_object.write(test_library, arcname=f"prefab/modules/{self.project}_test/libs/android.{android_abi}/lib{self.project}_test.a")
  628. zip_object.writestr(f"prefab/modules/{self.project}_test/libs/android.{android_abi}/abi.json", self.get_prefab_abi_json_text(abi=android_abi, cpp=False, shared=False))
  629. self.artifacts[f"android-aar"] = aar_path
  630. @classmethod
  631. def extract_sdl_version(cls, root: Path, project: str) -> str:
  632. with open(root / f"include/{project}/SDL_version.h", "r") as f:
  633. text = f.read()
  634. major = next(re.finditer(r"^#define SDL_MAJOR_VERSION\s+([0-9]+)$", text, flags=re.M)).group(1)
  635. minor = next(re.finditer(r"^#define SDL_MINOR_VERSION\s+([0-9]+)$", text, flags=re.M)).group(1)
  636. micro = next(re.finditer(r"^#define SDL_MICRO_VERSION\s+([0-9]+)$", text, flags=re.M)).group(1)
  637. return f"{major}.{minor}.{micro}"
  638. def main(argv=None) -> int:
  639. parser = argparse.ArgumentParser(allow_abbrev=False, description="Create SDL release artifacts")
  640. parser.add_argument("--root", metavar="DIR", type=Path, default=Path(__file__).absolute().parents[1], help="Root of SDL")
  641. parser.add_argument("--out", "-o", metavar="DIR", dest="dist_path", type=Path, default="dist", help="Output directory")
  642. parser.add_argument("--github", action="store_true", help="Script is running on a GitHub runner")
  643. parser.add_argument("--commit", default="HEAD", help="Git commit/tag of which a release should be created")
  644. parser.add_argument("--project", required=True, help="Name of the project (e.g. SDL3")
  645. parser.add_argument("--create", choices=["source", "mingw", "win32", "xcframework", "android"], required=True, action="append", dest="actions", help="What to do")
  646. parser.set_defaults(loglevel=logging.INFO)
  647. parser.add_argument('--vs-year', dest="vs_year", help="Visual Studio year")
  648. parser.add_argument('--android-api', type=int, dest="android_api", help="Android API version")
  649. parser.add_argument('--android-home', dest="android_home", default=os.environ.get("ANDROID_HOME"), help="Android Home folder")
  650. parser.add_argument('--android-ndk-home', dest="android_ndk_home", default=os.environ.get("ANDROID_NDK_HOME"), help="Android NDK Home folder")
  651. parser.add_argument('--android-abis', dest="android_abis", nargs="*", choices=ANDROID_AVAILABLE_ABIS, default=list(ANDROID_AVAILABLE_ABIS), help="Android NDK Home folder")
  652. parser.add_argument('--cmake-generator', dest="cmake_generator", default="Ninja", help="CMake Generator")
  653. parser.add_argument('--debug', action='store_const', const=logging.DEBUG, dest="loglevel", help="Print script debug information")
  654. parser.add_argument('--dry-run', action='store_true', dest="dry", help="Don't execute anything")
  655. parser.add_argument('--force', action='store_true', dest="force", help="Ignore a non-clean git tree")
  656. args = parser.parse_args(argv)
  657. logging.basicConfig(level=args.loglevel, format='[%(levelname)s] %(message)s')
  658. args.actions = set(args.actions)
  659. args.dist_path = args.dist_path.absolute()
  660. args.root = args.root.absolute()
  661. args.dist_path = args.dist_path.absolute()
  662. if args.dry:
  663. args.dist_path = args.dist_path / "dry"
  664. if args.github:
  665. section_printer: SectionPrinter = GitHubSectionPrinter()
  666. else:
  667. section_printer = SectionPrinter()
  668. executer = Executer(root=args.root, dry=args.dry)
  669. root_git_hash_path = args.root / GIT_HASH_FILENAME
  670. root_is_maybe_archive = root_git_hash_path.is_file()
  671. if root_is_maybe_archive:
  672. logger.warning("%s detected: Building from archive", GIT_HASH_FILENAME)
  673. archive_commit = root_git_hash_path.read_text().strip()
  674. if args.commit != archive_commit:
  675. logger.warning("Commit argument is %s, but archive commit is %s. Using %s.", args.commit, archive_commit, archive_commit)
  676. args.commit = archive_commit
  677. else:
  678. args.commit = executer.run(["git", "rev-parse", args.commit], stdout=True, dry_out="e5812a9fd2cda317b503325a702ba3c1c37861d9").stdout.strip()
  679. logger.info("Using commit %s", args.commit)
  680. releaser = Releaser(
  681. project=args.project,
  682. commit=args.commit,
  683. root=args.root,
  684. dist_path=args.dist_path,
  685. executer=executer,
  686. section_printer=section_printer,
  687. cmake_generator=args.cmake_generator,
  688. )
  689. if root_is_maybe_archive:
  690. logger.warning("Building from archive. Skipping clean git tree check.")
  691. else:
  692. porcelain_status = executer.run(["git", "status", "--ignored", "--porcelain"], stdout=True, dry_out="\n").stdout.strip()
  693. if porcelain_status:
  694. print(porcelain_status)
  695. logger.warning("The tree is dirty! Do not publish any generated artifacts!")
  696. if not args.force:
  697. raise Exception("The git repo contains modified and/or non-committed files. Run with --force to ignore.")
  698. with section_printer.group("Arguments"):
  699. print(f"project = {args.project}")
  700. print(f"version = {releaser.version}")
  701. print(f"commit = {args.commit}")
  702. print(f"out = {args.dist_path}")
  703. print(f"actions = {args.actions}")
  704. print(f"dry = {args.dry}")
  705. print(f"force = {args.force}")
  706. print(f"cmake_generator = {args.cmake_generator}")
  707. releaser.prepare()
  708. if "source" in args.actions:
  709. if root_is_maybe_archive:
  710. raise Exception("Cannot build source archive from source archive")
  711. with section_printer.group("Create source archives"):
  712. releaser.create_source_archives()
  713. if "xcframework" in args.actions:
  714. if platform.system() != "Darwin" and not args.dry:
  715. parser.error("xcframework artifact(s) can only be built on Darwin")
  716. releaser.create_xcframework()
  717. if "win32" in args.actions:
  718. if platform.system() != "Windows" and not args.dry:
  719. parser.error("win32 artifact(s) can only be built on Windows")
  720. with section_printer.group("Find Visual Studio"):
  721. vs = VisualStudio(executer=executer)
  722. arm64 = releaser.build_vs_cmake(arch="arm64", arch_cmake="ARM64")
  723. x86 = releaser.build_vs(arch="x86", platform="Win32", vs=vs)
  724. x64 = releaser.build_vs(arch="x64", platform="x64", vs=vs)
  725. with section_printer.group("Create SDL VC development zip"):
  726. arch_vc = {
  727. "x86": x86,
  728. "x64": x64,
  729. "arm64": arm64,
  730. }
  731. releaser.build_vs_devel(arch_vc)
  732. if "mingw" in args.actions:
  733. releaser.create_mingw_archives()
  734. if "android" in args.actions:
  735. if args.android_home is None or not Path(args.android_home).is_dir():
  736. parser.error("Invalid $ANDROID_HOME or --android-home: must be a directory containing the Android SDK")
  737. if args.android_ndk_home is None or not Path(args.android_ndk_home).is_dir():
  738. parser.error("Invalid $ANDROID_NDK_HOME or --android_ndk_home: must be a directory containing the Android NDK")
  739. if args.android_api is None:
  740. with section_printer.group("Detect Android APIS"):
  741. args.android_api = releaser.detect_android_api(android_home=args.android_home)
  742. if args.android_api is None or not (Path(args.android_home) / f"platforms/android-{args.android_api}").is_dir():
  743. parser.error("Invalid --android-api, and/or could not be detected")
  744. if not args.android_abis:
  745. parser.error("Need at least one Android ABI")
  746. with section_printer.group("Android arguments"):
  747. print(f"android_home = {args.android_home}")
  748. print(f"android_ndk_home = {args.android_ndk_home}")
  749. print(f"android_api = {args.android_api}")
  750. print(f"android_abis = {args.android_abis}")
  751. releaser.create_android_archives(
  752. android_api=args.android_api,
  753. android_home=args.android_home,
  754. android_ndk_home=args.android_ndk_home,
  755. android_abis=args.android_abis,
  756. )
  757. with section_printer.group("Summary"):
  758. print(f"artifacts = {releaser.artifacts}")
  759. if args.github:
  760. if args.dry:
  761. os.environ["GITHUB_OUTPUT"] = "/tmp/github_output.txt"
  762. with open(os.environ["GITHUB_OUTPUT"], "a") as f:
  763. f.write(f"project={releaser.project}\n")
  764. f.write(f"version={releaser.version}\n")
  765. for k, v in releaser.artifacts.items():
  766. f.write(f"{k}={v.name}\n")
  767. return 0
  768. if __name__ == "__main__":
  769. raise SystemExit(main())