mirror of
https://github.com/suyu-emu/drippu-rep.git
synced 2026-09-21 14:44:58 -07:00
Replace silent except-pass in smoke_libretro with logged warnings. FreeLibrary and dll_directory.close failures must not fail the release after the core already passed, but they should be visible.
382 lines
16 KiB
Python
382 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""Verify packaged desktop and libretro artifacts on their native runners.
|
|
|
|
The release workflow calls this script before uploading each artifact. It
|
|
deliberately uses only the standard library so the check also works on the
|
|
minimal publish runner and can be reused by downstream packagers.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import ctypes
|
|
import os
|
|
import plistlib
|
|
from pathlib import Path
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
import zipfile
|
|
|
|
|
|
def fail(message: str) -> None:
|
|
raise RuntimeError(message)
|
|
|
|
|
|
def unpack(archive: Path, destination: Path) -> None:
|
|
destination_root = destination.resolve()
|
|
if archive.suffix == ".zip":
|
|
with zipfile.ZipFile(archive) as package:
|
|
bad = package.testzip()
|
|
if bad:
|
|
fail(f"corrupt zip member: {bad}")
|
|
members = package.infolist()
|
|
for member in members:
|
|
target = (destination / member.filename).resolve()
|
|
mode = (member.external_attr >> 16) & 0xFFFF
|
|
if destination_root not in target.parents and target != destination_root:
|
|
fail(f"unsafe zip member: {member.filename}")
|
|
if stat.S_ISLNK(mode):
|
|
fail(f"symlink zip member is not allowed: {member.filename}")
|
|
for member in members:
|
|
package.extract(member, destination)
|
|
return
|
|
if archive.name.endswith((".tar.gz", ".tgz")):
|
|
with tarfile.open(archive, "r:gz") as package:
|
|
members = package.getmembers()
|
|
for member in members:
|
|
target = (destination / member.name).resolve()
|
|
if destination_root not in target.parents and target != destination_root:
|
|
fail(f"unsafe tar member: {member.name}")
|
|
if member.issym():
|
|
# macOS frameworks use relative symlinks for Versions/Current
|
|
# and their top-level binary/resources. Permit those only when
|
|
# the resolved link remains inside the extraction directory.
|
|
link_target = (destination / member.name).parent / member.linkname
|
|
resolved_link = link_target.resolve()
|
|
if member.linkname.startswith("/") or (
|
|
destination_root not in resolved_link.parents
|
|
and resolved_link != destination_root
|
|
):
|
|
fail(f"unsafe tar symlink: {member.name} -> {member.linkname}")
|
|
elif member.islnk() or member.isdev() or member.isfifo():
|
|
fail(f"special tar member is not allowed: {member.name}")
|
|
package.extractall(destination)
|
|
return
|
|
if archive.suffix == ".apk":
|
|
with zipfile.ZipFile(archive) as package:
|
|
bad = package.testzip()
|
|
if bad:
|
|
fail(f"corrupt APK member: {bad}")
|
|
names = set(package.namelist())
|
|
if "AndroidManifest.xml" not in names:
|
|
fail("APK has no AndroidManifest.xml")
|
|
for member in package.infolist():
|
|
target = (destination / member.filename).resolve()
|
|
mode = (member.external_attr >> 16) & 0xFFFF
|
|
if destination_root not in target.parents and target != destination_root:
|
|
fail(f"unsafe APK member: {member.filename}")
|
|
if stat.S_ISLNK(mode):
|
|
fail(f"symlink APK member is not allowed: {member.filename}")
|
|
package.extract(member, destination)
|
|
return
|
|
fail(f"unsupported archive: {archive}")
|
|
|
|
|
|
def run(command: list[str], *, timeout: int = 30, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
|
|
return subprocess.run(command, check=False, capture_output=True, text=True, timeout=timeout, env=env)
|
|
|
|
|
|
def check_dependencies(binary: Path, target: str) -> None:
|
|
if target == "linux":
|
|
tool = shutil.which("ldd")
|
|
if not tool:
|
|
fail("ldd is required for Linux dependency validation")
|
|
result = run([tool, str(binary)])
|
|
if result.returncode != 0:
|
|
fail(f"ldd failed for {binary}: {result.stderr.strip()}")
|
|
if "not found" in result.stdout:
|
|
fail(f"unresolved Linux dependency for {binary}:\n{result.stdout}")
|
|
elif target == "macos":
|
|
tool = shutil.which("otool")
|
|
if not tool:
|
|
fail("otool is required for macOS dependency validation")
|
|
result = run([tool, "-L", str(binary)])
|
|
if result.returncode != 0:
|
|
fail(f"otool failed for {binary}: {result.stderr.strip()}")
|
|
if "not found" in result.stdout:
|
|
fail(f"unresolved macOS dependency for {binary}:\n{result.stdout}")
|
|
elif target == "windows":
|
|
# dumpbin is provided by the MSVC developer shell in the packaging job.
|
|
# It verifies that the PE is readable and records the import table; the
|
|
# process launch below verifies that the packaged DLL search path works.
|
|
tool = shutil.which("dumpbin")
|
|
if not tool:
|
|
fail("dumpbin is required for Windows dependency validation")
|
|
result = run([tool, "/DEPENDENTS", str(binary)])
|
|
if result.returncode != 0:
|
|
fail(f"dumpbin failed for {binary}: {result.stderr.strip()}")
|
|
|
|
|
|
def desktop_binaries(root: Path, target: str) -> list[Path]:
|
|
if target == "windows":
|
|
names = ("drippu.exe", "drippu-cmd.exe")
|
|
elif target == "macos":
|
|
names = ("drippu", "drippu-cmd")
|
|
else:
|
|
names = ("drippu", "drippu-cmd")
|
|
candidates: list[Path] = []
|
|
for name in names:
|
|
paths = [root / name, root / "_pkg" / name]
|
|
if target == "macos" and name == "drippu":
|
|
bundle_bin = root / "drippu.app" / "Contents" / "MacOS"
|
|
bundle_executable = None
|
|
info_plist = bundle_bin.parent / "Info.plist"
|
|
if info_plist.is_file():
|
|
try:
|
|
with info_plist.open("rb") as stream:
|
|
executable_name = plistlib.load(stream).get("CFBundleExecutable")
|
|
if isinstance(executable_name, str):
|
|
candidate = bundle_bin / executable_name
|
|
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
bundle_executable = candidate
|
|
except (OSError, plistlib.InvalidFileException, ValueError):
|
|
pass
|
|
if bundle_executable is None:
|
|
bundle_executable = next(
|
|
(
|
|
path
|
|
for path in sorted(bundle_bin.glob("*"))
|
|
if path.is_file() and os.access(path, os.X_OK)
|
|
),
|
|
None,
|
|
)
|
|
if bundle_executable is not None:
|
|
paths[0:0] = [bundle_executable]
|
|
for candidate in paths:
|
|
if candidate.is_file() and os.access(candidate, os.X_OK if target != "windows" else os.R_OK):
|
|
candidates.append(candidate)
|
|
break
|
|
if not candidates:
|
|
fail(f"packaged desktop executable not found under {root}")
|
|
return candidates
|
|
|
|
|
|
def launch_desktop(binary: Path, target: str) -> None:
|
|
env = os.environ.copy()
|
|
if target == "linux":
|
|
env["QT_QPA_PLATFORM"] = "offscreen"
|
|
elif target == "macos":
|
|
# Release bundles carry the native Cocoa plugin, not Qt's optional
|
|
# offscreen plugin. GitHub's macOS runner provides a WindowServer.
|
|
env["QT_QPA_PLATFORM"] = "cocoa"
|
|
command = [str(binary), "--help"]
|
|
try:
|
|
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env)
|
|
except OSError as exc:
|
|
fail(f"could not launch packaged desktop executable {binary}: {exc}")
|
|
try:
|
|
stdout, stderr = process.communicate(timeout=12)
|
|
except subprocess.TimeoutExpired:
|
|
# A GUI that remains alive proves that its packaged runtime initialized;
|
|
# terminate it so CI never leaves a desktop process behind.
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=3)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
return
|
|
if process.returncode not in (0,):
|
|
fail(f"packaged desktop launch failed ({process.returncode}): {stderr[-2000:]}\n{stdout[-1000:]}")
|
|
|
|
|
|
def smoke_cli(binary: Path, target: str) -> None:
|
|
"""Run each packaged command-line binary with a bounded success probe."""
|
|
env = os.environ.copy()
|
|
if target == "linux":
|
|
env["QT_QPA_PLATFORM"] = "offscreen"
|
|
elif target == "macos":
|
|
env["QT_QPA_PLATFORM"] = "cocoa"
|
|
try:
|
|
result = run([str(binary), "--version"], timeout=12, env=env)
|
|
except OSError as exc:
|
|
fail(f"could not launch packaged command-line executable {binary}: {exc}")
|
|
if result.returncode != 0:
|
|
fail(f"packaged command-line smoke failed ({result.returncode}) for {binary}: {result.stderr[-2000:]}")
|
|
|
|
|
|
class RetroSystemInfo(ctypes.Structure):
|
|
_fields_ = [
|
|
("library_name", ctypes.c_char_p),
|
|
("library_version", ctypes.c_char_p),
|
|
("valid_extensions", ctypes.c_char_p),
|
|
("need_fullpath", ctypes.c_bool),
|
|
("block_extract", ctypes.c_bool),
|
|
]
|
|
|
|
|
|
def libretro_binary(root: Path) -> Path:
|
|
candidates = sorted(
|
|
path for path in root.rglob("*") if path.is_file() and path.suffix.lower() in (".so", ".dll", ".dylib")
|
|
)
|
|
if not candidates:
|
|
fail(f"no libretro shared library found under {root}")
|
|
candidates.sort(key=lambda path: (0 if "libretro" in path.stem.lower() else 1, str(path)))
|
|
return candidates[0]
|
|
|
|
|
|
def smoke_libretro(binary: Path, target: str) -> None:
|
|
check_dependencies(binary, target)
|
|
dll_directory = None
|
|
old_library_path: str | None = None
|
|
if target == "windows" and hasattr(os, "add_dll_directory"):
|
|
dll_directory = os.add_dll_directory(str(binary.parent))
|
|
elif target == "linux":
|
|
old_library_path = os.environ.get("LD_LIBRARY_PATH")
|
|
os.environ["LD_LIBRARY_PATH"] = str(binary.parent) + (
|
|
os.pathsep + old_library_path if old_library_path else ""
|
|
)
|
|
core = None
|
|
try:
|
|
try:
|
|
core = ctypes.CDLL(str(binary))
|
|
except OSError as exc:
|
|
fail(f"could not load packaged libretro core {binary}: {exc}")
|
|
try:
|
|
api_version = core.retro_api_version
|
|
api_version.restype = ctypes.c_uint
|
|
if api_version() != 1:
|
|
fail("libretro API version is not 1")
|
|
get_info = core.retro_get_system_info
|
|
get_info.argtypes = [ctypes.POINTER(RetroSystemInfo)]
|
|
get_info.restype = None
|
|
info = RetroSystemInfo()
|
|
get_info(ctypes.byref(info))
|
|
if not info.library_name or not info.library_version or not info.valid_extensions:
|
|
fail("retro_get_system_info returned incomplete metadata")
|
|
except AttributeError as exc:
|
|
fail(f"packaged libretro core is missing required API: {exc}")
|
|
finally:
|
|
if target == "windows" and core is not None:
|
|
# Windows cannot delete a loaded module, which breaks the
|
|
# TemporaryDirectory cleanup in verify(). Free the DLL now.
|
|
try:
|
|
ctypes.windll.kernel32.FreeLibrary.argtypes = [ctypes.c_void_p]
|
|
ctypes.windll.kernel32.FreeLibrary.restype = ctypes.c_int
|
|
ctypes.windll.kernel32.FreeLibrary(core._handle)
|
|
except Exception as exc:
|
|
# Best-effort cleanup: the libretro API checks above
|
|
# already passed, so a FreeLibrary failure must not fail
|
|
# the release. Log it and continue to release the handle.
|
|
print(f"warning: FreeLibrary failed for {binary}: {exc}", file=sys.stderr)
|
|
finally:
|
|
# Drop the last Python reference so the file handle is
|
|
# released before the temp dir is removed. Antivirus
|
|
# scanners may briefly hold the new DLL; gc ensures the
|
|
# refcount drops deterministically.
|
|
del core
|
|
import gc
|
|
|
|
gc.collect()
|
|
finally:
|
|
if target == "linux":
|
|
if old_library_path is None:
|
|
os.environ.pop("LD_LIBRARY_PATH", None)
|
|
else:
|
|
os.environ["LD_LIBRARY_PATH"] = old_library_path
|
|
if dll_directory is not None:
|
|
try:
|
|
dll_directory.close()
|
|
except Exception as exc:
|
|
# Best-effort cleanup: closing the DLL search directory must
|
|
# not fail verification after the core already passed.
|
|
print(f"warning: dll_directory.close() failed: {exc}", file=sys.stderr)
|
|
|
|
|
|
def verify_structure(root: Path, archive: Path) -> None:
|
|
files = [path for path in root.rglob("*") if path.is_file()]
|
|
if not files:
|
|
fail(f"package is empty: {archive.name}")
|
|
if archive.suffix == ".apk":
|
|
if not (root / "AndroidManifest.xml").is_file():
|
|
fail(f"APK has no AndroidManifest.xml: {archive.name}")
|
|
return
|
|
names = {path.name for path in files}
|
|
if "libretro-core" in archive.name:
|
|
if not any(path.suffix.lower() in (".so", ".dll", ".dylib") for path in files):
|
|
fail(f"libretro package has no shared library: {archive.name}")
|
|
elif archive.name.endswith("-windows-x64.zip") and not {"drippu.exe", "drippu-cmd.exe"}.issubset(names):
|
|
fail(f"Windows desktop package is missing its launchable binaries: {archive.name}")
|
|
else:
|
|
has_cli = "drippu-cmd" in names or "drippu-cmd.exe" in names
|
|
has_macos_bundle = any(
|
|
path.parent.name == "MacOS"
|
|
and path.parent.parent.name == "Contents"
|
|
and path.parent.parent.parent.name == "drippu.app"
|
|
for path in files
|
|
)
|
|
has_gui = "drippu" in names or "drippu.exe" in names or has_macos_bundle
|
|
if not (has_gui and has_cli):
|
|
fail(f"desktop package is missing its launchable binaries: {archive.name}")
|
|
|
|
|
|
def verify(archive: Path, mode: str, target: str) -> None:
|
|
if not archive.is_file():
|
|
fail(f"artifact does not exist: {archive}")
|
|
# Python 3.10+ can ignore cleanup errors (e.g. a briefly locked DLL on
|
|
# Windows when an antivirus scanner holds the freshly extracted core).
|
|
# The libretro smoke test already frees the DLL explicitly; this is only
|
|
# a backstop so verification never fails with WinError 5 on rmtree.
|
|
if sys.version_info >= (3, 10):
|
|
temporary_dir = tempfile.TemporaryDirectory(
|
|
prefix="drippu-release-verify-", ignore_cleanup_errors=True
|
|
)
|
|
else:
|
|
temporary_dir = tempfile.TemporaryDirectory(prefix="drippu-release-verify-")
|
|
with temporary_dir as temporary:
|
|
root = Path(temporary)
|
|
unpack(archive, root)
|
|
if mode == "structure":
|
|
verify_structure(root, archive)
|
|
elif mode == "desktop":
|
|
binaries = desktop_binaries(root, target)
|
|
for binary in binaries:
|
|
check_dependencies(binary, target)
|
|
gui = next(
|
|
binary
|
|
for binary in binaries
|
|
if binary.name not in ("drippu-cmd", "drippu-cmd.exe")
|
|
)
|
|
launch_desktop(gui, target)
|
|
for binary in binaries:
|
|
if binary.name in ("drippu-cmd", "drippu-cmd.exe"):
|
|
smoke_cli(binary, target)
|
|
elif mode == "libretro":
|
|
smoke_libretro(libretro_binary(root), target)
|
|
elif mode == "apk":
|
|
# APK validation is intentionally limited to ZIP integrity and the
|
|
# manifest here; install/launch belongs on an Android emulator.
|
|
pass
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("mode", choices=("structure", "desktop", "libretro", "apk"))
|
|
parser.add_argument("--archive", required=True, type=Path)
|
|
parser.add_argument("--platform", required=True, choices=("linux", "macos", "windows", "android", "generic"))
|
|
args = parser.parse_args()
|
|
try:
|
|
verify(args.archive, args.mode, args.platform)
|
|
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
|
|
print(f"release artifact verification failed: {exc}", file=sys.stderr)
|
|
return 1
|
|
print(f"release artifact verification passed: {args.mode} {args.platform} {args.archive}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|