diff --git a/CMakeLists.txt b/CMakeLists.txt index 5cb4fb85c..0fb42cb0d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -65,6 +65,8 @@ if(BUILD_TESTING) ${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_pr_overlap_exports.py) add_test(NAME nid_database_cache COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_nid_names.py) + add_test(NAME release_package COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tools/tests/test_package_release.py) endif() endif() diff --git a/tools/package_release.py b/tools/package_release.py index 53add7365..476314e0e 100644 --- a/tools/package_release.py +++ b/tools/package_release.py @@ -2,15 +2,18 @@ import argparse import re import shutil import tarfile +import tempfile import zipfile from pathlib import Path +ROOT = Path(__file__).resolve().parent.parent + def package(platform, build, output, version): if not re.fullmatch(r"v[0-9A-Za-z][0-9A-Za-z._-]*", version) or version.endswith("."): raise ValueError(f"Invalid release tag for asset filenames: {version}") libraries = sorted((build / "core/libs/libs").glob("*.prx")) - expected = {f"{directory.name}.prx" for directory in Path("core/libs/prx").iterdir() if directory.is_dir()} + expected = {f"{directory.name}.prx" for directory in (ROOT / "core/libs/prx").iterdir() if directory.is_dir() and not directory.name.startswith(".")} missing = expected - {library.name for library in libraries} if missing: raise RuntimeError(f"Missing patched libraries: {', '.join(sorted(missing))}") @@ -24,25 +27,29 @@ def package(platform, build, output, version): if not file.is_file() or file.stat().st_size == 0: raise RuntimeError(f"Missing or empty release file: {file}") output.mkdir(parents=True, exist_ok=True) - with zipfile.ZipFile(output / f"prx-{platform}-{version}.zip", "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: - for file in files: - archive.write(file, arcname=f"libs/{file.name}") - with tarfile.open(output / f"prx-{platform}-{version}.tar.gz", "w:gz", compresslevel=9) as archive: - for file in files: - archive.add(file, arcname=f"libs/{file.name}") asset = f"relinker-{version}.exe" if platform == "windows" else f"relinker-{version}" - shutil.copy2(binary, output / asset) + with tempfile.TemporaryDirectory(prefix=".release-", dir=output) as temporary: + staging = Path(temporary) + with zipfile.ZipFile(staging / f"prx-{platform}-{version}.zip", "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: + for file in files: + archive.write(file, arcname=f"libs/{file.name}") + with tarfile.open(staging / f"prx-{platform}-{version}.tar.gz", "w:gz", compresslevel=9, dereference=True) as archive: + for file in files: + archive.add(file, arcname=f"libs/{file.name}") + shutil.copy2(binary, staging / asset) + for file in sorted(staging.iterdir()): + file.replace(output / file.name) def collect_docs(source, output): - documents = sorted(source.rglob("*.md")) + documents = sorted(document for document in source.rglob("*.md") if document.is_file()) if not documents: raise RuntimeError(f"No Markdown documents found in {source}") - names = set() + names = {file.name.casefold() for file in output.iterdir()} if output.exists() else set() for document in documents: - if document.name in names or (output / document.name).exists(): + if document.name.casefold() in names: raise RuntimeError(f"Duplicate release asset name: {document.name}") - names.add(document.name) + names.add(document.name.casefold()) output.mkdir(parents=True, exist_ok=True) for document in documents: shutil.copy2(document, output / document.name) diff --git a/tools/tests/test_package_release.py b/tools/tests/test_package_release.py new file mode 100644 index 000000000..c00c6b237 --- /dev/null +++ b/tools/tests/test_package_release.py @@ -0,0 +1,181 @@ +import os +import sys +import tarfile +import tempfile +import unittest +import zipfile +from contextlib import contextmanager +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import package_release + + +@contextmanager +def working_directory(path): + previous = Path.cwd() + os.chdir(path) + try: + yield + finally: + os.chdir(previous) + + +class ReleasePackageTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + self.source = self.root / "source" + self.build = self.root / "build" + self.output = self.root / "output" + (self.source / "core/libs/prx/libc").mkdir(parents=True) + (self.source / "core/libs/prx/libc/Export.cpp").write_text("", encoding="utf-8") + libraries = self.build / "core/libs/libs" + libraries.mkdir(parents=True) + self.library = libraries / "libc.prx" + self.library.write_bytes(b"library bytes") + binary = self.build / "core/relinker/relinker" + binary.parent.mkdir(parents=True) + binary.write_bytes(b"relinker bytes") + self.addCleanup(patch.stopall) + patch.object(package_release, "ROOT", self.source, create=True).start() + + def test_source_discovery_does_not_depend_on_working_directory(self): + with working_directory(self.root): + package_release.package("linux", self.build, self.output, "v1.0") + self.assertEqual((self.output / "relinker-v1.0").read_bytes(), b"relinker bytes") + with zipfile.ZipFile(self.output / "prx-linux-v1.0.zip") as archive: + self.assertEqual(archive.read("libs/libc.prx"), b"library bytes") + + def test_hidden_cache_directory_is_not_a_required_library(self): + (self.source / "core/libs/prx/.cache").mkdir() + with working_directory(self.source): + package_release.package("linux", self.build, self.output, "v1.0") + with zipfile.ZipFile(self.output / "prx-linux-v1.0.zip") as archive: + self.assertEqual(archive.namelist(), ["libs/libc.prx"]) + + def test_tar_and_zip_include_the_same_symlink_contents(self): + target = self.library.with_name("libc.target") + self.library.rename(target) + try: + self.library.symlink_to(target.name) + except (OSError, NotImplementedError) as error: + self.skipTest(str(error)) + with working_directory(self.source): + package_release.package("linux", self.build, self.output, "v1.0") + with tarfile.open(self.output / "prx-linux-v1.0.tar.gz") as archive: + member = archive.getmember("libs/libc.prx") + self.assertTrue(member.isfile()) + self.assertEqual(archive.extractfile(member).read(), b"library bytes") + with zipfile.ZipFile(self.output / "prx-linux-v1.0.zip") as archive: + self.assertEqual(archive.read("libs/libc.prx"), b"library bytes") + + def test_missing_real_library_still_fails(self): + self.library.unlink() + with working_directory(self.source), self.assertRaisesRegex(RuntimeError, "Missing patched libraries"): + package_release.package("linux", self.build, self.output, "v1.0") + + def test_invalid_tag_creates_no_assets(self): + with self.assertRaises(ValueError): + package_release.package("linux", self.build, self.output, "../v1.0") + self.assertFalse(self.output.exists()) + + + +class DocumentationAssetsTests(unittest.TestCase): + def test_unique_nested_documents_are_flattened(self): + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source" + (source / "nested").mkdir(parents=True) + (source / "A.md").write_text("A", encoding="utf-8") + (source / "nested/B.md").write_text("B", encoding="utf-8") + output = Path(tmp) / "assets" + package_release.collect_docs(source, output) + self.assertEqual({file.name: file.read_text(encoding="utf-8") for file in output.iterdir()}, {"A.md": "A", "B.md": "B"}) + + def test_existing_case_collision_preserves_assets(self): + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source" + source.mkdir() + (source / "guide.md").write_text("new guide", encoding="utf-8") + output = Path(tmp) / "assets" + output.mkdir() + (output / "Guide.md").write_text("old guide", encoding="utf-8") + with self.assertRaisesRegex(RuntimeError, "Duplicate release asset"): + package_release.collect_docs(source, output) + self.assertEqual([file.name for file in output.iterdir()], ["Guide.md"]) + self.assertEqual((output / "Guide.md").read_text(encoding="utf-8"), "old guide") + + def test_markdown_directory_is_ignored(self): + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source" + source.mkdir() + (source / "A.md").write_text("A", encoding="utf-8") + (source / "Z.md").mkdir() + output = Path(tmp) / "assets" + package_release.collect_docs(source, output) + self.assertEqual([file.name for file in output.iterdir()], ["A.md"]) + self.assertEqual((output / "A.md").read_text(encoding="utf-8"), "A") + + def test_case_colliding_assets_fail_before_copying(self): + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source" + (source / "one").mkdir(parents=True) + (source / "two").mkdir() + (source / "one/Guide.md").write_text("one", encoding="utf-8") + (source / "two/guide.md").write_text("two", encoding="utf-8") + output = Path(tmp) / "assets" + with self.assertRaisesRegex(RuntimeError, "Duplicate release asset"): + package_release.collect_docs(source, output) + self.assertFalse(output.exists()) + + def test_dangling_asset_symlink_is_not_followed(self): + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source" + source.mkdir() + (source / "Guide.md").write_text("guide", encoding="utf-8") + output = Path(tmp) / "assets" + output.mkdir() + target = Path(tmp) / "outside.md" + try: + (output / "Guide.md").symlink_to(target) + except (OSError, NotImplementedError) as error: + self.skipTest(str(error)) + with self.assertRaisesRegex(RuntimeError, "Duplicate release asset"): + package_release.collect_docs(source, output) + self.assertFalse(target.exists()) + self.assertTrue((output / "Guide.md").is_symlink()) + + +class ReleaseAssetsTests(unittest.TestCase): + def test_failed_tar_creation_preserves_existing_release_assets(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source" + (source / "core/libs/prx/libc").mkdir(parents=True) + build = root / "build" + (build / "core/libs/libs").mkdir(parents=True) + (build / "core/libs/libs/libc.prx").write_bytes(b"new library") + (build / "core/relinker").mkdir(parents=True) + (build / "core/relinker/relinker").write_bytes(b"new executable") + output = root / "assets" + output.mkdir() + names = ["prx-linux-v1.zip", "prx-linux-v1.tar.gz", "relinker-v1"] + for name in names: + (output / name).write_bytes(b"old " + name.encode()) + previous = Path.cwd() + os.chdir(source) + self.addCleanup(os.chdir, previous) + with patch.object(package_release, "ROOT", source), \ + patch.object(package_release.tarfile, "open", side_effect=OSError("disk full")), \ + self.assertRaisesRegex(OSError, "disk full"): + package_release.package("linux", build, output, "v1") + self.assertEqual(sorted(file.name for file in output.iterdir()), sorted(names)) + for name in names: + self.assertEqual((output / name).read_bytes(), b"old " + name.encode()) + + +if __name__ == "__main__": + unittest.main()