from __future__ import annotations import base64 import json import os from pathlib import Path import shutil import subprocess import sys import tempfile import unittest from scripts.release_lib import verify_signature PROJECT_ROOT = Path(__file__).resolve().parents[1] MOCK_CURL = r'''#!/usr/bin/env bash set -euo pipefail METHOD="GET" OUTPUT="" FORM="" DATA_FILE="" URL="" while (( $# > 0 )) do case "$1" in --request) METHOD="$2" shift 2 ;; --output) OUTPUT="$2" shift 2 ;; --form) FORM="$2" shift 2 ;; --data-binary) DATA_FILE="${2#@}" shift 2 ;; --connect-timeout|--max-time|--header|--write-out) shift 2 ;; --silent|--show-error) shift ;; *) URL="$1" shift ;; esac done release_json() { local assets assets="$(jq -s '.' "${MOCK_STATE_DIR}"/asset-*.json)" jq -n \ --arg html_url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" \ --arg published_at "2026-09-08T12:00:00Z" \ --arg tag "$RELEASE_TAG" \ --arg name "PristonTale EU Launcher ${MOCK_VERSION}" \ --arg target_commitish "$RELEASE_COMMIT" \ --argjson assets "$assets" \ --argjson prerelease "$RELEASE_PRERELEASE" \ '{id:71,tag_name:$tag,name:$name,draft:false,prerelease:$prerelease,target_commitish:$target_commitish,published_at:$published_at,created_at:$published_at,html_url:$html_url,assets:$assets}' } case "${METHOD} ${URL}" in "GET "*"/releases/tags/"*) if [[ "${MOCK_TIMEOUT_STAGE:-}" == "existing" ]] then printf 'curl: (28) simulated release lookup timeout\n' >&2 exit 28 fi if [[ -n "${MOCK_EXISTING_STATUS:-}" ]] then printf '%s\n' '{"message":"simulated release lookup failure"}' > "$OUTPUT" printf '%s' "$MOCK_EXISTING_STATUS" exit 0 fi if [[ -f "${MOCK_STATE_DIR}/created" ]] then release_json > "$OUTPUT" printf '200' else printf '%s\n' '{"message":"not found"}' > "$OUTPUT" printf '404' fi ;; "POST "*"/releases") if [[ "${MOCK_TIMEOUT_STAGE:-}" == "create" ]] then printf 'curl: (28) simulated release creation timeout\n' >&2 exit 28 fi if [[ -n "${MOCK_CREATE_STATUS:-}" ]] then printf '%s\n' '{"message":"simulated release creation failure"}' > "$OUTPUT" printf '%s' "$MOCK_CREATE_STATUS" exit 0 fi cp -- "$DATA_FILE" "${MOCK_STATE_DIR}/release-payload.json" : > "${MOCK_STATE_DIR}/created" printf '%s\n' '{"id":71}' > "$OUTPUT" printf '201' ;; "POST "*"/assets?name="*) if [[ "${MOCK_TIMEOUT_STAGE:-}" == "asset" ]] then printf 'curl: (28) simulated attachment timeout\n' >&2 exit 28 fi if [[ -n "${MOCK_ASSET_STATUS:-}" ]] then printf '%s\n' '{"message":"simulated attachment failure"}' > "$OUTPUT" printf '%s' "$MOCK_ASSET_STATUS" exit 0 fi name="${URL##*?name=}" file="${FORM#attachment=@}" file="${file%%;*}" size="$(stat -c '%s' -- "$file")" jq -n \ --arg name "$name" \ --arg url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}/${name}" \ --argjson size "$size" \ '{name:$name,size:$size,browser_download_url:$url}' > "$OUTPUT" cp -- "$OUTPUT" "${MOCK_STATE_DIR}/asset-${name}.json" printf '201' ;; "GET "*"/releases?limit=100&page=1") if [[ "${MOCK_RELEASE_PAGINATION:-}" == "1" ]] then jq -n '[range(0;100) as $index | {draft:true,tag_name:("ignored-" + ($index|tostring))}]' > "$OUTPUT" else release_json | jq -s '.' > "$OUTPUT" fi printf '200' ;; "GET "*"/releases?limit=100&page=2") release_json | jq -s '.' > "$OUTPUT" printf '200' ;; "GET "*"/contents/"*) printf '%s\n' '{"message":"not found"}' > "$OUTPUT" printf '404' ;; "POST "*"/contents") cp -- "$DATA_FILE" "${MOCK_STATE_DIR}/channel-payload.json" printf '%s\n' '{"commit":{"sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}' > "$OUTPUT" printf '201' ;; *) printf 'Unexpected mock curl request: %s %s\n' "$METHOD" "$URL" >&2 exit 90 ;; esac ''' MOCK_HISTORY_CURL = r'''#!/usr/bin/env bash set -euo pipefail METHOD="GET" OUTPUT="" DATA_FILE="" URL="" while (( $# > 0 )) do case "$1" in --request) METHOD="$2" shift 2 ;; --output) OUTPUT="$2" shift 2 ;; --data-binary) DATA_FILE="${2#@}" shift 2 ;; --connect-timeout|--max-time|--header|--write-out) shift 2 ;; --silent|--show-error) shift ;; *) URL="$1" shift ;; esac done case "${METHOD} ${URL}" in "GET "*"/releases?limit=100&page=1") cp -- "$MOCK_RELEASES" "$OUTPUT" printf '200' ;; "GET "*"/manifest.json.sig") cp -- "$MOCK_MANIFEST_SIGNATURE" "$OUTPUT" printf '200' ;; "GET "*"/manifest.json") cp -- "$MOCK_MANIFEST" "$OUTPUT" printf '200' ;; "GET "*"/contents/"*) printf '%s\n' '{"message":"not found"}' > "$OUTPUT" printf '404' ;; "POST "*"/contents") cp -- "$DATA_FILE" "$MOCK_HISTORY_PAYLOAD" printf '%s\n' '{"commit":{"sha":"cccccccccccccccccccccccccccccccccccccccc"}}' > "$OUTPUT" printf '201' ;; *) printf 'Unexpected history mock request: %s %s\n' "$METHOD" "$URL" >&2 exit 90 ;; esac ''' def _publisher_manifest(version: str = "1.8.0") -> bytes: archive_name = f"PristonTale-EU-{version}.tar.gz" archive_url = ( f"https://gitea.example.test/owner/project/releases/download/" f"v{version}/{archive_name}" ) return ( json.dumps( { "application": "PristonTale-EU", "archive": { "name": archive_name, "sha256": "a" * 64, "size": 123, "url": archive_url, }, "archive_sha256": "a" * 64, "archive_size": 123, "archive_url": archive_url, "channel": "stable", "compatibility": { "config_schema": 1, "manifest_schema": 1, "minimum_config_schema": 1, "minimum_updater_version": "0.5.0", }, "minimum_updater_version": "0.5.0", "notes_url": ( f"https://gitea.example.test/owner/project/releases/tag/v{version}" ), "published_at": "2026-09-08T12:00:00Z", "schema": 1, "status": "active", "tag": f"v{version}", "version": version, }, indent=2, sort_keys=True, ) + "\n" ).encode() class GiteaPublisherTests(unittest.TestCase): def test_publishes_assets_and_updates_only_the_selected_channel(self) -> None: openssl = shutil.which("openssl") jq = shutil.which("jq") if openssl is None or jq is None: self.skipTest("OpenSSL and jq are required") with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) output = root / "dist" state = root / "state" fake_bin = root / "bin" for directory in (output, state, fake_bin): directory.mkdir() archive = output / "PristonTale-EU-0.9.8.tar.gz" archive.write_bytes(b"archive fixture") (output / f"{archive.name}.sha256").write_text( f"{'a' * 64} {archive.name}\n", encoding="ascii", ) (output / "manifest.json").write_text('{"schema":1}\n', encoding="utf-8") (output / "manifest.json.sig").write_bytes(b"manifest signature") (output / "release-notes.md").write_text("Release notes.\n", encoding="utf-8") private_key = root / "private.pem" public_key = root / "public.pem" subprocess.run( [ openssl, "genpkey", "-quiet", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", "-out", str(private_key), ], check=True, capture_output=True, ) subprocess.run( [ openssl, "pkey", "-in", str(private_key), "-pubout", "-out", str(public_key), ], check=True, capture_output=True, ) fake_curl = fake_bin / "curl" fake_curl.write_text(MOCK_CURL, encoding="utf-8") fake_curl.chmod(0o755) summary = root / "summary.md" environment = { **os.environ, "PATH": f"{fake_bin}:{os.environ['PATH']}", "GITHUB_API_URL": "https://gitea.example.test/api/v1", "GITHUB_REPOSITORY": "owner/project", "GITHUB_SERVER_URL": "https://gitea.example.test", "GITHUB_STEP_SUMMARY": str(summary), "MOCK_STATE_DIR": str(state), "MOCK_RELEASE_PAGINATION": "1", "MOCK_VERSION": "0.9.8", "PTLINUX_PYTHON": sys.executable, "PTLINUX_SIGNING_KEY": str(private_key), "PTLINUX_UPDATE_PUBLIC_KEY": str(public_key), "RELEASE_BRANCH": "main", "RELEASE_COMMIT": "a" * 40, "RELEASE_PRERELEASE": "false", "RELEASE_TAG": "v0.9.8", "RELEASE_TOKEN": "mock-release-token", } result = subprocess.run( [ str(PROJECT_ROOT / "scripts/publish-gitea-release.sh"), "0.9.8", "stable", str(output), str(output / "release-notes.md"), ], cwd=PROJECT_ROOT, env=environment, capture_output=True, text=True, check=False, ) self.assertEqual(result.returncode, 0, result.stderr) release_payload = json.loads( (state / "release-payload.json").read_text(encoding="utf-8") ) self.assertFalse(release_payload["draft"]) self.assertFalse(release_payload["prerelease"]) self.assertEqual(release_payload["tag_name"], "v0.9.8") self.assertEqual(release_payload["body"], "Release notes.\n") self.assertEqual(len(list(state.glob("asset-*.json"))), 5) channel_payload = json.loads( (state / "channel-payload.json").read_text(encoding="utf-8") ) paths = {operation["path"] for operation in channel_payload["files"]} self.assertIn("updates/stable.json", paths) self.assertIn("updates/stable.json.sig", paths) self.assertNotIn("updates/prerelease.json", paths) self.assertIn("updates/history.json", paths) self.assertIn("Status | Published", summary.read_text(encoding="utf-8")) self.assertNotIn("mock-release-token", result.stdout + result.stderr) second = subprocess.run( [ str(PROJECT_ROOT / "scripts/publish-gitea-release.sh"), "0.9.8", "stable", str(output), str(output / "release-notes.md"), ], cwd=PROJECT_ROOT, env=environment, capture_output=True, text=True, check=False, ) self.assertNotEqual(second.returncode, 0) self.assertIn("already exists", second.stderr) def test_status_publication_rebuilds_signed_channel_metadata(self) -> None: openssl = shutil.which("openssl") jq = shutil.which("jq") if openssl is None or jq is None: self.skipTest("OpenSSL and jq are required") with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) fake_bin = root / "bin" fake_bin.mkdir() private_key = root / "private.pem" public_key = root / "public.pem" subprocess.run( [ openssl, "genpkey", "-quiet", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", "-out", str(private_key), ], check=True, capture_output=True, ) subprocess.run( [ openssl, "pkey", "-in", str(private_key), "-pubout", "-out", str(public_key), ], check=True, capture_output=True, ) manifest = root / "manifest.json" manifest.write_bytes(_publisher_manifest()) manifest_signature = root / "manifest.json.sig" subprocess.run( [ openssl, "dgst", "-sha256", "-sign", str(private_key), "-out", str(manifest_signature), str(manifest), ], check=True, capture_output=True, ) releases = root / "releases.json" releases.write_text( json.dumps( [ { "assets": [ { "browser_download_url": ( "https://gitea.example.test/owner/project/" "releases/download/v1.8.0/manifest.json" ), "name": "manifest.json", }, { "browser_download_url": ( "https://gitea.example.test/owner/project/" "releases/download/v1.8.0/manifest.json.sig" ), "name": "manifest.json.sig", }, ], "draft": False, "html_url": ( "https://gitea.example.test/owner/project/releases/tag/v1.8.0" ), "id": 71, "name": "PristonTale EU Launcher 1.8.0", "prerelease": False, "published_at": "2026-09-08T12:00:00Z", "tag_name": "v1.8.0", } ] ), encoding="utf-8", ) statuses = root / "statuses.json" statuses.write_text( ( '{"releases":{"v1.8.0":{"config_schema":2,' '"minimum_config_schema":1,"status":"withdrawn"}},"schema":1}\n' ), encoding="utf-8", ) mock_curl = fake_bin / "curl" mock_curl.write_text(MOCK_HISTORY_CURL, encoding="utf-8") mock_curl.chmod(0o755) payload = root / "history-payload.json" summary = root / "summary.md" environment = { **os.environ, "PATH": f"{fake_bin}:{os.environ['PATH']}", "GITHUB_API_URL": "https://gitea.example.test/api/v1", "GITHUB_REPOSITORY": "owner/project", "GITHUB_SERVER_URL": "https://gitea.example.test", "GITHUB_STEP_SUMMARY": str(summary), "MOCK_HISTORY_PAYLOAD": str(payload), "MOCK_MANIFEST": str(manifest), "MOCK_MANIFEST_SIGNATURE": str(manifest_signature), "MOCK_RELEASES": str(releases), "PTLINUX_PYTHON": sys.executable, "PTLINUX_SIGNING_KEY": str(private_key), "PTLINUX_UPDATE_PUBLIC_KEY": str(public_key), "RELEASE_BRANCH": "main", "RELEASE_STATUS_FILE": str(statuses), "RELEASE_TOKEN": "mock-release-token", } result = subprocess.run( [str(PROJECT_ROOT / "scripts/publish-release-history.sh"), str(root / "dist")], cwd=PROJECT_ROOT, env=environment, capture_output=True, text=True, check=False, ) self.assertEqual(result.returncode, 0, result.stderr) published = json.loads(payload.read_text(encoding="utf-8")) files = {item["path"]: item for item in published["files"]} self.assertEqual( set(files), { "updates/history.json", "updates/history.json.sig", "updates/stable.json", "updates/stable.json.sig", }, ) stable = json.loads(base64.b64decode(files["updates/stable.json"]["content"])) history = json.loads(base64.b64decode(files["updates/history.json"]["content"])) self.assertEqual(stable["status"], "withdrawn") self.assertEqual(history["releases"][0]["status"], "withdrawn") self.assertEqual( history["releases"][0]["compatibility"], {"config_schema": 2, "minimum_config_schema": 1}, ) self.assertEqual( stable["compatibility"]["config_schema"], 2, ) published_stable = root / "published-stable.json" published_stable_signature = root / "published-stable.json.sig" published_stable.write_bytes( base64.b64decode(files["updates/stable.json"]["content"]) ) published_stable_signature.write_bytes( base64.b64decode(files["updates/stable.json.sig"]["content"]) ) verify_signature(published_stable, published_stable_signature, public_key) self.assertIn("stable channel: `1.8.0`", summary.read_text(encoding="utf-8")) self.assertNotIn("mock-release-token", result.stdout + result.stderr) def test_api_failures_never_report_publication_success(self) -> None: if shutil.which("jq") is None: self.skipTest("jq is required") with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) output = root / "dist" state = root / "state" fake_bin = root / "bin" for directory in (output, state, fake_bin): directory.mkdir() archive = output / "PristonTale-EU-0.9.8.tar.gz" archive.write_bytes(b"archive fixture") (output / f"{archive.name}.sha256").write_text( f"{'a' * 64} {archive.name}\n", encoding="ascii", ) for name in ("manifest.json", "manifest.json.sig", "release-notes.md"): (output / name).write_text("fixture\n", encoding="utf-8") fake_curl = fake_bin / "curl" fake_curl.write_text(MOCK_CURL, encoding="utf-8") fake_curl.chmod(0o755) base_environment = { **os.environ, "PATH": f"{fake_bin}:{os.environ['PATH']}", "GITHUB_API_URL": "https://gitea.example.test/api/v1", "GITHUB_REPOSITORY": "owner/project", "GITHUB_SERVER_URL": "https://gitea.example.test", "MOCK_STATE_DIR": str(state), "MOCK_VERSION": "0.9.8", "RELEASE_BRANCH": "main", "RELEASE_COMMIT": "a" * 40, "RELEASE_PRERELEASE": "false", "RELEASE_TAG": "v0.9.8", "RELEASE_TOKEN": "mock-release-token", } command = [ str(PROJECT_ROOT / "scripts/publish-gitea-release.sh"), "0.9.8", "stable", str(output), str(output / "release-notes.md"), ] cases = [ ("MOCK_EXISTING_STATUS", "401"), ("MOCK_EXISTING_STATUS", "403"), ("MOCK_EXISTING_STATUS", "500"), ("MOCK_CREATE_STATUS", "404"), ("MOCK_CREATE_STATUS", "409"), ("MOCK_CREATE_STATUS", "422"), ("MOCK_CREATE_STATUS", "500"), ("MOCK_ASSET_STATUS", "500"), ("MOCK_TIMEOUT_STAGE", "existing"), ("MOCK_TIMEOUT_STAGE", "create"), ("MOCK_TIMEOUT_STAGE", "asset"), ] for variable, value in cases: with self.subTest(variable=variable, value=value): (state / "created").unlink(missing_ok=True) environment = {**base_environment, variable: value} result = subprocess.run( command, cwd=PROJECT_ROOT, env=environment, capture_output=True, text=True, check=False, ) self.assertNotEqual(result.returncode, 0) self.assertNotIn("Published stable release", result.stdout) self.assertNotIn("mock-release-token", result.stdout + result.stderr) if __name__ == "__main__": unittest.main()