diff --git a/.gitignore b/.gitignore index 4eddd0f..d35c98e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,7 @@ .vscode/* # for ipc files (inter process communication) -app/sandbox \ No newline at end of file +app/sandbox + +# for deployment +deployment/upload_api.key diff --git a/deployment/build_release.py b/deployment/build_release.py index 2082bee..b7df47c 100755 --- a/deployment/build_release.py +++ b/deployment/build_release.py @@ -9,6 +9,7 @@ import shutil import subprocess import sys from pathlib import Path +import argparse def run(cmd: list[str], cwd: Path | None = None, check: bool = True) -> subprocess.CompletedProcess: @@ -26,27 +27,33 @@ def parse_version(project_path: Path) -> str: raise RuntimeError(f"Failed to parse version from {project_path}") -def open_folder_and_url(builds_dir: Path, url: str) -> None: - os_name = platform.system() - try: - if os_name == "Linux": - # fire-and-forget - subprocess.Popen(["xdg-open", str(builds_dir)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - subprocess.Popen(["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - elif os_name == "Darwin": - subprocess.Popen(["open", str(builds_dir)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - subprocess.Popen(["open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except Exception: - # Non-fatal if opening fails - pass +def build_expected_zip_paths(builds_dir: Path, version: str, os_name: str) -> list[Path]: + paths: list[Path] = [] + if os_name == "Linux": + paths.append(builds_dir / "Linux" / f"TheGates_Linux_{version}.zip") + paths.append(builds_dir / "Windows" / f"TheGates_Windows_{version}.zip") + elif os_name == "Darwin": + paths.append(builds_dir / f"TheGates_MacOS_{version}.zip") + return paths + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Export, compress, and upload builds.") + parser.add_argument( + "--force", + action="store_true", + help="Overwrite existing compressed files (Linux compressor only).", + ) + return parser.parse_args() def main() -> int: + args = parse_args() script_dir = Path(__file__).resolve().parent repo_root = script_dir.parent app_dir = repo_root / "app" export_script = repo_root / "deployment" / "export_project.py" - open_url = "https://devs.thegates.io/files/builds/" + uploader = repo_root / "deployment" / "upload_build.py" os_name = platform.system() print(f"==> Using repo root: {repo_root}") @@ -63,6 +70,8 @@ def main() -> int: version = parse_version(app_dir / "project.godot") print(f"==> App version: {version}") + uploaded: list[Path] = [] + if os_name == "Linux": builds_dir = Path("/media/common/Projects/thegates-folder/AppBuilds") compress_src = repo_root / "deployment" / "compress_builds_linux.py" @@ -77,9 +86,12 @@ def main() -> int: shutil.copy2(compress_src, compress_dst) print(f"==> Compressing Linux/Windows builds with version {version}...") - run([sys.executable, str(compress_dst), version], cwd=builds_dir) + compress_cmd = [sys.executable, str(compress_dst), version] + if args.force: + compress_cmd.append("--force") + run(compress_cmd, cwd=builds_dir) - open_folder_and_url(builds_dir, open_url) + uploaded = build_expected_zip_paths(builds_dir, version, os_name) elif os_name == "Darwin": builds_dir = Path("/Users/nordup/Projects/thegates-folder/AppBuilds") @@ -91,7 +103,19 @@ def main() -> int: print(f"==> Compressing macOS build with version {version}...") run([sys.executable, str(compress_script), version], cwd=builds_dir) - open_folder_and_url(builds_dir, open_url) + uploaded = build_expected_zip_paths(builds_dir, version, os_name) + + # Upload created zip files via uploader + existing = [p for p in uploaded if p.exists()] + if not existing: + print("No compressed build files found to upload.") + return 1 + + print("==> Uploading:") + for p in existing: + print(f" - {p}") + + run([sys.executable, str(uploader), *[str(p) for p in existing]], cwd=repo_root) print("==> Done.") return 0 diff --git a/deployment/upload_build.py b/deployment/upload_build.py new file mode 100755 index 0000000..ea29c2d --- /dev/null +++ b/deployment/upload_build.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import os +import sys +import mimetypes +import uuid +from pathlib import Path +from typing import Iterable +from urllib import request, error + + +DEFAULT_ENDPOINT = "https://app.thegates.io/api/upload_build" + + +def read_api_key(repo_root: Path) -> str: + # Allow override via env. Default to deployment/upload_api.key + key_file_env = os.environ.get("TG_UPLOAD_API_KEY_FILE") + if key_file_env: + key_path = Path(key_file_env).expanduser().resolve() + else: + key_path = (repo_root / "deployment" / "upload_api.key").resolve() + + if not key_path.exists(): + raise FileNotFoundError( + f"API key file not found: {key_path}. Set TG_UPLOAD_API_KEY_FILE or create the file." + ) + + key = key_path.read_text(encoding="utf-8").strip() + if not key: + raise ValueError(f"API key file {key_path} is empty") + return key + + +def build_multipart_body(field_name: str, file_path: Path, boundary: str) -> tuple[bytes, str]: + filename = file_path.name + content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream" + file_bytes = file_path.read_bytes() + + boundary_bytes = boundary.encode("utf-8") + crlf = b"\r\n" + + body = [] + body.append(b"--" + boundary_bytes + crlf) + body.append( + ( + f'Content-Disposition: form-data; name="{field_name}"; filename="{filename}"' # noqa: E501 + ).encode("utf-8") + ) + body.append((f"Content-Type: {content_type}").encode("utf-8") + crlf + crlf) + body.append(file_bytes + crlf) + body.append(b"--" + boundary_bytes + b"--" + crlf) + + body_bytes = b"".join(body) + content_type_header = f"multipart/form-data; boundary={boundary}" + return body_bytes, content_type_header + + +def upload_file(endpoint: str, api_key: str, file_path: Path) -> int: + if not file_path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + + boundary = f"----TheGatesBoundary{uuid.uuid4().hex}" + body, content_type = build_multipart_body("file", file_path, boundary) + + req = request.Request(endpoint, method="POST") + req.add_header("Content-Type", content_type) + req.add_header("Content-Length", str(len(body))) + req.add_header("X-API-Key", api_key) + + try: + with request.urlopen(req, data=body, timeout=300) as resp: + status = resp.getcode() + print(f"Uploaded {file_path.name}: HTTP {status}") + return status + except error.HTTPError as e: + print(f"Upload failed for {file_path.name}: HTTP {e.code} - {e.read().decode(errors='ignore')}") + return e.code + except Exception as e: + print(f"Upload error for {file_path.name}: {e}") + return 1 + + +def main(argv: list[str]) -> int: + if len(argv) < 2: + print("Usage: upload_build.py [ ...]") + return 2 + + repo_root = Path(__file__).resolve().parent.parent + endpoint = os.environ.get("TG_UPLOAD_ENDPOINT", DEFAULT_ENDPOINT) + api_key = read_api_key(repo_root) + + statuses: list[int] = [] + for arg in argv[1:]: + file_path = Path(arg).expanduser().resolve() + statuses.append(upload_file(endpoint, api_key, file_path)) + + # Return non-zero if any upload failed (status >= 400 or ==1) + for s in statuses: + if isinstance(s, int) and (s >= 400 or s == 1): + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv))