#!/usr/bin/env python3 """Build, test, version, and publish source plus an Android APK to Gitea.""" from __future__ import annotations import argparse import hashlib import json import os import re import secrets import shutil import subprocess import sys import urllib.error import urllib.parse import urllib.request from datetime import datetime from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] ANDROID_ROOT = REPO_ROOT / "android" PACKAGE_JSON = REPO_ROOT / "package.json" GITEA_REMOTE = "https://git.whoagland.com/phenom/i-want-to-heal-mmo.git" GITEA_API = "https://git.whoagland.com/api/v1" GITEA_TOKEN = "ed2db3fd54546e9658377d0551b3fc3961583f1d" GITEA_OWNER = "phenom" GITEA_REPO = "i-want-to-heal-mmo" BRANCH = "main" TRUENAS_PATH = Path("/mnt/usbssds/apps/iwanttoheal-mmo/app") TRUENAS_GITEA_REPO = Path( "/mnt/.ix-apps/app_mounts/gitea/data/git/repositories/phenom/i-want-to-heal-mmo.git" ) SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") def run( args: list[str], *, cwd: Path = REPO_ROOT, env: dict[str, str] | None = None, ) -> None: print(f"$ {' '.join(args)}", flush=True) subprocess.run( args, cwd=cwd, env={**os.environ, **(env or {})}, check=True, ) def capture(args: list[str], *, cwd: Path = REPO_ROOT) -> str: return subprocess.run( args, cwd=cwd, check=True, capture_output=True, text=True, ).stdout.strip() def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser( description="Build and publish I Want to Heal source plus a Thor test APK to Gitea." ) parser.add_argument( "-m", "--message", help="Git commit and release message (default: Release vVERSION).", ) parser.add_argument( "--version", help="Release version as MAJOR.MINOR.PATCH. Defaults to package version, or next patch when already tagged.", ) parser.add_argument( "--skip-checks", action="store_true", help="Skip dependency installation and tests. Web and APK builds still run.", ) parser.add_argument( "--skip-release", action="store_true", help="Push source and tag without creating a Gitea release or uploading the APK.", ) parser.add_argument( "--skip-truenas", action="store_true", help="Do not pull the new commit into a locally mounted TrueNAS app directory.", ) parser.add_argument( "--dry-run", action="store_true", help="Run builds and tests without changing package version, Git, Gitea, or TrueNAS.", ) return parser.parse_args(argv) def package_manager() -> list[str]: pnpm = shutil.which("pnpm") if pnpm: return [pnpm] corepack = shutil.which("corepack") if corepack: return [corepack, "pnpm"] raise SystemExit("pnpm or corepack is required") def read_package_version() -> str: version = json.loads(PACKAGE_JSON.read_text())["version"] validate_version(version) return version def validate_version(version: str) -> tuple[int, int, int]: match = SEMVER.fullmatch(version) if not match: raise SystemExit(f"Invalid version {version!r}; expected MAJOR.MINOR.PATCH") return tuple(int(part) for part in match.groups()) # type: ignore[return-value] def remote_tags() -> set[str]: output = capture(["git", "ls-remote", "--tags", "origin"]) return { line.rsplit("refs/tags/", 1)[1].removesuffix("^{}") for line in output.splitlines() if "refs/tags/" in line } def resolve_version(explicit: str | None) -> str: tags = set(capture(["git", "tag", "--list"]).splitlines()) | remote_tags() if explicit: validate_version(explicit) return explicit version = read_package_version() major, minor, patch = validate_version(version) while f"v{version}" in tags: patch += 1 version = f"{major}.{minor}.{patch}" return version def android_version_code(version: str) -> int: major, minor, patch = validate_version(version) code = major * 1_000_000 + minor * 1_000 + patch if code < 1 or code > 2_100_000_000: raise SystemExit(f"Version {version} cannot be represented as an Android versionCode") return code def write_package_version(version: str) -> None: package = json.loads(PACKAGE_JSON.read_text()) package["version"] = version PACKAGE_JSON.write_text(json.dumps(package, indent=2) + "\n") def run_checks(*, version: str, skip_checks: bool) -> Path: pnpm = package_manager() if not skip_checks: run([*pnpm, "install", "--frozen-lockfile"]) run([*pnpm, "run", "build"]) if not skip_checks: run([*pnpm, "test"]) run([*pnpm, "exec", "cap", "sync", "android"]) version_code = android_version_code(version) gradle_tasks = ( ["assembleDebug"] if skip_checks else ["testDebugUnitTest", "lintDebug", "assembleDebug"] ) run( ["./gradlew", "--no-daemon", "clean", *gradle_tasks], cwd=ANDROID_ROOT, env={ "ANDROID_VERSION_NAME": version, "ANDROID_VERSION_CODE": str(version_code), }, ) apk = ( ANDROID_ROOT / "app/build/outputs/apk/debug" / f"i-want-to-heal-{version}-debug.apk" ) if not apk.is_file(): raise SystemExit(f"Gradle completed but APK was not found at {apk}") print(f"APK built: {apk}") return apk def normalized_remote(url: str) -> str: return url.rstrip("/").removesuffix(".git") def ensure_git_target(*, dry_run: bool) -> None: try: origin = capture(["git", "remote", "get-url", "origin"]) except subprocess.CalledProcessError: if dry_run: raise SystemExit(f"Missing origin remote; expected {GITEA_REMOTE}") run(["git", "remote", "add", "origin", GITEA_REMOTE]) origin = GITEA_REMOTE if normalized_remote(origin) != normalized_remote(GITEA_REMOTE): raise SystemExit( f"Refusing to publish to unexpected origin {origin!r}; expected {GITEA_REMOTE!r}" ) branch = capture(["git", "branch", "--show-current"]) if branch != BRANCH: raise SystemExit( f"Refusing to publish branch {branch!r}; switch to {BRANCH!r} first" ) def has_staged_changes() -> bool: result = subprocess.run( ["git", "diff", "--cached", "--quiet", "--exit-code"], cwd=REPO_ROOT, check=False, ) if result.returncode not in (0, 1): raise SystemExit("Could not inspect staged Git changes") return result.returncode == 1 def commit_and_push(message: str) -> str: run(["git", "add", "--all"]) if has_staged_changes(): run(["git", "commit", "-m", message]) else: print("No changes to commit.") run(["git", "push", "--set-upstream", "origin", BRANCH]) return capture(["git", "rev-parse", "HEAD"]) def ensure_tag(version: str, commit: str, message: str) -> str: tag = f"v{version}" local_tags = set(capture(["git", "tag", "--list", tag]).splitlines()) if tag not in local_tags and tag in remote_tags(): run(["git", "fetch", "origin", f"refs/tags/{tag}:refs/tags/{tag}"]) local_tags.add(tag) if tag in local_tags: tagged_commit = capture(["git", "rev-list", "-n", "1", tag]) if tagged_commit != commit: raise SystemExit( f"Refusing to reuse {tag}; it points at {tagged_commit}, not {commit}" ) print(f"Tag {tag} already points at this commit.") else: run(["git", "tag", "-a", tag, "-m", message]) run(["git", "push", "origin", tag]) return tag def gitea_token() -> str: token = os.environ.get("GITEA_TOKEN", GITEA_TOKEN).strip() if not token: raise SystemExit( "GITEA_TOKEN is required to create the release and upload the APK. " "Use --skip-release to push source/tag only." ) return token def gitea_request( path: str, *, token: str, method: str = "GET", body: bytes | None = None, content_type: str = "application/json", allow_not_found: bool = False, ) -> dict[str, object] | list[object] | None: request = urllib.request.Request( f"{GITEA_API}{path}", data=body, method=method, headers={ "Accept": "application/json", "Authorization": f"token {token}", "Content-Type": content_type, "User-Agent": "i-want-to-heal-publisher", }, ) try: with urllib.request.urlopen(request, timeout=90) as response: payload = response.read() except urllib.error.HTTPError as error: details = error.read().decode(errors="replace") if allow_not_found and error.code == 404: return None raise SystemExit(f"Gitea API {method} {path} failed ({error.code}): {details}") from error return json.loads(payload) if payload else None def release_for_tag(tag: str, token: str) -> dict[str, object] | None: result = gitea_request( f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases/tags/{urllib.parse.quote(tag)}", token=token, allow_not_found=True, ) return result if isinstance(result, dict) else None def create_release(tag: str, commit: str, message: str, token: str) -> dict[str, object]: existing = release_for_tag(tag, token) if existing: print(f"Gitea release {tag} already exists.") return existing payload = json.dumps( { "tag_name": tag, "target_commitish": commit, "name": f"I Want To Heal {tag} — Thor test APK", "body": ( f"{message}\n\n" "Unsigned-for-store debug APK for sideload testing on AYN Thor. " "Android debug signing is included; Play Store release signing is not." ), "draft": False, "prerelease": True, } ).encode() result = gitea_request( f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases", token=token, method="POST", body=payload, ) if not isinstance(result, dict): raise SystemExit("Gitea returned an invalid release response") return result def multipart_asset(path: Path, media_type: str) -> tuple[bytes, str]: boundary = f"----iwanttoheal{secrets.token_hex(12)}" prefix = ( f"--{boundary}\r\n" f'Content-Disposition: form-data; name="attachment"; filename="{path.name}"\r\n' f"Content-Type: {media_type}\r\n\r\n" ).encode() body = prefix + path.read_bytes() + f"\r\n--{boundary}--\r\n".encode() return body, f"multipart/form-data; boundary={boundary}" def upload_release_asset( release_id: int, path: Path, *, media_type: str, token: str, ) -> None: body, content_type = multipart_asset(path, media_type) gitea_request( f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases/{release_id}/assets" f"?name={urllib.parse.quote(path.name)}", token=token, method="POST", body=body, content_type=content_type, ) print(f"Release asset uploaded: {path.name}") def upload_apk(release: dict[str, object], apk: Path, token: str) -> None: assets = release.get("assets", []) existing_names = { str(asset.get("name")) for asset in (assets if isinstance(assets, list) else []) if isinstance(asset, dict) } release_id = release.get("id") if not isinstance(release_id, int): raise SystemExit("Gitea release response has no numeric id") checksum = hashlib.sha256(apk.read_bytes()).hexdigest() checksum_path = apk.with_name(f"{apk.name}.sha256") checksum_path.write_text(f"{checksum} {apk.name}\n") release_assets = ( (apk, "application/vnd.android.package-archive"), (checksum_path, "text/plain"), ) for path, media_type in release_assets: if path.name in existing_names: print(f"Release asset {path.name} already exists.") else: upload_release_asset( release_id, path, media_type=media_type, token=token, ) print(f"SHA-256: {checksum}") def update_truenas() -> None: if (TRUENAS_PATH / ".git").is_dir(): origin = capture(["git", "remote", "get-url", "origin"], cwd=TRUENAS_PATH) accepted_origins = { normalized_remote(GITEA_REMOTE), normalized_remote(str(TRUENAS_GITEA_REPO)), } if normalized_remote(origin) not in accepted_origins: raise SystemExit( "Refusing to update TrueNAS from its old repository origin " f"{origin!r}. Replace that checkout with this game's repository first." ) if TRUENAS_GITEA_REPO.is_dir(): run( [ "git", "-c", f"safe.directory={TRUENAS_GITEA_REPO}", "pull", "--ff-only", str(TRUENAS_GITEA_REPO), BRANCH, ], cwd=TRUENAS_PATH, ) print(f"TrueNAS source updated from local Gitea storage: {TRUENAS_GITEA_REPO}") else: run(["git", "pull", "--ff-only", "origin", BRANCH], cwd=TRUENAS_PATH) print("TrueNAS source updated from its configured origin.") print("Restart the iwanttoheal-mmo app in the TrueNAS UI.") return print("New TrueNAS app clone is not mounted on this machine.") print("Run these commands in the TrueNAS shell:") print(f" sudo git config --global --add safe.directory {TRUENAS_GITEA_REPO}") print(f" sudo git clone {TRUENAS_GITEA_REPO} {TRUENAS_PATH}") print(f" sudo chown -R truenas_admin:truenas_admin {TRUENAS_PATH.parent}") print("For later releases:") print( f" git -C {TRUENAS_PATH} pull --ff-only " f"{TRUENAS_GITEA_REPO} {BRANCH}" ) print("Then restart the iwanttoheal-mmo app in the TrueNAS UI.") def main(argv: list[str] | None = None) -> int: args = parse_args(sys.argv[1:] if argv is None else argv) ensure_git_target(dry_run=args.dry_run) version = resolve_version(args.version) message = args.message or f"Release v{version} {datetime.now():%Y-%m-%d}" token = None if args.dry_run or args.skip_release else gitea_token() if not args.dry_run: write_package_version(version) apk = run_checks(version=version, skip_checks=args.skip_checks) if args.dry_run: print(f"Dry run complete for v{version}. No Git, Gitea, or TrueNAS state changed.") return 0 commit = commit_and_push(message) tag = ensure_tag(version, commit, message) if token: release = create_release(tag, commit, message, token) upload_apk(release, apk, token) if not args.skip_truenas: update_truenas() print(f"Publish complete: {tag}") return 0 if __name__ == "__main__": raise SystemExit(main())