fix(packaging): validate and stage release archives

This commit is contained in:
MrMha 2026-10-11 18:23:59 +04:00
parent a11c8f7f70
commit 5ccc11642f
3 changed files with 202 additions and 12 deletions

View File

@ -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()

View File

@ -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)

View File

@ -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()