I Want To Heal 2 build v1.0.5 code
This commit is contained in:
Executable
+562
@@ -0,0 +1,562 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tkinter deployment helper for I Want To Heal 2."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def relaunch_with_tk_python() -> None:
|
||||
if os.environ.get("DEPLOY_GUI_TK_RELAUNCH") == "1":
|
||||
return
|
||||
candidates = [
|
||||
"/Library/Frameworks/Python.framework/Versions/3.12/bin/python3",
|
||||
"/usr/local/bin/python3",
|
||||
"/usr/bin/python3",
|
||||
]
|
||||
for candidate in candidates:
|
||||
if Path(candidate) == Path(sys.executable):
|
||||
continue
|
||||
probe = subprocess.run(
|
||||
[candidate, "-c", "import tkinter"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
if probe.returncode == 0:
|
||||
env = os.environ.copy()
|
||||
env["DEPLOY_GUI_TK_RELAUNCH"] = "1"
|
||||
os.execve(candidate, [candidate, *sys.argv], env)
|
||||
|
||||
|
||||
try:
|
||||
from tkinter import END, StringVar, Text, Tk, messagebox
|
||||
from tkinter import ttk
|
||||
from tkinter.scrolledtext import ScrolledText
|
||||
except ModuleNotFoundError as error:
|
||||
if error.name != "_tkinter":
|
||||
raise
|
||||
relaunch_with_tk_python()
|
||||
raise SystemExit(
|
||||
"Tkinter is not available in this Python. Run scripts/deploy-gui.sh "
|
||||
"or install python.org Python 3 with Tk support."
|
||||
)
|
||||
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||||
VERSION_RE = re.compile(r"^[0-9]+[.][0-9]+[.][0-9]+$")
|
||||
|
||||
DEFAULT_GITEA_URL = "https://git.whoagland.com"
|
||||
DEFAULT_GITEA_OWNER = "phenom"
|
||||
DEFAULT_GITEA_REPO = "i-want-to-heal-2"
|
||||
DEFAULT_BRANCH = "main"
|
||||
MAX_STAGED_FILE_BYTES = 200 * 1024 * 1024
|
||||
|
||||
|
||||
class ApiError(RuntimeError):
|
||||
def __init__(self, status: int, message: str, body: str = "") -> None:
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.body = body
|
||||
|
||||
|
||||
class CommandError(RuntimeError):
|
||||
def __init__(self, command: list[str], return_code: int, output: str) -> None:
|
||||
super().__init__(f"Command failed with exit {return_code}: {shell_join(command)}")
|
||||
self.command = command
|
||||
self.return_code = return_code
|
||||
self.output = output
|
||||
|
||||
|
||||
def shell_join(command: list[str]) -> str:
|
||||
return shlex.join(command)
|
||||
|
||||
|
||||
def run_command(
|
||||
command: list[str],
|
||||
env: dict[str, str],
|
||||
log: callable,
|
||||
*,
|
||||
check: bool = True,
|
||||
) -> int:
|
||||
log(f"$ {shell_join(command)}")
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
cwd=ROOT_DIR,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
assert process.stdout is not None
|
||||
output_lines = []
|
||||
for line in process.stdout:
|
||||
clean_line = line.rstrip("\n")
|
||||
output_lines.append(clean_line)
|
||||
log(clean_line)
|
||||
return_code = process.wait()
|
||||
if check and return_code != 0:
|
||||
raise CommandError(command, return_code, "\n".join(output_lines))
|
||||
return return_code
|
||||
|
||||
|
||||
def read_command(command: list[str], env: dict[str, str]) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
command,
|
||||
cwd=ROOT_DIR,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def request_json(
|
||||
method: str,
|
||||
url: str,
|
||||
token: str,
|
||||
*,
|
||||
payload: dict | None = None,
|
||||
) -> dict:
|
||||
data = None
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"token {token}",
|
||||
}
|
||||
if payload is not None:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=60) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as error:
|
||||
body = error.read().decode("utf-8", errors="replace")
|
||||
raise ApiError(error.code, f"Gitea API returned HTTP {error.code}", body) from error
|
||||
|
||||
if not body:
|
||||
return {}
|
||||
return json.loads(body)
|
||||
|
||||
|
||||
def upload_release_asset(url: str, token: str, apk_path: Path) -> dict:
|
||||
boundary = f"----codex-deploy-{uuid.uuid4().hex}"
|
||||
content_type = mimetypes.guess_type(apk_path.name)[0] or "application/octet-stream"
|
||||
file_bytes = apk_path.read_bytes()
|
||||
parts = [
|
||||
f"--{boundary}\r\n".encode("utf-8"),
|
||||
(
|
||||
'Content-Disposition: form-data; name="attachment"; '
|
||||
f'filename="{apk_path.name}"\r\n'
|
||||
).encode("utf-8"),
|
||||
f"Content-Type: {content_type}\r\n\r\n".encode("utf-8"),
|
||||
file_bytes,
|
||||
f"\r\n--{boundary}--\r\n".encode("utf-8"),
|
||||
]
|
||||
data = b"".join(parts)
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={
|
||||
"Authorization": f"token {token}",
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=180) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as error:
|
||||
body = error.read().decode("utf-8", errors="replace")
|
||||
raise ApiError(error.code, f"Asset upload returned HTTP {error.code}", body) from error
|
||||
return json.loads(body) if body else {}
|
||||
|
||||
|
||||
def api_base(gitea_url: str, owner: str, repo: str) -> str:
|
||||
return (
|
||||
f"{gitea_url.rstrip('/')}/api/v1/repos/"
|
||||
f"{urllib.parse.quote(owner)}/{urllib.parse.quote(repo)}"
|
||||
)
|
||||
|
||||
|
||||
def find_oversized_staged_files(env: dict[str, str]) -> list[tuple[str, int]]:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR"],
|
||||
cwd=ROOT_DIR,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(result.stderr.decode("utf-8", errors="replace").strip())
|
||||
|
||||
oversized = []
|
||||
for raw_path in result.stdout.split(b"\0"):
|
||||
if not raw_path:
|
||||
continue
|
||||
path = raw_path.decode("utf-8", errors="surrogateescape")
|
||||
file_path = ROOT_DIR / path
|
||||
if file_path.exists() and file_path.stat().st_size > MAX_STAGED_FILE_BYTES:
|
||||
oversized.append((path, file_path.stat().st_size))
|
||||
return oversized
|
||||
|
||||
|
||||
def verify_remote_branch_matches(env: dict[str, str], branch: str) -> bool:
|
||||
local = read_command(["git", "rev-parse", branch], env)
|
||||
remote = read_command(["git", "ls-remote", "origin", f"refs/heads/{branch}"], env)
|
||||
if local.returncode != 0 or remote.returncode != 0:
|
||||
return False
|
||||
local_hash = local.stdout.strip()
|
||||
remote_hash = remote.stdout.split()[0] if remote.stdout.strip() else ""
|
||||
return bool(local_hash and remote_hash and local_hash == remote_hash)
|
||||
|
||||
|
||||
def create_or_get_release(
|
||||
gitea_url: str,
|
||||
owner: str,
|
||||
repo: str,
|
||||
branch: str,
|
||||
token: str,
|
||||
version: str,
|
||||
notes: str,
|
||||
log: callable,
|
||||
) -> dict:
|
||||
base = api_base(gitea_url, owner, repo)
|
||||
tag = f"v{version}"
|
||||
payload = {
|
||||
"tag_name": tag,
|
||||
"target_commitish": branch,
|
||||
"name": tag,
|
||||
"body": notes,
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
}
|
||||
log(f"Creating Gitea release {tag}")
|
||||
try:
|
||||
return request_json("POST", f"{base}/releases", token, payload=payload)
|
||||
except ApiError as error:
|
||||
if error.status != 409:
|
||||
raise
|
||||
log(f"Release {tag} already exists; using existing release")
|
||||
encoded_tag = urllib.parse.quote(tag, safe="")
|
||||
return request_json("GET", f"{base}/releases/tags/{encoded_tag}", token)
|
||||
|
||||
|
||||
def upload_apk_asset(
|
||||
gitea_url: str,
|
||||
owner: str,
|
||||
repo: str,
|
||||
token: str,
|
||||
release_id: int,
|
||||
apk_path: Path,
|
||||
log: callable,
|
||||
) -> dict:
|
||||
base = api_base(gitea_url, owner, repo)
|
||||
asset_name = urllib.parse.quote(apk_path.name)
|
||||
url = f"{base}/releases/{release_id}/assets?name={asset_name}"
|
||||
log(f"Uploading APK asset {apk_path.name}")
|
||||
return upload_release_asset(url, token, apk_path)
|
||||
|
||||
|
||||
class DeployGui:
|
||||
def __init__(self) -> None:
|
||||
self.root = Tk()
|
||||
self.root.title("I Want To Heal 2 Deployment")
|
||||
self.root.geometry("980x720")
|
||||
self.log_queue: queue.Queue[tuple[str, str]] = queue.Queue()
|
||||
self.worker: threading.Thread | None = None
|
||||
|
||||
self.version = StringVar(value=self.detect_version())
|
||||
self.gitea_url = StringVar(value=DEFAULT_GITEA_URL)
|
||||
self.gitea_owner = StringVar(value=DEFAULT_GITEA_OWNER)
|
||||
self.gitea_repo = StringVar(value=DEFAULT_GITEA_REPO)
|
||||
self.branch = StringVar(value=DEFAULT_BRANCH)
|
||||
self.token = StringVar(value=os.environ.get("GITEA_TOKEN", ""))
|
||||
self.status = StringVar(value=f"Repo: {ROOT_DIR}")
|
||||
self.build_apk = StringVar(value="1")
|
||||
self.run_checks = StringVar(value="1")
|
||||
self.commit_push = StringVar(value="1")
|
||||
self.create_release = StringVar(value="1")
|
||||
self.default_notes = self.build_default_notes(self.version.get())
|
||||
|
||||
self.build_ui()
|
||||
self.root.after(100, self.drain_log_queue)
|
||||
|
||||
def detect_version(self) -> str:
|
||||
build_file = ROOT_DIR / "android" / "app" / "build.gradle"
|
||||
if build_file.exists():
|
||||
match = re.search(r'versionName\s+"([^"]+)"', build_file.read_text())
|
||||
if match:
|
||||
return match.group(1)
|
||||
return "1.0.0"
|
||||
|
||||
def build_ui(self) -> None:
|
||||
self.root.columnconfigure(0, weight=1)
|
||||
self.root.rowconfigure(3, weight=1)
|
||||
|
||||
main = ttk.Frame(self.root, padding=14)
|
||||
main.grid(row=0, column=0, sticky="nsew")
|
||||
main.columnconfigure(1, weight=1)
|
||||
|
||||
ttk.Label(main, text="APK version").grid(row=0, column=0, sticky="w")
|
||||
ttk.Entry(main, textvariable=self.version, width=20).grid(row=0, column=1, sticky="w")
|
||||
|
||||
ttk.Label(main, text="Gitea token").grid(row=1, column=0, sticky="w", pady=(8, 0))
|
||||
ttk.Entry(main, textvariable=self.token, show="*", width=48).grid(
|
||||
row=1, column=1, sticky="ew", pady=(8, 0)
|
||||
)
|
||||
|
||||
repo_frame = ttk.Frame(main)
|
||||
repo_frame.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(10, 0))
|
||||
for index in range(8):
|
||||
repo_frame.columnconfigure(index, weight=1 if index in (1, 3, 5, 7) else 0)
|
||||
ttk.Label(repo_frame, text="URL").grid(row=0, column=0, sticky="w")
|
||||
ttk.Entry(repo_frame, textvariable=self.gitea_url, width=28).grid(row=0, column=1, sticky="ew")
|
||||
ttk.Label(repo_frame, text="Owner").grid(row=0, column=2, sticky="w", padx=(10, 0))
|
||||
ttk.Entry(repo_frame, textvariable=self.gitea_owner, width=14).grid(row=0, column=3, sticky="ew")
|
||||
ttk.Label(repo_frame, text="Repo").grid(row=0, column=4, sticky="w", padx=(10, 0))
|
||||
ttk.Entry(repo_frame, textvariable=self.gitea_repo, width=20).grid(row=0, column=5, sticky="ew")
|
||||
ttk.Label(repo_frame, text="Branch").grid(row=0, column=6, sticky="w", padx=(10, 0))
|
||||
ttk.Entry(repo_frame, textvariable=self.branch, width=10).grid(row=0, column=7, sticky="ew")
|
||||
|
||||
steps = ttk.LabelFrame(main, text="Steps")
|
||||
steps.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(12, 0))
|
||||
ttk.Checkbutton(
|
||||
steps,
|
||||
text="Build Thor APK with scripts/build-thor-apk.sh",
|
||||
variable=self.build_apk,
|
||||
onvalue="1",
|
||||
offvalue="0",
|
||||
).grid(row=0, column=0, sticky="w", padx=8, pady=4)
|
||||
ttk.Checkbutton(
|
||||
steps,
|
||||
text="Run npm lint and npm build",
|
||||
variable=self.run_checks,
|
||||
onvalue="1",
|
||||
offvalue="0",
|
||||
).grid(row=0, column=1, sticky="w", padx=8, pady=4)
|
||||
ttk.Checkbutton(
|
||||
steps,
|
||||
text="Commit all repo changes and push",
|
||||
variable=self.commit_push,
|
||||
onvalue="1",
|
||||
offvalue="0",
|
||||
).grid(row=1, column=0, sticky="w", padx=8, pady=4)
|
||||
ttk.Checkbutton(
|
||||
steps,
|
||||
text="Create Gitea release and upload APK",
|
||||
variable=self.create_release,
|
||||
onvalue="1",
|
||||
offvalue="0",
|
||||
).grid(row=1, column=1, sticky="w", padx=8, pady=4)
|
||||
|
||||
notes_frame = ttk.Frame(self.root, padding=(14, 0, 14, 0))
|
||||
notes_frame.grid(row=1, column=0, sticky="ew")
|
||||
notes_frame.columnconfigure(0, weight=1)
|
||||
ttk.Label(notes_frame, text="Release notes").grid(row=0, column=0, sticky="w")
|
||||
self.notes = Text(notes_frame, height=6, wrap="word")
|
||||
self.notes.grid(row=1, column=0, sticky="ew")
|
||||
self.notes.insert("1.0", self.default_notes)
|
||||
|
||||
buttons = ttk.Frame(self.root, padding=14)
|
||||
buttons.grid(row=2, column=0, sticky="ew")
|
||||
buttons.columnconfigure(1, weight=1)
|
||||
self.run_button = ttk.Button(buttons, text="Run Deployment", command=self.start_deploy)
|
||||
self.run_button.grid(row=0, column=0, sticky="w")
|
||||
ttk.Label(buttons, textvariable=self.status).grid(row=0, column=1, sticky="w", padx=(12, 0))
|
||||
|
||||
log_frame = ttk.Frame(self.root, padding=(14, 0, 14, 14))
|
||||
log_frame.grid(row=3, column=0, sticky="nsew")
|
||||
log_frame.columnconfigure(0, weight=1)
|
||||
log_frame.rowconfigure(0, weight=1)
|
||||
self.log_text = ScrolledText(log_frame, height=22, wrap="word")
|
||||
self.log_text.grid(row=0, column=0, sticky="nsew")
|
||||
self.log_text.configure(state="disabled")
|
||||
|
||||
def log(self, message: str, level: str = "info") -> None:
|
||||
self.log_queue.put((level, message))
|
||||
|
||||
def drain_log_queue(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
level, message = self.log_queue.get_nowait()
|
||||
if level == "status":
|
||||
self.status.set(message)
|
||||
continue
|
||||
self.log_text.configure(state="normal")
|
||||
self.log_text.insert(END, message + "\n")
|
||||
self.log_text.see(END)
|
||||
self.log_text.configure(state="disabled")
|
||||
except queue.Empty:
|
||||
pass
|
||||
self.root.after(100, self.drain_log_queue)
|
||||
|
||||
def start_deploy(self) -> None:
|
||||
if self.worker and self.worker.is_alive():
|
||||
return
|
||||
|
||||
version = self.version.get().strip()
|
||||
token = self.token.get().strip()
|
||||
if not VERSION_RE.match(version):
|
||||
messagebox.showerror("Invalid version", "Version must look like 1.0.2")
|
||||
return
|
||||
if self.create_release.get() == "1" and not token:
|
||||
messagebox.showerror("Missing token", "Paste a Gitea API token or export GITEA_TOKEN.")
|
||||
return
|
||||
|
||||
notes = self.notes.get("1.0", END).strip()
|
||||
if not notes or notes == self.default_notes:
|
||||
notes = self.build_default_notes(version)
|
||||
|
||||
self.log_text.configure(state="normal")
|
||||
self.log_text.delete("1.0", END)
|
||||
self.log_text.configure(state="disabled")
|
||||
self.run_button.configure(state="disabled")
|
||||
self.status.set("Deployment running")
|
||||
|
||||
args = {
|
||||
"version": version,
|
||||
"notes": notes,
|
||||
"token": token,
|
||||
"gitea_url": self.gitea_url.get().strip(),
|
||||
"owner": self.gitea_owner.get().strip(),
|
||||
"repo": self.gitea_repo.get().strip(),
|
||||
"branch": self.branch.get().strip(),
|
||||
"build_apk": self.build_apk.get() == "1",
|
||||
"run_checks": self.run_checks.get() == "1",
|
||||
"commit_push": self.commit_push.get() == "1",
|
||||
"create_release": self.create_release.get() == "1",
|
||||
}
|
||||
self.worker = threading.Thread(target=self.deploy, kwargs=args, daemon=True)
|
||||
self.worker.start()
|
||||
|
||||
def deploy(
|
||||
self,
|
||||
*,
|
||||
version: str,
|
||||
notes: str,
|
||||
token: str,
|
||||
gitea_url: str,
|
||||
owner: str,
|
||||
repo: str,
|
||||
branch: str,
|
||||
build_apk: bool,
|
||||
run_checks: bool,
|
||||
commit_push: bool,
|
||||
create_release: bool,
|
||||
) -> None:
|
||||
try:
|
||||
env = os.environ.copy()
|
||||
if token:
|
||||
env["GITEA_TOKEN"] = token
|
||||
|
||||
self.log(f"Repo: {ROOT_DIR}")
|
||||
self.log(f"Version: {version}")
|
||||
|
||||
if build_apk:
|
||||
run_command(["scripts/build-thor-apk.sh", version], env, self.log)
|
||||
|
||||
if run_checks:
|
||||
run_command(["npm", "run", "lint"], env, self.log)
|
||||
run_command(["npm", "run", "build"], env, self.log)
|
||||
|
||||
if commit_push:
|
||||
status = read_command(["git", "status", "--short"], env)
|
||||
if status.stdout.strip():
|
||||
self.log("Git changes before commit:")
|
||||
self.log(status.stdout.rstrip())
|
||||
run_command(["git", "add", "."], env, self.log)
|
||||
diff = read_command(["git", "diff", "--cached", "--quiet"], env)
|
||||
if diff.returncode == 0:
|
||||
self.log("No staged changes; skipping commit")
|
||||
else:
|
||||
oversized = find_oversized_staged_files(env)
|
||||
if oversized:
|
||||
formatted = "\n".join(
|
||||
f"- {path} ({size / 1024 / 1024:.1f} MiB)"
|
||||
for path, size in oversized
|
||||
)
|
||||
raise RuntimeError(
|
||||
"Refusing to commit oversized files. Add them to .gitignore "
|
||||
f"or upload them as release assets instead:\n{formatted}"
|
||||
)
|
||||
run_command(
|
||||
["git", "commit", "-m", f"I Want To Heal 2 build v{version}"],
|
||||
env,
|
||||
self.log,
|
||||
)
|
||||
try:
|
||||
run_command(["git", "push", "origin", branch], env, self.log)
|
||||
except CommandError:
|
||||
if verify_remote_branch_matches(env, branch):
|
||||
self.log("Push reported an error, but remote branch matches local commit")
|
||||
else:
|
||||
raise
|
||||
|
||||
if create_release:
|
||||
apk_path = ROOT_DIR / f"IWantToHeal2-Thor-v{version}.apk"
|
||||
if not apk_path.exists():
|
||||
raise RuntimeError(f"APK missing: {apk_path}")
|
||||
release = create_or_get_release(
|
||||
gitea_url,
|
||||
owner,
|
||||
repo,
|
||||
branch,
|
||||
token,
|
||||
version,
|
||||
notes,
|
||||
self.log,
|
||||
)
|
||||
release_id = release.get("id")
|
||||
if not release_id:
|
||||
raise RuntimeError(f"Release response did not include id: {release}")
|
||||
self.log(f"Release ID: {release_id}")
|
||||
asset = upload_apk_asset(
|
||||
gitea_url,
|
||||
owner,
|
||||
repo,
|
||||
token,
|
||||
int(release_id),
|
||||
apk_path,
|
||||
self.log,
|
||||
)
|
||||
self.log(f"Uploaded asset: {asset.get('name', apk_path.name)}")
|
||||
|
||||
self.log("Deployment complete")
|
||||
self.log_queue.put(("status", "Deployment complete"))
|
||||
except Exception as error: # noqa: BLE001 - display deployment failures in GUI.
|
||||
self.log(f"ERROR: {error}")
|
||||
if isinstance(error, ApiError) and error.body:
|
||||
self.log(error.body)
|
||||
self.log_queue.put(("status", "Deployment failed"))
|
||||
finally:
|
||||
self.root.after(0, lambda: self.run_button.configure(state="normal"))
|
||||
|
||||
def run(self) -> None:
|
||||
self.root.mainloop()
|
||||
|
||||
@staticmethod
|
||||
def build_default_notes(version: str) -> str:
|
||||
return f"I Want To Heal 2 Android and web/server build v{version}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
DeployGui().run()
|
||||
Reference in New Issue
Block a user