Restore native POSIX setup tools and platform release validation

This commit is contained in:
Matthew Stanley
2026-10-17 14:17:16 -07:00
parent 444264f2aa
commit 2c92ac3ff1
6 changed files with 393 additions and 5 deletions
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Reject setup ZIPs whose toolchain copy does not match the host platform."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
import zipfile
from pathlib import Path, PurePosixPath
PLATFORM_MARKERS = {
"windows": ("windows-x64",),
"linux": ("linux-x64",),
"macos": ("macos-x64", "macos-arm64"),
}
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest().upper()
def infer_platform(path: Path) -> str:
lower = path.name.lower()
matches = [
platform
for platform, markers in PLATFORM_MARKERS.items()
if any(marker in lower for marker in markers)
]
if len(matches) != 1:
raise ValueError(
"archive name must contain exactly one supported platform marker: "
"windows-x64, linux-x64, macos-x64, or macos-arm64"
)
return matches[0]
def root_member(names: list[str], basename: str) -> str:
matches = [
name
for name in names
if not name.endswith("/")
and len(PurePosixPath(name).parts) == 1
and PurePosixPath(name).name.lower() == basename.lower()
]
if len(matches) != 1:
raise ValueError(f"archive must contain exactly one root {basename}")
return matches[0]
def require(text: str, tokens: tuple[str, ...], where: str) -> None:
missing = [token for token in tokens if token not in text]
if missing:
raise ValueError(f"{where} is missing: {', '.join(missing)}")
def reject(text: str, tokens: tuple[str, ...], where: str) -> None:
found = [token for token in tokens if token in text]
if found:
raise ValueError(f"{where} contains forbidden copy: {', '.join(found)}")
def audit(path: Path) -> dict[str, object]:
platform = infer_platform(path)
with zipfile.ZipFile(path) as archive:
names = archive.namelist()
readme_name = root_member(names, "README-SETUP.txt")
readme = archive.read(readme_name).decode("utf-8")
run_match = re.search(r"(?m)^\s*\d+\.\s+Run\s+([^\s.]+(?:\.exe)?)\.\s*$", readme)
if not run_match:
raise ValueError("README-SETUP.txt has no numbered Run instruction")
host_name = run_match.group(1)
if platform == "windows" and not host_name.lower().endswith(".exe"):
host_name += ".exe"
host_member = root_member(names, host_name)
host_copy = archive.read(host_member).decode("latin-1")
if platform == "windows":
require(
readme,
("downloads", "cmake-clang-v1", "toolchain pack"),
"Windows README-SETUP.txt",
)
require(
host_copy,
(
"Download latest portable toolchain",
"Select toolchain zip",
"cmake-clang-v1",
),
"Windows setup host",
)
else:
require(
readme,
(
"Install CMake, Ninja, Python 3",
"C/C++ compiler",
"available on PATH",
"native build tools",
),
"POSIX README-SETUP.txt",
)
reject(
readme.lower(),
("cmake-clang-v1", "portable toolchain", "toolchain pack", "toolchain zip"),
"POSIX README-SETUP.txt",
)
require(
host_copy,
(
"1. Native build tools",
"Install CMake, Ninja, Python 3, and either Clang or GCC",
"Check tools",
),
"POSIX setup host",
)
reject(
host_copy,
("Download latest portable toolchain##tc", "1. Portable toolchain"),
"POSIX setup host",
)
return {
"archive": str(path.resolve()),
"platform": platform,
"sha256": sha256(path),
"size": path.stat().st_size,
"status": "pass",
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("archives", nargs="+", type=Path)
parser.add_argument("--json", type=Path, dest="json_path")
args = parser.parse_args()
results: list[dict[str, object]] = []
failed = False
for path in args.archives:
try:
result = audit(path)
except (OSError, ValueError, zipfile.BadZipFile) as error:
result = {"archive": str(path.resolve()), "status": "fail", "error": str(error)}
failed = True
results.append(result)
print(f"{result['status'].upper()}: {path}: {result.get('error', result.get('platform'))}")
if args.json_path:
args.json_path.parent.mkdir(parents=True, exist_ok=True)
args.json_path.write_text(
json.dumps({"schema": 1, "results": results}, indent=2) + "\n",
encoding="utf-8",
)
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())
+27 -1
View File
@@ -530,9 +530,13 @@ ${DISPLAY_NAME} ${VERSION} — setup package
Platform: ${ARTIFACT}
One zip for first install and updates. Does NOT include disc images, retail
BIOS dumps, pre-generated game C, or a portable cmake/clang pack. Emitters
BIOS dumps, pre-generated game C, or a bundled build-tool pack. Emitters
(psxrecomp-game / psxrecomp-bios) and the CLI are inside psxrecomp/.
EOF
case "${ARTIFACT}" in
windows-*)
cat >>"${STAGE}/README-SETUP.txt" <<EOF
Standalone:
1. Install Python 3.
2. Run ${EXE_BASENAME}.
@@ -546,6 +550,28 @@ Retro uses this same zip: it harvests emitters into a shared SDK cache,
downloads the toolchain pack (or uses RETCOMM_TOOLCHAIN_DIR), and preserves
saves/user config across updates.
EOF
;;
linux-*|macos-*)
cat >>"${STAGE}/README-SETUP.txt" <<EOF
Standalone:
1. Install CMake, Ninja, Python 3, and a C/C++ compiler with your system
package manager.
2. Make sure cmake, ninja, python3, and either clang/clang++ or gcc/g++ are
available on PATH.
3. Run ${EXE_BASENAME}.
4. Provide ${DISC_HINT} and ${BIOS_HINT}.
5. Follow the Generate & rebuild wizard. The host checks the native build
tools before it continues.
Retro uses this same zip. It uses the same native build tools and preserves
saves/user config across updates.
EOF
;;
*)
echo "error: unsupported setup README platform: ${ARTIFACT}" >&2
exit 1
;;
esac
# --- Gate: the staged tree must be able to configure itself ----------------
# The project file/dir list is an allowlist, so any path a project adds to
+24
View File
@@ -956,6 +956,30 @@ def ensure_toolchain(
Installs always land under the shared Retro cache; the project only gets
a stamp file pointing at that pack.
"""
if not sys_platform_is_windows() and from_zip is None:
# Setup/rebuild on Linux and macOS uses native tools. Do not turn a
# missing dependency into an unsolicited portable-pack download.
groups = (("cmake",), ("ninja",), ("python3", "python"),
("cc", "gcc", "clang"), ("c++", "g++", "clang++"))
resolved = []
for names in groups:
command = next(filter(None, (shutil.which(name) for name in names)), None)
if command:
try:
result = subprocess.run([command, "--version"], capture_output=True,
text=True, timeout=30, check=False)
if result.returncode == 0:
resolved.append(command)
continue
except (OSError, subprocess.SubprocessError):
pass
raise RuntimeError("Native build tools are missing or unusable: {}. Install CMake, "
"Ninja, Python 3 and C/C++ compilers with your package manager."
.format("/".join(names)))
if log:
log("Using native build tools on PATH")
return Path(resolved[0]).parent
need = min_version or default_min_version()
if is_windows_store_python() and log:
log(