Release v0.1.0 2026-07-10

This commit is contained in:
Warren H
2026-07-10 22:03:51 -04:00
parent e0be0458aa
commit 537a311f52
65 changed files with 2303 additions and 56 deletions
Executable → Regular
+300 -18
View File
@@ -1,25 +1,50 @@
#!/usr/bin/env python3
"""Build, test, commit, and publish the game to its Gitea repository."""
"""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/app")
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) -> None:
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, check=True)
subprocess.run(
args,
cwd=cwd,
env={**os.environ, **(env or {})},
check=True,
)
def capture(args: list[str], *, cwd: Path = REPO_ROOT) -> str:
@@ -33,20 +58,27 @@ def capture(args: list[str], *, cwd: Path = REPO_ROOT) -> str:
def parse_args(argv: list[str]) -> argparse.Namespace:
default_message = f"Update 3D game {datetime.now():%Y-%m-%d %H:%M}"
parser = argparse.ArgumentParser(
description="Build, test, and push I Want to Heal 3D MMO to Gitea."
description="Build and publish I Want to Heal source plus a Thor test APK to Gitea."
)
parser.add_argument(
"-m",
"--message",
default=default_message,
help=f"Git commit message (default: {default_message!r}).",
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, build, and test checks before publishing.",
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",
@@ -56,7 +88,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
parser.add_argument(
"--dry-run",
action="store_true",
help="Run checks and validate Git configuration without staging, committing, pushing, or pulling.",
help="Run builds and tests without changing package version, Git, Gitea, or TrueNAS.",
)
return parser.parse_args(argv)
@@ -73,11 +105,88 @@ def package_manager() -> list[str]:
raise SystemExit("pnpm or corepack is required")
def run_checks() -> None:
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()
run([*pnpm, "install", "--frozen-lockfile"])
if not skip_checks:
run([*pnpm, "install", "--frozen-lockfile"])
run([*pnpm, "run", "build"])
run([*pnpm, "test"])
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:
@@ -116,13 +225,178 @@ def has_staged_changes() -> bool:
return result.returncode == 1
def commit_and_push(message: str) -> None:
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 = "ed2db3fd54546e9658377d0551b3fc3961583f1d"
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:
@@ -148,18 +422,26 @@ def update_truenas() -> None:
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.skip_checks:
run_checks()
if not args.dry_run:
write_package_version(version)
apk = run_checks(version=version, skip_checks=args.skip_checks)
if args.dry_run:
print("Dry run complete. No Git or TrueNAS state changed.")
print(f"Dry run complete for v{version}. No Git, Gitea, or TrueNAS state changed.")
return 0
commit_and_push(args.message)
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("Publish complete.")
print(f"Publish complete: {tag}")
return 0