Release v0.1.0 2026-07-10
This commit is contained in:
Executable → Regular
+300
-18
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user