mirror of
https://github.com/boykopovar/AnyPS5.git
synced 2026-10-22 00:22:57 -05:00
186 lines
8.4 KiB
Python
186 lines
8.4 KiB
Python
import argparse
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import tempfile
|
|
import urllib.request
|
|
import zipfile
|
|
|
|
from package_release import WEB_FILES
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
MAX_EXPANDED_BYTES = 512 * 1024 * 1024
|
|
|
|
|
|
def copy_sources(output):
|
|
source = ROOT / "demo/web"
|
|
for file in source.iterdir():
|
|
if file.is_file():
|
|
shutil.copy2(file, output / file.name)
|
|
(output / ".nojekyll").touch()
|
|
|
|
|
|
def request(url):
|
|
headers = {"User-Agent": "AnyPS5-Pages"}
|
|
token = os.environ.get("GITHUB_TOKEN")
|
|
if token and url.startswith("https://api.github.com/"):
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
return urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=60)
|
|
|
|
|
|
def publish_libraries(output, release=None):
|
|
if release is None:
|
|
with request("https://api.github.com/repos/boykopovar/AnyPS5/releases/latest") as response:
|
|
release = json.load(response)
|
|
tag = release["tag_name"]
|
|
if not re.fullmatch(r"v[0-9A-Za-z][0-9A-Za-z._-]*", tag):
|
|
raise ValueError("Invalid release tag")
|
|
manifest = {"tag": tag, "url": release["html_url"], "platforms": {}}
|
|
destination = output / "libraries"
|
|
destination.mkdir(exist_ok=True)
|
|
for platform in ("windows", "linux"):
|
|
name = f"prx-{platform}-{tag}.zip"
|
|
matches = [asset for asset in release["assets"] if asset["name"] == name]
|
|
if len(matches) != 1:
|
|
raise ValueError(f"Missing or ambiguous release asset: {name}")
|
|
asset = matches[0]
|
|
temporary = destination / f"{platform}.download"
|
|
try:
|
|
digest = hashlib.sha256()
|
|
size = 0
|
|
with request(asset["browser_download_url"]) as response, temporary.open("wb") as target:
|
|
while chunk := response.read(1024 * 1024):
|
|
size += len(chunk)
|
|
if size > asset["size"] or size > 512 * 1024 * 1024:
|
|
raise ValueError("Release archive exceeds expected size")
|
|
digest.update(chunk)
|
|
target.write(chunk)
|
|
if size != asset["size"]:
|
|
raise ValueError("Incomplete release archive")
|
|
checksum = digest.hexdigest()
|
|
if asset.get("digest") and asset["digest"] != "sha256:" + checksum:
|
|
raise ValueError("Release archive checksum mismatch")
|
|
with zipfile.ZipFile(temporary) as archive:
|
|
files = archive.infolist()
|
|
if not files or len(files) > 4096:
|
|
raise ValueError("Invalid library archive")
|
|
names = {}
|
|
for file in files:
|
|
if not file.filename.startswith("libs/") or any(part in ("..", ".", "") for part in file.filename.rstrip("/").split("/")) or "\\" in file.filename:
|
|
raise ValueError("Invalid library archive layout")
|
|
name = file.filename.rstrip("/").lower()
|
|
if name in names:
|
|
raise ValueError(f"Duplicate library archive path: {file.filename}")
|
|
names[name] = file.is_dir()
|
|
for name in names:
|
|
parts = name.split("/")
|
|
if any(names.get("/".join(parts[:index])) is False for index in range(1, len(parts))):
|
|
raise ValueError(f"Library archive file is also used as a directory: {name}")
|
|
expanded = sum(file.file_size for file in files)
|
|
if expanded > MAX_EXPANDED_BYTES:
|
|
raise ValueError("Release archive exceeds maximum expanded size")
|
|
corrupt = archive.testzip()
|
|
if corrupt:
|
|
raise ValueError(f"Corrupt release entry: {corrupt}")
|
|
path = f"libraries/{platform}-{checksum}.zip"
|
|
temporary.replace(output / path)
|
|
manifest["platforms"][platform] = {"path": path, "size": size, "expandedBytes": expanded, "sha256": checksum}
|
|
finally:
|
|
temporary.unlink(missing_ok=True)
|
|
(output / "release.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
|
print(f"Published library assets from {tag}")
|
|
|
|
|
|
def publish_release(output):
|
|
with request("https://api.github.com/repos/boykopovar/AnyPS5/releases/latest") as response:
|
|
release = json.load(response)
|
|
tag = release["tag_name"]
|
|
if not re.fullmatch(r"v[0-9A-Za-z][0-9A-Za-z._-]*", tag) or tag.endswith("."):
|
|
raise ValueError("Invalid release tag")
|
|
name = f"web-{tag}.zip"
|
|
matches = [asset for asset in release["assets"] if asset["name"] == name]
|
|
if len(matches) != 1:
|
|
raise ValueError(f"Missing or ambiguous release asset: {name}; publish a release with the web converter")
|
|
asset = matches[0]
|
|
if not 0 < asset["size"] <= 128 * 1024 * 1024:
|
|
raise ValueError("Invalid web release archive size")
|
|
with request(asset["browser_download_url"]) as response:
|
|
data = response.read(asset["size"] + 1)
|
|
if len(data) != asset["size"]:
|
|
raise ValueError("Incomplete or oversized web release archive")
|
|
checksum = hashlib.sha256(data).hexdigest()
|
|
if asset.get("digest") and asset["digest"] != "sha256:" + checksum:
|
|
raise ValueError("Web release archive checksum mismatch")
|
|
if output.is_symlink() or (output.exists() and not output.is_dir()):
|
|
raise ValueError(f"Release output is not a directory: {output}")
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = Path(tempfile.mkdtemp(prefix=".web-release-", dir=output.parent))
|
|
staging = temporary / "site"
|
|
previous = temporary / "previous"
|
|
published = False
|
|
try:
|
|
staging.mkdir()
|
|
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
|
files = archive.infolist()
|
|
expected = {*WEB_FILES, "web-release.json"}
|
|
if len(files) != len(expected) or {file.filename for file in files} != expected:
|
|
raise ValueError("Invalid web release archive layout")
|
|
if any(file.file_size == 0 for file in files) or sum(file.file_size for file in files) > 128 * 1024 * 1024:
|
|
raise ValueError("Invalid expanded web release archive size")
|
|
metadata = json.loads(archive.read("web-release.json"))
|
|
if (not isinstance(metadata, dict) or metadata.get("tag") != tag
|
|
or not isinstance(metadata.get("revision"), str)
|
|
or not re.fullmatch(r"[0-9a-f]{40}", metadata["revision"])):
|
|
raise ValueError("Web converter does not match the selected release")
|
|
for file in files:
|
|
target = staging / file.filename
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_bytes(archive.read(file))
|
|
publish_libraries(staging, release)
|
|
manifest = json.loads((staging / "release.json").read_text(encoding="utf-8"))
|
|
manifest["revision"] = metadata["revision"]
|
|
(staging / "release.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
|
(staging / ".nojekyll").touch()
|
|
if output.exists():
|
|
output.rename(previous)
|
|
try:
|
|
staging.rename(output)
|
|
except OSError:
|
|
if previous.exists():
|
|
try:
|
|
previous.rename(output)
|
|
except OSError as error:
|
|
raise RuntimeError(f"Could not restore release output. Previous site is preserved at {previous}") from error
|
|
raise
|
|
published = True
|
|
finally:
|
|
if published or not previous.exists():
|
|
shutil.rmtree(temporary)
|
|
print(f"Published web converter and libraries from {tag} ({metadata['revision']})")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--build", type=Path, required=True)
|
|
parser.add_argument("--release", "--libraries", dest="release", action="store_true",
|
|
help="Download the matching converter and libraries from the latest release")
|
|
args = parser.parse_args()
|
|
output = args.build.resolve() / "web"
|
|
if args.release:
|
|
publish_release(output)
|
|
print(output)
|
|
return
|
|
for name in ("relinker_wasm.mjs", "relinker_wasm.wasm"):
|
|
if not (output / "native" / name).is_file():
|
|
raise ValueError(f"Build relinker_wasm first: missing {name}")
|
|
copy_sources(output)
|
|
print(output)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|