Files
drippu/.github/scripts/verify_release_artifacts.py
Dallin Bland ac2a516999 Address code-quality bot: explain best-effort Windows cleanup
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.
2026-09-21 00:58:47 -05:00

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