Files
pristontale-eu-linux/release_tests/test_gitea_publisher.py

644 lines
17 KiB
Python

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()