527 lines
16 KiB
Python
527 lines
16 KiB
Python
#!/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 shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
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_TOKEN_FILE = REPO_ROOT / "git-token"
|
|
GITEA_REMOTE = "https://git.whoagland.com/phenom/i-want-to-heal-mmo.git"
|
|
GITEA_API = "https://git.whoagland.com/api/v1"
|
|
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*)$")
|
|
|
|
|
|
class GiteaAPIError(SystemExit):
|
|
def __init__(self, method: str, path: str, status: int, details: str) -> None:
|
|
self.method = method
|
|
self.path = path
|
|
self.status = status
|
|
self.details = details
|
|
super().__init__(f"Gitea API {method} {path} failed ({status}): {details}")
|
|
|
|
|
|
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", "").strip()
|
|
if not token and GITEA_TOKEN_FILE.is_file():
|
|
token = GITEA_TOKEN_FILE.read_text().strip()
|
|
if not token:
|
|
raise SystemExit(
|
|
f"Gitea token is required. Paste it into {GITEA_TOKEN_FILE}, "
|
|
"set GITEA_TOKEN, or use --skip-release."
|
|
)
|
|
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 GiteaAPIError(method, path, 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()
|
|
path = f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases"
|
|
result = None
|
|
for attempt in range(3):
|
|
try:
|
|
result = gitea_request(
|
|
path,
|
|
token=token,
|
|
method="POST",
|
|
body=payload,
|
|
)
|
|
break
|
|
except GiteaAPIError as error:
|
|
tag_sync_race = (
|
|
error.status == 500
|
|
and "UQE_release_n" in error.details
|
|
and "23505" in error.details
|
|
)
|
|
if not tag_sync_race or attempt == 2:
|
|
raise
|
|
delay = 0.5 * (attempt + 1)
|
|
print(
|
|
f"Gitea tag sync still settling for {tag}; "
|
|
f"retrying release in {delay:g}s."
|
|
)
|
|
time.sleep(delay)
|
|
if not isinstance(result, dict):
|
|
raise SystemExit("Gitea returned an invalid release response")
|
|
return result
|
|
|
|
|
|
def upload_release_asset(
|
|
release_id: int,
|
|
path: Path,
|
|
*,
|
|
media_type: str,
|
|
token: str,
|
|
) -> None:
|
|
curl = shutil.which("curl")
|
|
if not curl:
|
|
raise SystemExit("curl is required to upload Gitea release assets")
|
|
|
|
url = (
|
|
f"{GITEA_API}/repos/{GITEA_OWNER}/{GITEA_REPO}/releases/{release_id}/assets"
|
|
f"?name={urllib.parse.quote(path.name)}"
|
|
)
|
|
headers = (
|
|
"Accept: application/json\n"
|
|
f"Authorization: token {token}\n"
|
|
"Expect: 100-continue\n"
|
|
)
|
|
result = subprocess.run(
|
|
[
|
|
curl,
|
|
"--silent",
|
|
"--show-error",
|
|
"--fail-with-body",
|
|
"--connect-timeout",
|
|
"30",
|
|
"--max-time",
|
|
"300",
|
|
"--header",
|
|
"@-",
|
|
"--form",
|
|
f"attachment=@{path};type={media_type}",
|
|
url,
|
|
],
|
|
cwd=REPO_ROOT,
|
|
input=headers,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode:
|
|
details = result.stdout.strip() or result.stderr.strip()
|
|
raise SystemExit(
|
|
f"Gitea release asset upload failed (curl {result.returncode}): {details}"
|
|
)
|
|
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())
|