diff --git a/IWantToHeal-Thor-v1.1.26.apk b/IWantToHeal-Thor-v1.1.26.apk index f38f726..761b0be 100644 Binary files a/IWantToHeal-Thor-v1.1.26.apk and b/IWantToHeal-Thor-v1.1.26.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index f29bcde..495f50e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,7 +7,7 @@ android { applicationId "com.warren.iwanttoheal" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 105 + versionCode 106 versionName "1.1.26" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { diff --git a/docs/push-updates.md b/docs/push-updates.md index 789199b..9018eb4 100644 --- a/docs/push-updates.md +++ b/docs/push-updates.md @@ -96,6 +96,12 @@ curl -sS -X POST "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases/$REL -F "attachment=@$APK" ``` +If the CLI release fails after creating the Gitea release but before uploading the APK asset, retry only the release upload: + +```sh +npm run release:cli -- --release-only 1.1.26 +``` + ## Step 5: Update TrueNAS ```sh diff --git a/scripts/__pycache__/release_game.cpython-314 2.pyc b/scripts/__pycache__/release_game.cpython-314 2.pyc new file mode 100644 index 0000000..5a725c8 Binary files /dev/null and b/scripts/__pycache__/release_game.cpython-314 2.pyc differ diff --git a/scripts/release_game.py b/scripts/release_game.py index b1d6252..a3c761e 100755 --- a/scripts/release_game.py +++ b/scripts/release_game.py @@ -5,6 +5,7 @@ import json import os import queue import re +import shutil import subprocess import sys import threading @@ -153,6 +154,48 @@ def gitea_request( except urllib.error.HTTPError as exc: data = exc.read() return exc.code, data + except urllib.error.URLError as exc: + raise ReleaseError(f"{method} {url} failed: {exc.reason}") from exc + + +def gitea_upload_asset(config: ReleaseConfig, path: str, apk_path: Path) -> tuple[int, bytes]: + curl = shutil.which("curl") + if curl is None: + raise ReleaseError("curl is required for Gitea asset uploads") + + url = config.gitea_url.rstrip("/") + path + proc = subprocess.run( + [ + curl, + "--silent", + "--show-error", + "--retry", + "3", + "--retry-all-errors", + "--retry-delay", + "2", + "--request", + "POST", + url, + "--header", + f"Authorization: token {config.token}", + "--form", + f"attachment=@{apk_path}", + "--write-out", + "\n%{http_code}", + ], + check=False, + capture_output=True, + ) + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout).decode("utf-8", "replace").strip() + raise ReleaseError(f"Gitea asset upload failed: curl exited {proc.returncode}: {detail}") + try: + body, status_text = proc.stdout.rsplit(b"\n", 1) + return int(status_text), body + except ValueError as exc: + output = proc.stdout.decode("utf-8", "replace") + raise ReleaseError(f"Gitea asset upload returned malformed curl output: {output}") from exc def parse_json_response(status: int, data: bytes, action: str) -> dict: @@ -211,26 +254,12 @@ def upload_release_asset(config: ReleaseConfig, log: Callable[[str], None]) -> N raise ReleaseError(f"APK not found for release upload: {apk_path}") release_id = find_or_create_release(config, log) - boundary = f"----iwanttoheal{int(time.time() * 1000)}" - header = ( - f"--{boundary}\r\n" - f'Content-Disposition: form-data; name="attachment"; filename="{apk_path.name}"\r\n' - "Content-Type: application/vnd.android.package-archive\r\n\r\n" - ).encode("utf-8") - footer = f"\r\n--{boundary}--\r\n".encode("utf-8") - body = header + apk_path.read_bytes() + footer asset_name = urllib.parse.quote(apk_path.name) path = ( f"/api/v1/repos/{config.gitea_owner}/{config.gitea_repo}" f"/releases/{release_id}/assets?name={asset_name}" ) - status, data = gitea_request( - config, - "POST", - path, - body=body, - content_type=f"multipart/form-data; boundary={boundary}", - ) + status, data = gitea_upload_asset(config, path, apk_path) if status == 409: raise ReleaseError(f"Release asset already exists: {apk_path.name}") parse_json_response(status, data, "Upload release asset") diff --git a/scripts/release_game_cli.py b/scripts/release_game_cli.py index 610c4a8..584ac76 100755 --- a/scripts/release_game_cli.py +++ b/scripts/release_game_cli.py @@ -68,9 +68,16 @@ def parse_args(argv: list[str]) -> argparse.Namespace: action="store_true", help="Skip the RESET confirmation when used with --reset-player-data.", ) + parser.add_argument( + "--release-only", + metavar="VERSION", + help="Create or reuse the Gitea release and upload an existing IWantToHeal-Thor-vVERSION.apk, without rebuilding.", + ) args = parser.parse_args(argv) if args.yes_reset and not args.reset_player_data: parser.error("--yes-reset requires --reset-player-data") + if args.release_only and args.reset_player_data: + parser.error("--release-only cannot be combined with --reset-player-data") return args @@ -131,44 +138,90 @@ def request_json(method: str, path: str, token: str, data: dict | None = None) - except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", "replace") raise SystemExit(f"Gitea {method} failed ({exc.code}): {detail}") from exc + except urllib.error.URLError as exc: + raise SystemExit(f"Gitea {method} failed: {exc.reason}") from exc def upload_asset(path: str, token: str, apk: Path) -> dict: - boundary = "----iwanttoheal-release-boundary" - body = ( - f"--{boundary}\r\n" - f'Content-Disposition: form-data; name="attachment"; filename="{apk.name}"\r\n' - "Content-Type: application/vnd.android.package-archive\r\n\r\n" - ).encode("utf-8") - body += apk.read_bytes() - body += f"\r\n--{boundary}--\r\n".encode("utf-8") - headers = { - "Authorization": f"token {token}", - "Content-Type": f"multipart/form-data; boundary={boundary}", - } - req = urllib.request.Request(GITEA_URL + path, data=body, headers=headers, method="POST") + url = GITEA_URL + path + curl = shutil.which("curl") + if curl is None: + raise SystemExit("curl is required for Gitea asset uploads") + + proc = subprocess.run( + [ + curl, + "--silent", + "--show-error", + "--retry", + "3", + "--retry-all-errors", + "--retry-delay", + "2", + "--request", + "POST", + url, + "--header", + f"Authorization: token {token}", + "--form", + f"attachment=@{apk}", + "--write-out", + "\n%{http_code}", + ], + check=False, + capture_output=True, + text=True, + ) + if proc.returncode != 0: + detail = proc.stderr.strip() or proc.stdout.strip() + raise SystemExit(f"Gitea asset upload failed: curl exited {proc.returncode}: {detail}") + + try: + body, status_text = proc.stdout.rsplit("\n", 1) + status = int(status_text) + except ValueError as exc: + raise SystemExit(f"Gitea asset upload returned malformed curl output: {proc.stdout}") from exc + + if status >= 400: + raise SystemExit(f"Gitea asset upload failed ({status}): {body}") + + try: + parsed = json.loads(body or "{}") + except json.JSONDecodeError as exc: + raise SystemExit(f"Gitea asset upload returned invalid JSON: {body}") from exc + if not isinstance(parsed, dict): + raise SystemExit(f"Gitea asset upload returned unexpected JSON: {parsed}") + return parsed + + +def find_or_create_release(version: str, token: str) -> int: + repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}" + tag = f"v{version}" + tag_path = repo_path + "/releases/tags/" + urllib.parse.quote(tag, safe="") + req = urllib.request.Request(GITEA_URL + tag_path, headers={"Authorization": f"token {token}"}, method="GET") try: with urllib.request.urlopen(req) as resp: - return json.loads(resp.read().decode("utf-8")) + release = json.loads(resp.read().decode("utf-8")) + release_id = release.get("id") + if not release_id: + raise SystemExit(f"Gitea release missing id: {release}") + print(f"Gitea release exists: {tag} (id {release_id})") + return int(release_id) except urllib.error.HTTPError as exc: - detail = exc.read().decode("utf-8", "replace") - raise SystemExit(f"Gitea asset upload failed ({exc.code}): {detail}") from exc + if exc.code != 404: + detail = exc.read().decode("utf-8", "replace") + raise SystemExit(f"Gitea release lookup failed ({exc.code}): {detail}") from exc + except urllib.error.URLError as exc: + raise SystemExit(f"Gitea release lookup failed: {exc.reason}") from exc - -def create_gitea_release(version: str, apk: Path) -> None: - token = GITEA_TOKEN.strip() - if not token or token == "PASTE_YOUR_GITEA_TOKEN_HERE": - raise SystemExit("Set GITEA_TOKEN near top of scripts/release_game_cli.py") - - repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}" release = request_json( "POST", repo_path + "/releases", token, { - "tag_name": f"v{version}", + "tag_name": tag, "target_commitish": BRANCH, - "name": f"v{version}", + "name": tag, "body": f"I Want to Heal Android build v{version}", "draft": False, "prerelease": False, @@ -177,7 +230,17 @@ def create_gitea_release(version: str, apk: Path) -> None: release_id = release.get("id") if not release_id: raise SystemExit(f"Gitea release missing id: {release}") + print(f"Gitea release created: {tag} (id {release_id})") + return int(release_id) + +def create_gitea_release(version: str, apk: Path) -> None: + token = GITEA_TOKEN.strip() + if not token or token == "PASTE_YOUR_GITEA_TOKEN_HERE": + raise SystemExit("Set GITEA_TOKEN near top of scripts/release_game_cli.py") + + repo_path = f"/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}" + release_id = find_or_create_release(version, token) asset_name = urllib.parse.quote(apk.name) upload_asset(f"{repo_path}/releases/{release_id}/assets?name={asset_name}", token, apk) print(f"Gitea release uploaded: v{version}") @@ -289,6 +352,16 @@ def reset_player_data(database_path: Path, *, assume_yes: bool) -> None: def main(argv: list[str] | None = None) -> int: args = parse_args(sys.argv[1:] if argv is None else argv) os.chdir(REPO_ROOT) + if args.release_only: + version = args.release_only.strip() + if not re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", version): + raise SystemExit("Version must look like 1.1.2") + apk = REPO_ROOT / f"IWantToHeal-Thor-v{version}.apk" + if not apk.exists(): + raise SystemExit(f"APK not found: {apk}") + create_gitea_release(version, apk) + return 0 + version = prompt_version() apk = build_apk(version) commit_and_push(version)