Files
i-want-to-heal-mmo/scripts/publish_gitea.py
T
2026-07-13 22:50:51 -04:00

496 lines
15 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 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 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())