diff --git a/.gitignore b/.gitignore index 9dcede2..50167db 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ vite.config.d.ts .env .env.* !.env.example +/git-token __pycache__/ *.py[cod] /public/basis/ diff --git a/README.md b/README.md index 7750491..75553da 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,16 @@ The repository target is: https://git.whoagland.com/phenom/i-want-to-heal-mmo.git ``` -The Mac publisher is configured with the Gitea release token. `GITEA_TOKEN` can -optionally override it for one run. Publish from `main`: +Create `git-token` in the repository root. Paste only the Gitea token into it: + +```text +gitea_token_value_goes_here +``` + +Do not add quotes or a `GITEA_TOKEN=` prefix. The exact `/git-token` path is +Git-ignored. Restrict local file access with `chmod 600 git-token`. The publisher +reads it automatically. `GITEA_TOKEN` remains available as an optional override. +Publish from `main` normally: ```bash pnpm publish:gitea -- --message "Describe the update" diff --git a/package.json b/package.json index e492d1f..6d38d8f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "i-want-to-heal", "private": true, - "version": "0.1.15", + "version": "0.1.16", "type": "module", "scripts": { "predev": "node scripts/sync_basis_transcoder.mjs", diff --git a/scripts/publish_gitea.py b/scripts/publish_gitea.py index 823fc1e..4101fd6 100644 --- a/scripts/publish_gitea.py +++ b/scripts/publish_gitea.py @@ -11,6 +11,7 @@ import re import shutil import subprocess import sys +import time import urllib.error import urllib.parse import urllib.request @@ -21,9 +22,9 @@ 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_TOKEN = "ed2db3fd54546e9658377d0551b3fc3961583f1d" GITEA_OWNER = "phenom" GITEA_REPO = "i-want-to-heal-mmo" BRANCH = "main" @@ -34,6 +35,15 @@ TRUENAS_GITEA_REPO = Path( 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], *, @@ -258,11 +268,13 @@ def ensure_tag(version: str, commit: str, message: str) -> str: def gitea_token() -> str: - token = os.environ.get("GITEA_TOKEN", GITEA_TOKEN).strip() + 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( - "GITEA_TOKEN is required to create the release and upload the APK. " - "Use --skip-release to push source/tag only." + f"Gitea token is required. Paste it into {GITEA_TOKEN_FILE}, " + "set GITEA_TOKEN, or use --skip-release." ) return token @@ -294,7 +306,7 @@ def gitea_request( 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 + raise GiteaAPIError(method, path, error.code, details) from error return json.loads(payload) if payload else None @@ -327,12 +339,31 @@ def create_release(tag: str, commit: str, message: str, token: str) -> dict[str, "prerelease": True, } ).encode() - result = gitea_request( - f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases", - token=token, - method="POST", - body=payload, - ) + 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 diff --git a/scripts/test_publish_gitea.py b/scripts/test_publish_gitea.py new file mode 100644 index 0000000..75973e5 --- /dev/null +++ b/scripts/test_publish_gitea.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import os +import unittest +from unittest.mock import MagicMock, patch + +from scripts import publish_gitea + + +class GiteaTokenTests(unittest.TestCase): + def test_reads_ignored_token_file(self) -> None: + token_file = MagicMock() + token_file.is_file.return_value = True + token_file.read_text.return_value = "local-token\n" + with ( + patch.dict(os.environ, {}, clear=True), + patch.object(publish_gitea, "GITEA_TOKEN_FILE", token_file), + ): + self.assertEqual(publish_gitea.gitea_token(), "local-token") + + def test_environment_token_overrides_file(self) -> None: + token_file = MagicMock() + with ( + patch.dict(os.environ, {"GITEA_TOKEN": "environment-token"}, clear=True), + patch.object(publish_gitea, "GITEA_TOKEN_FILE", token_file), + ): + self.assertEqual(publish_gitea.gitea_token(), "environment-token") + token_file.is_file.assert_not_called() + + def test_requires_token(self) -> None: + token_file = MagicMock() + token_file.is_file.return_value = False + with ( + patch.dict(os.environ, {}, clear=True), + patch.object(publish_gitea, "GITEA_TOKEN_FILE", token_file), + ): + with self.assertRaisesRegex(SystemExit, "Gitea token is required"): + publish_gitea.gitea_token() + + +class CreateReleaseTests(unittest.TestCase): + def test_retries_postgres_tag_sync_collision(self) -> None: + collision = publish_gitea.GiteaAPIError( + "POST", + "/repos/phenom/i-want-to-heal-mmo/releases", + 500, + 'duplicate key violates "UQE_release_n" (23505)', + ) + created = {"id": 15, "tag_name": "v0.1.15"} + + with ( + patch.object(publish_gitea, "release_for_tag", return_value=None), + patch.object( + publish_gitea, + "gitea_request", + side_effect=[collision, created], + ) as request, + patch.object(publish_gitea.time, "sleep") as sleep, + ): + result = publish_gitea.create_release( + "v0.1.15", + "ea5d455", + "Release v0.1.15 2026-07-18", + "token", + ) + + self.assertEqual(result, created) + self.assertEqual(request.call_count, 2) + sleep.assert_called_once_with(0.5) + + def test_does_not_retry_unrelated_api_error(self) -> None: + unauthorized = publish_gitea.GiteaAPIError( + "POST", + "/repos/phenom/i-want-to-heal-mmo/releases", + 401, + "unauthorized", + ) + + with ( + patch.object(publish_gitea, "release_for_tag", return_value=None), + patch.object( + publish_gitea, + "gitea_request", + side_effect=unauthorized, + ) as request, + patch.object(publish_gitea.time, "sleep") as sleep, + ): + with self.assertRaises(publish_gitea.GiteaAPIError): + publish_gitea.create_release( + "v0.1.16", + "commit", + "Release v0.1.16", + "token", + ) + + request.assert_called_once() + sleep.assert_not_called() + + +if __name__ == "__main__": + unittest.main()