Release v0.1.16 2026-07-18
This commit is contained in:
@@ -10,6 +10,7 @@ vite.config.d.ts
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
/git-token
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
/public/basis/
|
/public/basis/
|
||||||
|
|||||||
@@ -97,8 +97,16 @@ The repository target is:
|
|||||||
https://git.whoagland.com/phenom/i-want-to-heal-mmo.git
|
https://git.whoagland.com/phenom/i-want-to-heal-mmo.git
|
||||||
```
|
```
|
||||||
|
|
||||||
The Mac publisher is configured with the Gitea release token. `GITEA_TOKEN` can
|
Create `git-token` in the repository root. Paste only the Gitea token into it:
|
||||||
optionally override it for one run. Publish from `main`:
|
|
||||||
|
```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
|
```bash
|
||||||
pnpm publish:gitea -- --message "Describe the update"
|
pnpm publish:gitea -- --message "Describe the update"
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "i-want-to-heal",
|
"name": "i-want-to-heal",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.15",
|
"version": "0.1.16",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "node scripts/sync_basis_transcoder.mjs",
|
"predev": "node scripts/sync_basis_transcoder.mjs",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -21,9 +22,9 @@ from pathlib import Path
|
|||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
ANDROID_ROOT = REPO_ROOT / "android"
|
ANDROID_ROOT = REPO_ROOT / "android"
|
||||||
PACKAGE_JSON = REPO_ROOT / "package.json"
|
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_REMOTE = "https://git.whoagland.com/phenom/i-want-to-heal-mmo.git"
|
||||||
GITEA_API = "https://git.whoagland.com/api/v1"
|
GITEA_API = "https://git.whoagland.com/api/v1"
|
||||||
GITEA_TOKEN = "ed2db3fd54546e9658377d0551b3fc3961583f1d"
|
|
||||||
GITEA_OWNER = "phenom"
|
GITEA_OWNER = "phenom"
|
||||||
GITEA_REPO = "i-want-to-heal-mmo"
|
GITEA_REPO = "i-want-to-heal-mmo"
|
||||||
BRANCH = "main"
|
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*)$")
|
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(
|
def run(
|
||||||
args: list[str],
|
args: list[str],
|
||||||
*,
|
*,
|
||||||
@@ -258,11 +268,13 @@ def ensure_tag(version: str, commit: str, message: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def gitea_token() -> 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:
|
if not token:
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
"GITEA_TOKEN is required to create the release and upload the APK. "
|
f"Gitea token is required. Paste it into {GITEA_TOKEN_FILE}, "
|
||||||
"Use --skip-release to push source/tag only."
|
"set GITEA_TOKEN, or use --skip-release."
|
||||||
)
|
)
|
||||||
return token
|
return token
|
||||||
|
|
||||||
@@ -294,7 +306,7 @@ def gitea_request(
|
|||||||
details = error.read().decode(errors="replace")
|
details = error.read().decode(errors="replace")
|
||||||
if allow_not_found and error.code == 404:
|
if allow_not_found and error.code == 404:
|
||||||
return None
|
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
|
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,
|
"prerelease": True,
|
||||||
}
|
}
|
||||||
).encode()
|
).encode()
|
||||||
|
path = f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases"
|
||||||
|
result = None
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
result = gitea_request(
|
result = gitea_request(
|
||||||
f"/repos/{GITEA_OWNER}/{GITEA_REPO}/releases",
|
path,
|
||||||
token=token,
|
token=token,
|
||||||
method="POST",
|
method="POST",
|
||||||
body=payload,
|
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):
|
if not isinstance(result, dict):
|
||||||
raise SystemExit("Gitea returned an invalid release response")
|
raise SystemExit("Gitea returned an invalid release response")
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user