fix(packaging): validate and stage release archives
This commit is contained in:
parent
a11c8f7f70
commit
5ccc11642f
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
Loading…
Reference in New Issue