Add channel-aware release tooling and updater recovery

This commit is contained in:
2026-09-10 00:39:37 +02:00
parent cc48bf45d2
commit 0a89323533
42 changed files with 7840 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""Tracked preflight tests for the automated release system."""

View File

@@ -0,0 +1,96 @@
from __future__ import annotations
from unittest.mock import patch
import unittest
from ptlinux.self_update import (
DEFAULT_PRERELEASE_UPDATE_MANIFEST_URL,
DEFAULT_STABLE_UPDATE_MANIFEST_URL,
UpdateManifest,
check_for_launcher_update,
configured_manifest_urls,
)
def manifest(version: str, channel: str, *, status: str = "active") -> UpdateManifest:
return UpdateManifest(
schema=1,
channel=channel,
version=version,
minimum_updater_version="0.5.0",
archive_url=f"https://example.test/PristonTale-EU-{version}.tar.gz",
archive_sha256="a" * 64,
archive_size=123,
published_at="2026-09-08T00:00:00Z",
notes_url=f"https://example.test/releases/v{version}",
tag=f"v{version}",
status=status,
)
class ChannelDiscoveryTests(unittest.TestCase):
def test_stable_fetches_only_stable_metadata(self) -> None:
self.assertEqual(
configured_manifest_urls(channel="stable", environment={}),
(("stable", DEFAULT_STABLE_UPDATE_MANIFEST_URL, False),),
)
def test_prerelease_fetches_stable_and_optional_prerelease_metadata(self) -> None:
self.assertEqual(
configured_manifest_urls(channel="prerelease", environment={}),
(
("stable", DEFAULT_STABLE_UPDATE_MANIFEST_URL, False),
("prerelease", DEFAULT_PRERELEASE_UPDATE_MANIFEST_URL, True),
),
)
def test_stable_client_ignores_a_newer_prerelease(self) -> None:
with patch(
"ptlinux.self_update._fetch_authenticated_manifest",
return_value=manifest("1.8.0", "stable"),
) as fetch:
check = check_for_launcher_update(
current_version="1.7.4",
channel="stable",
)
self.assertEqual(fetch.call_count, 1)
self.assertEqual(check.latest_version, "1.8.0")
self.assertEqual(check.manifest.channel, "stable")
self.assertTrue(check.available)
def test_prerelease_client_chooses_the_highest_eligible_release(self) -> None:
def fetch(url: str, *_args: object, **_kwargs: object) -> UpdateManifest:
if url == DEFAULT_STABLE_UPDATE_MANIFEST_URL:
return manifest("1.8.0", "stable")
return manifest("1.9.0-beta.2", "prerelease")
with patch(
"ptlinux.self_update._fetch_authenticated_manifest",
side_effect=fetch,
):
check = check_for_launcher_update(
current_version="1.9.0-beta.1",
channel="prerelease",
)
self.assertEqual(check.latest_version, "1.9.0-beta.2")
self.assertEqual(check.manifest.channel, "prerelease")
self.assertTrue(check.available)
def test_returning_to_stable_never_offers_an_older_stable_release(self) -> None:
with patch(
"ptlinux.self_update._fetch_authenticated_manifest",
return_value=manifest("1.8.0", "stable"),
):
check = check_for_launcher_update(
current_version="1.9.0-beta.2",
channel="stable",
)
self.assertFalse(check.available)
self.assertIn("will not downgrade", check.reason)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,102 @@
from __future__ import annotations
import json
import unittest
from scripts.prepare_channel_metadata import prepare_channel_metadata
from scripts.release_lib import ReleaseToolError
def manifest(version: str, channel: str) -> bytes:
value = {
"application": "PristonTale-EU",
"archive": {
"name": f"PristonTale-EU-{version}.tar.gz",
"sha256": "a" * 64,
"size": 123,
"url": f"https://gitea.example.test/releases/download/v{version}/PristonTale-EU-{version}.tar.gz",
},
"archive_sha256": "a" * 64,
"archive_size": 123,
"archive_url": f"https://gitea.example.test/releases/download/v{version}/PristonTale-EU-{version}.tar.gz",
"channel": channel,
"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/releases/tag/v{version}",
"published_at": "2026-09-08T12:00:00Z",
"schema": 1,
"status": "active",
"tag": f"v{version}",
"version": version,
}
return (json.dumps(value, sort_keys=True) + "\n").encode()
def record(version: str, channel: str, status: str) -> dict[str, object]:
return {
"channel": channel,
"compatibility": {
"config_schema": 1,
"minimum_config_schema": 1,
},
"manifest_url": f"https://gitea.example.test/releases/download/v{version}/manifest.json",
"status": status,
"tag": f"v{version}",
"version": version,
}
class ChannelMetadataTests(unittest.TestCase):
def test_selects_latest_active_release_instead_of_withdrawn_head(self) -> None:
history = {
"schema": 1,
"releases": [
record("1.9.0", "stable", "withdrawn"),
record("1.8.0", "stable", "active"),
],
}
selected, prepared = prepare_channel_metadata(
history,
"stable",
manifest("1.8.0", "stable"),
)
self.assertEqual(selected["version"], "1.8.0")
self.assertEqual(prepared["status"], "active")
def test_marks_channel_inactive_when_no_active_release_exists(self) -> None:
history = {
"schema": 1,
"releases": [record("1.9.0-beta.1", "prerelease", "blocked")],
}
selected, prepared = prepare_channel_metadata(
history,
"prerelease",
manifest("1.9.0-beta.1", "prerelease"),
)
self.assertEqual(selected["version"], "1.9.0-beta.1")
self.assertEqual(prepared["status"], "blocked")
def test_rejects_a_manifest_that_does_not_match_selected_history(self) -> None:
history = {
"schema": 1,
"releases": [record("1.8.0", "stable", "active")],
}
with self.assertRaisesRegex(ReleaseToolError, "does not match"):
prepare_channel_metadata(
history,
"stable",
manifest("1.7.4", "stable"),
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from pathlib import Path
import tempfile
import unittest
from ptlinux.paths import AppPaths
from ptlinux.settings import Settings, load_settings, save_settings
class ChannelSettingsTests(unittest.TestCase):
def test_existing_users_default_to_stable(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = AppPaths(root / "data", root / "config", root / "state", root / "cache")
paths.config.mkdir(parents=True)
paths.settings_file.write_text('{"realm": "Babel"}\n', encoding="utf-8")
self.assertEqual(load_settings(paths).update_channel, "stable")
def test_prerelease_preference_round_trips(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = AppPaths(root / "data", root / "config", root / "state", root / "cache")
save_settings(paths, Settings(update_channel="prerelease"))
self.assertEqual(load_settings(paths).update_channel, "prerelease")
def test_unknown_channel_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = AppPaths(root / "data", root / "config", root / "state", root / "cache")
paths.config.mkdir(parents=True)
paths.settings_file.write_text('{"update_channel": "nightly"}\n', encoding="utf-8")
with self.assertRaisesRegex(ValueError, "update channel"):
load_settings(paths)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,643 @@
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()

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
from contextlib import redirect_stderr
import io
import unittest
from ptlinux.cli import build_parser
class LauncherUpdateCliTests(unittest.TestCase):
def test_bare_rollback_uses_retained_previous_release(self) -> None:
arguments = build_parser().parse_args(["launcher-update", "--rollback"])
self.assertEqual(arguments.rollback, "")
def test_explicit_rollback_accepts_version_and_history_override(self) -> None:
arguments = build_parser().parse_args(
[
"launcher-update",
"--rollback",
"1.8.0",
"--history-url",
"https://updates.example.test/history.json",
]
)
self.assertEqual(arguments.rollback, "1.8.0")
self.assertEqual(
arguments.history_url,
"https://updates.example.test/history.json",
)
def test_rollback_remains_exclusive_with_check_and_verify(self) -> None:
parser = build_parser()
with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
parser.parse_args(["launcher-update", "--rollback", "--check"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,270 @@
from __future__ import annotations
import json
from pathlib import Path
from pathlib import PurePosixPath
import shutil
import subprocess
import tarfile
import tempfile
import unittest
from ptlinux import __version__
from ptlinux.paths import AppPaths
from ptlinux.self_update import (
_evaluate_launcher_update,
_stage_and_install_release,
parse_update_manifest,
)
from scripts.release_lib import (
ReleaseToolError,
_archive_layout_for_version,
_forbid_release_path,
_safe_output_directory,
build_release,
load_config,
sha256_file,
sign_file,
verify_release,
verify_signature,
)
PROJECT_ROOT = Path(__file__).resolve().parents[1]
class ReleaseArtifactTests(unittest.TestCase):
def test_build_is_reproducible_and_matches_the_runtime_parser(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
notes = root / "notes.md"
notes.write_text("## Highlights\n\nChannel-aware updates.\n", encoding="utf-8")
commit = "a" * 40
first = root / "first"
second = root / "second"
for output in (first, second):
build_release(
__version__,
"stable",
output,
repository_url="https://gitea.example.test/owner/project",
commit=commit,
source_date_epoch=1_700_000_000,
release_notes_file=notes,
)
verify_release(__version__, "stable", output)
archive_name = f"PristonTale-EU-{__version__}.tar.gz"
self.assertEqual(
sha256_file(first / archive_name),
sha256_file(second / archive_name),
)
self.assertEqual(
(first / "manifest.json").read_bytes(),
(second / "manifest.json").read_bytes(),
)
manifest = parse_update_manifest((first / "manifest.json").read_bytes())
self.assertEqual(manifest.version, __version__)
self.assertEqual(manifest.channel, "stable")
self.assertEqual(manifest.tag, f"v{__version__}")
self.assertGreater(len(manifest.files), 10)
self.assertEqual(manifest.archive_name, archive_name)
raw_manifest = json.loads((first / "manifest.json").read_text(encoding="utf-8"))
self.assertEqual(
raw_manifest["archive_url"],
f"https://gitea.example.test/owner/project/releases/download/"
f"v{__version__}/{archive_name}?sha256={manifest.archive_sha256}",
)
expected_layout = _archive_layout_for_version(load_config(), __version__)
self.assertEqual(raw_manifest["archive"]["layout"], expected_layout)
self.assertIn("Channel-aware updates.", manifest.notes_markdown or "")
paths = AppPaths.from_environment({"HOME": str(root / "home")})
installed = _stage_and_install_release(
paths,
first / archive_name,
manifest,
)
self.assertEqual(installed, paths.launcher_releases / __version__)
self.assertTrue((installed / "ptlinux/update_resolver.py").is_file())
with tarfile.open(first / archive_name, "r:gz") as bundle:
names = bundle.getnames()
archive_root = (
f"pristontale-eu-linux-update-v{__version__}/"
if expected_layout == "wrapped"
else ""
)
self.assertIn(f"{archive_root}release.json", names)
self.assertIn(f"{archive_root}ptlinux/__init__.py", names)
self.assertEqual(
any(name.startswith("pristontale-eu-linux-update-v") for name in names),
expected_layout == "wrapped",
)
self.assertFalse(any("__pycache__" in name for name in names))
self.assertFalse(any(name.endswith(".pyc") for name in names))
self.assertFalse(any("/.git/" in name for name in names))
def test_manifest_file_list_rejects_reordering(self) -> None:
manifest = {
"schema": 1,
"channel": "stable",
"version": "1.8.0",
"minimum_updater_version": "0.5.0",
"archive_url": "https://example.test/PristonTale-EU-1.8.0.tar.gz",
"archive_sha256": "a" * 64,
"archive_size": 100,
"published_at": "2026-09-08T00:00:00Z",
"notes_url": "https://example.test/releases/v1.8.0",
"files": [
{"path": "ptlinux/z.py", "size": 1, "sha256": "b" * 64},
{"path": "ptlinux/a.py", "size": 1, "sha256": "c" * 64},
],
}
with self.assertRaisesRegex(RuntimeError, "uniquely sorted"):
parse_update_manifest(
(json.dumps(manifest, sort_keys=True) + "\n").encode("utf-8")
)
def test_manual_build_can_set_only_the_packaged_launcher_version(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
target_version = "9.8.7-beta.2"
source_path = PROJECT_ROOT / "ptlinux/__init__.py"
source_before = source_path.read_bytes()
build_release(
target_version,
"prerelease",
root / "dist",
repository_url="https://gitea.example.test/owner/project",
commit="b" * 40,
source_date_epoch=1_700_000_000,
allow_source_version_override=True,
)
verify_release(target_version, "prerelease", root / "dist")
archive = root / "dist" / f"PristonTale-EU-{target_version}.tar.gz"
with tarfile.open(archive, "r:gz") as bundle:
member = bundle.extractfile("ptlinux/__init__.py")
assert member is not None
self.assertIn(
f'__version__ = "{target_version}"'.encode(),
member.read(),
)
self.assertEqual(source_path.read_bytes(), source_before)
def test_configured_bridge_release_uses_legacy_wrapper(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
version = "0.9.10"
output = root / "dist"
build_release(
version,
"stable",
output,
repository_url="https://gitea.example.test/owner/project",
commit="c" * 40,
source_date_epoch=1_700_000_000,
allow_source_version_override=True,
)
verify_release(version, "stable", output)
manifest = json.loads((output / "manifest.json").read_text(encoding="utf-8"))
self.assertEqual(manifest["archive"]["layout"], "wrapped")
self.assertEqual(manifest["minimum_updater_version"], "0.5.0")
archive = output / f"PristonTale-EU-{version}.tar.gz"
archive_root = f"pristontale-eu-linux-update-v{version}"
with tarfile.open(archive, "r:gz") as bundle:
names = bundle.getnames()
self.assertIn(f"{archive_root}/release.json", names)
self.assertIn(f"{archive_root}/ptlinux/self_update.py", names)
self.assertFalse(any(name == "release.json" for name in names))
parsed = parse_update_manifest((output / "manifest.json").read_bytes())
check = _evaluate_launcher_update(
parsed,
"0.9.8",
"https://gitea.example.test/owner/project/raw/branch/main/updates/stable.json",
)
self.assertTrue(check.available)
self.assertTrue(check.supported)
paths = AppPaths.from_environment({"HOME": str(root / "home")})
installed = _stage_and_install_release(paths, archive, parsed)
self.assertEqual(installed.name, version)
def test_output_cleaning_cannot_target_source_directories(self) -> None:
self.assertEqual(
_safe_output_directory(PROJECT_ROOT / "dist/v9.8.7"),
(PROJECT_ROOT / "dist/v9.8.7").resolve(),
)
with self.assertRaisesRegex(ReleaseToolError, "repository dist directory"):
_safe_output_directory(PROJECT_ROOT / "docs")
with self.assertRaisesRegex(ReleaseToolError, "unsafe output"):
_safe_output_directory(Path(tempfile.gettempdir()))
def test_forbidden_release_paths_are_rejected(self) -> None:
config = load_config()
for path in (
PurePosixPath(".env"),
PurePosixPath("private.key"),
PurePosixPath("ptlinux/__pycache__/module.pyc"),
PurePosixPath("logs/launcher.log"),
):
with self.subTest(path=path):
with self.assertRaisesRegex(ReleaseToolError, "forbidden"):
_forbid_release_path(path, config)
_forbid_release_path(
PurePosixPath("ptlinux/launcher-update-public.pem"),
config,
)
def test_signature_verification_rejects_modified_metadata(self) -> None:
openssl = shutil.which("openssl")
if openssl is None:
self.skipTest("OpenSSL is unavailable")
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
private_key = root / "private.pem"
public_key = root / "public.pem"
metadata = root / "manifest.json"
signature = root / "manifest.json.sig"
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,
)
metadata.write_text('{"schema": 1}\n', encoding="utf-8")
sign_file(metadata, signature, private_key)
verify_signature(metadata, signature, public_key)
metadata.write_text('{"schema": 2}\n', encoding="utf-8")
with self.assertRaisesRegex(ReleaseToolError, "verification failed"):
verify_signature(metadata, signature, public_key)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,318 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tarfile
import tempfile
import unittest
PROJECT_ROOT = Path(__file__).resolve().parents[1]
class ReleaseCliTests(unittest.TestCase):
def setUp(self) -> None:
openssl = shutil.which("openssl")
if openssl is None:
self.skipTest("OpenSSL is required")
self.temporary = tempfile.TemporaryDirectory()
base = Path(self.temporary.name)
self.repository = base / "working"
self.remote = base / "remote.git"
for relative in ("scripts", "ptlinux", "release"):
(self.repository / relative).mkdir(parents=True, exist_ok=True)
for relative in (
"scripts/__init__.py",
"scripts/build-release.sh",
"scripts/build_release.py",
"scripts/release.sh",
"scripts/release_lib.py",
"scripts/verify-release.sh",
"scripts/verify_release.py",
"ptlinux/update_resolver.py",
):
shutil.copy2(PROJECT_ROOT / relative, self.repository / relative)
(self.repository / "ptlinux/__init__.py").write_text(
'__version__ = "1.7.4"\n',
encoding="utf-8",
)
(self.repository / "pyproject.toml").write_text(
'[project]\nname = "release-fixture"\nversion = "1.7.4"\n',
encoding="utf-8",
)
(self.repository / ".gitignore").write_text(
"dist/\n__pycache__/\n*.pyc\n",
encoding="utf-8",
)
self.private_key = base / "private.pem"
public_key = self.repository / "ptlinux/launcher-update-public.pem"
subprocess.run(
[
openssl,
"genpkey",
"-quiet",
"-algorithm",
"RSA",
"-pkeyopt",
"rsa_keygen_bits:2048",
"-out",
str(self.private_key),
],
check=True,
capture_output=True,
)
subprocess.run(
[
openssl,
"pkey",
"-in",
str(self.private_key),
"-pubout",
"-out",
str(public_key),
],
check=True,
capture_output=True,
)
(self.repository / "release/config.json").write_text(
json.dumps(
{
"application": "PristonTale-EU",
"archive_layout": "flat",
"archive_prefix": "PristonTale-EU",
"channels": ["stable", "prerelease"],
"config_schema": 1,
"exclusions": [],
"legacy_archive_root_prefix": "pristontale-eu-linux-update-v",
"legacy_minimum_updater_version": "0.5.0",
"manifest_version": 1,
"minimum_config_schema": 1,
"minimum_updater_version": "0.5.0",
"payload": {
"files": [
{
"mode": "0644",
"path": "ptlinux/launcher-update-public.pem",
}
],
"python_trees": [{"exclude": [], "source": "ptlinux"}],
},
"preflight": [],
"release_branch": "main",
"release_status_file": "release/statuses.json",
"remote": "origin",
"repository_url": "https://gitea.example.test/owner/project",
"required_files": [
"release.json",
"ptlinux/__init__.py",
"ptlinux/launcher-update-public.pem",
],
"allowed_pem_files": ["ptlinux/launcher-update-public.pem"],
"version_mirrors": ["pyproject.toml"],
"version_source": "ptlinux/__init__.py",
"wrapped_bridge_versions": [],
},
indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
self._run(["git", "init", "--bare", str(self.remote)], cwd=base)
self._git("init", "-b", "main")
self._git("config", "user.name", "Release Test")
self._git("config", "user.email", "release-test@example.test")
self._git("add", ".")
self._git("commit", "-m", "Initial launcher release")
self._git("tag", "-a", "v1.7.4", "-m", "Launcher 1.7.4")
self._git("remote", "add", "origin", str(self.remote))
self._git("push", "-u", "origin", "main", "v1.7.4")
(self.repository / "change.txt").write_text("update\n", encoding="utf-8")
self._git("add", "change.txt")
self._git("commit", "-m", "Improve updater reliability")
self._git("push", "origin", "main")
def tearDown(self) -> None:
self.temporary.cleanup()
def _run(
self,
command: list[str],
*,
cwd: Path | None = None,
environment: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
command,
cwd=cwd or self.repository,
env=environment,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
check=False,
)
def _git(self, *arguments: str) -> str:
result = self._run(["git", *arguments])
self.assertEqual(result.returncode, 0, result.stderr)
return result.stdout.strip()
def _release(self, *arguments: str, **environment: str) -> subprocess.CompletedProcess[str]:
return self._run(
[str(self.repository / "scripts/release.sh"), *arguments],
environment={
**os.environ,
"PTLINUX_PYTHON": sys.executable,
"PTLINUX_SIGNING_KEY": str(self.private_key),
**environment,
},
)
def test_changelog_modes_preserve_markdown_and_do_not_mutate_git(self) -> None:
head_before = self._git("rev-parse", "HEAD")
tags_before = self._git("tag")
inline = self._release(
"stable",
"1.8.0",
"--description",
"## Highlights\n\n- Signed channels",
"--changelog-only",
)
self.assertEqual(inline.returncode, 0, inline.stderr)
notes = (self.repository / "dist/v1.8.0/release-notes.md").read_text(
encoding="utf-8"
)
self.assertLess(notes.index("## Highlights"), notes.index("## Changes"))
self.assertIn("Improve updater reliability", notes)
description = Path(self.temporary.name) / "description.md"
description.write_text("## Fixes\n\n- Preserved **Markdown**", encoding="utf-8")
from_file = self._release(
"stable",
"1.8.0",
"--description-file",
str(description),
"--no-auto-changelog",
"--changelog-only",
)
self.assertEqual(from_file.returncode, 0, from_file.stderr)
notes = (self.repository / "dist/v1.8.0/release-notes.md").read_text(
encoding="utf-8"
)
self.assertIn("- Preserved **Markdown**", notes)
self.assertNotIn("## Changes", notes)
editor = Path(self.temporary.name) / "editor"
editor.write_text(
'#!/usr/bin/env bash\nprintf "\\nEdited before publication.\\n" >> "$1"\n',
encoding="utf-8",
)
editor.chmod(0o755)
edited = self._release(
"stable",
"1.8.0",
"--editor",
"--changelog-only",
EDITOR=str(editor),
)
self.assertEqual(edited.returncode, 0, edited.stderr)
self.assertIn(
"Edited before publication.",
(self.repository / "dist/v1.8.0/release-notes.md").read_text(
encoding="utf-8"
),
)
self.assertEqual(self._git("rev-parse", "HEAD"), head_before)
self.assertEqual(self._git("tag"), tags_before)
def test_dry_run_then_builds_manual_stable_and_prerelease_packages(self) -> None:
head_before = self._git("rev-parse", "HEAD")
tags_before = self._git("tag")
remote_before = self._git("ls-remote", "origin")
dry_run = self._release(
"stable",
"1.8.0",
"--description",
"Stable release",
"--skip-tests",
"--dry-run",
)
self.assertEqual(dry_run.returncode, 0, dry_run.stderr)
self.assertIn("Dry run complete", dry_run.stdout)
self.assertFalse((self.repository / "dist/v1.8.0/manifest.json").exists())
stable = self._release(
"stable",
"1.8.0",
"--description",
"Stable release",
"--skip-tests",
)
self.assertEqual(stable.returncode, 0, stable.stderr)
stable_output = self.repository / "dist/v1.8.0"
manifest_path = stable_output / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
self.assertEqual(manifest["version"], "1.8.0")
self.assertEqual(manifest["channel"], "stable")
self.assertTrue((stable_output / "manifest.json.sig").is_file())
self.assertEqual(
manifest_path.read_bytes(),
(stable_output / "channel/stable.json").read_bytes(),
)
archive = stable_output / "PristonTale-EU-1.8.0.tar.gz"
with tarfile.open(archive, "r:gz") as bundle:
version_source = bundle.extractfile("ptlinux/__init__.py")
assert version_source is not None
self.assertIn(b'__version__ = "1.8.0"', version_source.read())
self.assertIn("leave the Gitea", stable.stdout)
prerelease = self._release(
"prerelease",
"1.9.0-beta.1",
"--description",
"First beta",
"--skip-tests",
)
self.assertEqual(prerelease.returncode, 0, prerelease.stderr)
prerelease_output = self.repository / "dist/v1.9.0-beta.1"
manifest = json.loads(
(prerelease_output / "manifest.json").read_text(encoding="utf-8")
)
self.assertEqual(manifest["version"], "1.9.0-beta.1")
self.assertEqual(manifest["channel"], "prerelease")
self.assertTrue(manifest_path.is_file())
self.assertIn('select the Gitea "Pre-release" checkbox', prerelease.stdout)
self.assertEqual(self._git("rev-parse", "HEAD"), head_before)
self.assertEqual(self._git("tag"), tags_before)
self.assertEqual(self._git("ls-remote", "origin"), remote_before)
self.assertIn('1.7.4', (self.repository / "ptlinux/__init__.py").read_text())
self.assertIn('1.7.4', (self.repository / "pyproject.toml").read_text())
def test_rejects_a_channel_version_mismatch_before_mutation(self) -> None:
result = self._release(
"stable",
"1.9.0-beta.1",
"--changelog-only",
)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(self._git("tag", "-l", "v1.9.0-beta.1"), "")
def test_build_does_not_depend_on_release_branch_or_clean_worktree(self) -> None:
self._git("switch", "-c", "release-candidate")
(self.repository / "uncommitted.txt").write_text("local source\n", encoding="utf-8")
result = self._release(
"stable",
"1.8.0",
"--skip-tests",
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self._git("tag", "-l", "v1.8.0"), "")
self.assertTrue((self.repository / "dist/v1.8.0/manifest.json").is_file())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,156 @@
from __future__ import annotations
import unittest
from scripts.generate_history import load_release_statuses, release_history
from scripts.release_lib import ReleaseToolError
import json
from pathlib import Path
import tempfile
def gitea_release(tag: str, *, prerelease: bool) -> dict[str, object]:
return {
"id": 42,
"tag_name": tag,
"name": f"Release {tag}",
"draft": False,
"prerelease": prerelease,
"published_at": "2026-09-08T12:00:00Z",
"html_url": f"https://gitea.example.test/owner/project/releases/tag/{tag}",
"assets": [
{
"name": "manifest.json",
"browser_download_url": (
f"https://gitea.example.test/owner/project/releases/download/"
f"{tag}/manifest.json"
),
},
{
"name": "manifest.json.sig",
"browser_download_url": (
f"https://gitea.example.test/owner/project/releases/download/"
f"{tag}/manifest.json.sig"
),
},
],
}
class ReleaseHistoryTests(unittest.TestCase):
def test_history_uses_only_installable_immutable_releases(self) -> None:
stable = gitea_release("v1.8.0", prerelease=False)
beta = gitea_release("v1.9.0-beta.2", prerelease=True)
wrong_flag = gitea_release("v2.0.0-beta.1", prerelease=False)
draft = gitea_release("v2.0.0", prerelease=False)
draft["draft"] = True
history = release_history(
[stable, wrong_flag, draft, beta],
"2026-09-08T12:01:00Z",
)
releases = history["releases"]
assert isinstance(releases, list)
self.assertEqual(
[release["version"] for release in releases],
["1.9.0-beta.2", "1.8.0"],
)
self.assertEqual(releases[0]["channel"], "prerelease")
self.assertEqual(releases[1]["channel"], "stable")
self.assertTrue(releases[0]["manifest_url"].endswith("/manifest.json"))
def test_history_rejects_an_invalid_generation_timestamp(self) -> None:
with self.assertRaisesRegex(ReleaseToolError, "timestamp"):
release_history([], "today")
def test_history_applies_signed_release_status_overrides(self) -> None:
history = release_history(
[gitea_release("v1.9.0-beta.2", prerelease=True)],
"2026-09-08T12:01:00Z",
{"v1.9.0-beta.2": "withdrawn"},
)
releases = history["releases"]
assert isinstance(releases, list)
self.assertEqual(releases[0]["status"], "withdrawn")
self.assertEqual(
releases[0]["compatibility"],
{"config_schema": 1, "minimum_config_schema": 1},
)
def test_history_supports_explicit_config_compatibility(self) -> None:
history = release_history(
[gitea_release("v2.0.0", prerelease=False)],
"2026-09-08T12:01:00Z",
{
"v2.0.0": {
"status": "active",
"config_schema": 3,
"minimum_config_schema": 2,
}
},
)
releases = history["releases"]
assert isinstance(releases, list)
self.assertEqual(
releases[0]["compatibility"],
{"config_schema": 3, "minimum_config_schema": 2},
)
def test_status_file_rejects_unknown_fields_and_unsafe_schema_ranges(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "statuses.json"
path.write_text(
json.dumps(
{
"schema": 1,
"releases": {
"v2.0.0": {
"status": "active",
"config_schema": 1,
"minimum_config_schema": 2,
}
},
}
),
encoding="utf-8",
)
with self.assertRaisesRegex(ReleaseToolError, "exceeds"):
load_release_statuses(path)
path.write_text(
'{"schema":1,"releases":{"v2.0.0":{"status":"active","note":"x"}}}',
encoding="utf-8",
)
with self.assertRaisesRegex(ReleaseToolError, "unknown"):
load_release_statuses(path)
def test_history_rejects_an_unknown_status(self) -> None:
with self.assertRaisesRegex(ReleaseToolError, "invalid status"):
release_history(
[gitea_release("v1.8.0", prerelease=False)],
"2026-09-08T12:01:00Z",
{"v1.8.0": "broken"},
)
def test_history_rejects_status_override_for_a_missing_release(self) -> None:
with self.assertRaisesRegex(ReleaseToolError, "do not match"):
release_history(
[gitea_release("v1.8.0", prerelease=False)],
"2026-09-08T12:01:00Z",
{"v1.9.0-beta.1": "withdrawn"},
)
def test_history_rejects_duplicate_release_tags(self) -> None:
release = gitea_release("v1.8.0", prerelease=False)
with self.assertRaisesRegex(ReleaseToolError, "duplicate"):
release_history(
[release, dict(release)],
"2026-09-08T12:01:00Z",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,53 @@
from __future__ import annotations
from pathlib import Path
import tempfile
import unittest
from scripts.validate_release_remote import remote_matches, repository_identity
class ReleaseRemoteTests(unittest.TestCase):
def test_https_and_ssh_forms_identify_the_same_repository(self) -> None:
expected = repository_identity(
"https://git.m4ko.net/m4ko/pristontale-eu-linux"
)
self.assertEqual(
repository_identity("git@git.m4ko.net:m4ko/pristontale-eu-linux.git"),
expected,
)
self.assertEqual(
repository_identity(
"ssh://git@git.m4ko.net/m4ko/pristontale-eu-linux.git"
),
expected,
)
def test_file_url_and_local_path_identify_the_same_repository(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / "remote.git"
self.assertEqual(
repository_identity(path.as_uri()),
repository_identity(str(path)),
)
def test_remote_must_match_one_explicitly_allowed_identity(self) -> None:
self.assertTrue(
remote_matches(
"ssh://git@192.168.0.110:222/m4ko/pristontale-eu-linux.git",
[
"https://git.m4ko.net/m4ko/pristontale-eu-linux",
"ssh://git@192.168.0.110:222/m4ko/pristontale-eu-linux.git",
],
)
)
self.assertFalse(
remote_matches(
"ssh://git@untrusted.test/m4ko/pristontale-eu-linux.git",
["https://git.m4ko.net/m4ko/pristontale-eu-linux"],
)
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,603 @@
from __future__ import annotations
import hashlib
import io
import json
from pathlib import Path
from types import SimpleNamespace
import tarfile
import tempfile
import unittest
from unittest.mock import patch
from ptlinux.paths import AppPaths
from ptlinux.self_update import (
LauncherRemoteHistory,
LauncherRemoteRelease,
LauncherUpdateError,
ManifestFile,
UpdateManifest,
_activate_release,
_fetch_historical_manifest,
_recover_interrupted_activation,
_validate_release_directory,
launcher_rollback_candidates,
parse_launcher_release_history,
rollback_launcher_update,
)
def history_release(
version: str,
channel: str,
*,
status: str = "active",
) -> dict[str, object]:
tag = f"v{version}"
base = f"https://updates.example.test/releases/download/{tag}"
return {
"channel": channel,
"compatibility": {"config_schema": 1, "minimum_config_schema": 1},
"manifest_signature_url": f"{base}/manifest.json.sig",
"manifest_url": f"{base}/manifest.json",
"release_date": "2026-09-08T12:00:00Z",
"release_id": 42,
"release_notes_url": f"https://updates.example.test/releases/tag/{tag}",
"status": status,
"tag": tag,
"title": f"Launcher {version}",
"version": version,
}
def history_bytes(releases: list[dict[str, object]]) -> bytes:
return (
json.dumps(
{
"application": "PristonTale-EU",
"generated_at": "2026-09-08T12:01:00Z",
"releases": releases,
"schema": 1,
},
sort_keys=True,
)
+ "\n"
).encode("utf-8")
def app_paths(root: Path) -> AppPaths:
return AppPaths.from_environment({"HOME": str(root / "home")})
def make_release(path: Path, version: str, *, extra_file: bool = False) -> None:
files = {
"release.json": (
json.dumps(
{
"channel": "prerelease" if "-" in version else "stable",
"config_schema": 1,
"minimum_config_schema": 1,
"schema": 1,
"tag": f"v{version}",
"version": version,
},
sort_keys=True,
)
+ "\n"
).encode(),
"ptlinux/__init__.py": f'__version__ = "{version}"\n'.encode(),
"ptlinux/__main__.py": b"fixture\n",
"ptlinux/cli.py": b"fixture\n",
"ptlinux/self_update.py": b"fixture\n",
"ptlinux/launcher-update-public.pem": b"fixture\n",
"ptdiag/__init__.py": b"fixture\n",
"ptdiag/assbin.py": b"fixture\n",
"ptdiag/logs.py": b"fixture\n",
}
if extra_file:
files["ptlinux/new_feature.py"] = b"only in the newer release\n"
for relative, data in files.items():
target = path / relative
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
target.chmod(0o644)
for directory in (path, *path.rglob("*")):
if directory.is_dir():
directory.chmod(0o755)
def archived_release(root: Path, version: str) -> tuple[Path, tuple[ManifestFile, ...]]:
release = root / "payload"
make_release(release, version)
archive = root / f"PristonTale-EU-{version}.tar.gz"
with tarfile.open(archive, "w:gz") as bundle:
for path in sorted(release.rglob("*")):
relative = path.relative_to(release).as_posix()
info = tarfile.TarInfo(relative)
info.mode = 0o755 if path.is_dir() else 0o644
if path.is_dir():
info.type = tarfile.DIRTYPE
bundle.addfile(info)
else:
data = path.read_bytes()
info.size = len(data)
bundle.addfile(info, io.BytesIO(data))
files = tuple(
ManifestFile(
path=path.relative_to(release).as_posix(),
size=path.stat().st_size,
sha256=hashlib.sha256(path.read_bytes()).hexdigest(),
)
for path in sorted(release.rglob("*"))
if path.is_file()
)
return archive, files
class RemoteHistoryTests(unittest.TestCase):
def test_release_validation_removes_generated_bytecode_caches(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
release = Path(temporary) / "1.8.0"
make_release(release, "1.8.0")
cache = release / "ptlinux/__pycache__"
cache.mkdir(mode=0o775)
(cache / "cli.cpython-314.pyc").write_bytes(b"generated bytecode")
cache.chmod(0o775)
self.assertEqual(_validate_release_directory(release), "1.8.0")
self.assertFalse(cache.exists())
def test_release_validation_rejects_unexpected_cache_content(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
release = Path(temporary) / "1.8.0"
make_release(release, "1.8.0")
cache = release / "ptlinux/__pycache__"
cache.mkdir()
unexpected = cache / "payload.txt"
unexpected.write_text("not bytecode\n", encoding="utf-8")
with self.assertRaisesRegex(LauncherUpdateError, "unexpected path"):
_validate_release_directory(release)
self.assertTrue(unexpected.is_file())
def test_history_is_strict_and_drives_channel_aware_rollback(self) -> None:
data = history_bytes(
[
history_release("1.9.0-beta.2", "prerelease"),
history_release("1.9.0-beta.1", "prerelease", status="withdrawn"),
history_release("1.8.0", "stable"),
history_release("1.7.4", "stable"),
]
)
history = parse_launcher_release_history(
data,
history_url="https://updates.example.test/updates/history.json",
)
stable = launcher_rollback_candidates(
history,
installed_version="1.9.0-beta.3",
selected_channel="stable",
)
prerelease = launcher_rollback_candidates(
history,
installed_version="1.9.0-beta.3",
selected_channel="prerelease",
)
self.assertEqual([item.version for item in stable], ["1.8.0", "1.7.4"])
self.assertEqual(
[item.version for item in prerelease],
["1.9.0-beta.2", "1.8.0", "1.7.4"],
)
def test_history_rejects_unsorted_and_detached_signature_urls(self) -> None:
with self.assertRaisesRegex(LauncherUpdateError, "descending"):
parse_launcher_release_history(
history_bytes(
[
history_release("1.8.0", "stable"),
history_release("1.9.0", "stable"),
]
),
history_url="https://updates.example.test/updates/history.json",
)
detached = history_release("1.8.0", "stable")
detached["manifest_signature_url"] = "https://attacker.test/manifest.sig"
with self.assertRaisesRegex(LauncherUpdateError, "signature URL"):
parse_launcher_release_history(
history_bytes([detached]),
history_url="https://updates.example.test/updates/history.json",
)
def test_signed_history_compatibility_overrides_immutable_manifest_defaults(self) -> None:
record = LauncherRemoteRelease(
version="1.8.0",
channel="stable",
tag="v1.8.0",
manifest_url="https://updates.example.test/v1.8.0/manifest.json",
manifest_signature_url=(
"https://updates.example.test/v1.8.0/manifest.json.sig"
),
release_date="2026-09-08T12:00:00Z",
release_notes_url="https://updates.example.test/releases/v1.8.0",
status="active",
title="Launcher 1.8.0",
config_schema=3,
minimum_config_schema=2,
)
manifest = UpdateManifest(
schema=1,
channel="stable",
version="1.8.0",
minimum_updater_version="0.5.0",
archive_url="https://updates.example.test/v1.8.0/archive.tar.gz",
archive_sha256="a" * 64,
archive_size=123,
published_at="2026-09-08T12:00:00Z",
notes_url=record.release_notes_url,
tag="v1.8.0",
config_schema=1,
minimum_config_schema=1,
)
with (
patch(
"ptlinux.self_update._fetch_small_file",
side_effect=[b"manifest", b"signature"],
),
patch("ptlinux.self_update._verify_manifest_signature"),
patch("ptlinux.self_update.parse_update_manifest", return_value=manifest),
):
result = _fetch_historical_manifest(
record,
Path("public.pem"),
timeout_seconds=1,
)
self.assertEqual(result.config_schema, 3)
self.assertEqual(result.minimum_config_schema, 2)
class RemoteRollbackTests(unittest.TestCase):
def test_remote_rollback_verifies_and_activates_a_complete_target_release(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.9.0-beta.3"
make_release(current, current.name, extra_file=True)
paths.launcher_current.symlink_to(Path("releases") / current.name)
archive, files = archived_release(root, "1.8.0")
archive_bytes = archive.read_bytes()
manifest = UpdateManifest(
schema=1,
channel="stable",
version="1.8.0",
minimum_updater_version="0.5.0",
archive_url="https://updates.example.test/PristonTale-EU-1.8.0.tar.gz",
archive_sha256=hashlib.sha256(archive_bytes).hexdigest(),
archive_size=len(archive_bytes),
published_at="2026-09-08T12:00:00Z",
notes_url="https://updates.example.test/releases/tag/v1.8.0",
tag="v1.8.0",
files=files,
manifest_sha256="a" * 64,
)
record_value = history_release("1.8.0", "stable")
record = LauncherRemoteRelease(
version="1.8.0",
channel="stable",
tag="v1.8.0",
manifest_url=str(record_value["manifest_url"]),
manifest_signature_url=str(record_value["manifest_signature_url"]),
release_date=str(record_value["release_date"]),
release_notes_url=str(record_value["release_notes_url"]),
status="active",
title="Launcher 1.8.0",
)
history = LauncherRemoteHistory(
generated_at="2026-09-08T12:01:00Z",
history_url="https://updates.example.test/updates/history.json",
releases=(record,),
history_sha256="b" * 64,
)
paths.config.mkdir(parents=True)
paths.settings_file.write_text(
json.dumps({"schema": 1, "user_choice": "preserve me"}),
encoding="utf-8",
)
def copy_archive(_manifest: object, destination: Path, **_kwargs: object) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(archive_bytes)
with (
patch("ptlinux.self_update.fetch_launcher_release_history", return_value=history),
patch("ptlinux.self_update._fetch_historical_manifest", return_value=manifest),
patch("ptlinux.self_update._download_archive", side_effect=copy_archive),
patch("ptlinux.self_update._self_check_release"),
):
result = rollback_launcher_update(
paths,
target_version="1.8.0",
selected_channel="prerelease",
)
self.assertEqual(result.from_version, "1.9.0-beta.3")
self.assertEqual(result.to_version, "1.8.0")
self.assertEqual(paths.launcher_current.resolve().name, "1.8.0")
self.assertEqual(paths.launcher_previous.resolve().name, "1.9.0-beta.3")
self.assertFalse((paths.launcher_current / "ptlinux/new_feature.py").exists())
self.assertEqual(
json.loads(paths.settings_file.read_text(encoding="utf-8"))["user_choice"],
"preserve me",
)
operations = json.loads(
paths.launcher_update_operations.read_text(encoding="utf-8")
)["operations"]
self.assertEqual(operations[-1]["reason"], "manual")
self.assertTrue(operations[-1]["rollback"])
integrity_record = paths.launcher_release_integrity / "1.8.0.json"
self.assertTrue(integrity_record.is_file())
(paths.launcher_current / "ptlinux/__init__.py").write_text(
"locally modified after verification\n",
encoding="utf-8",
)
_activate_release(paths, current)
with self.assertRaisesRegex(LauncherUpdateError, "signed manifest"):
rollback_launcher_update(paths)
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
def test_remote_rollback_rejects_incompatible_configuration_before_download(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.9.0"
make_release(current, current.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
paths.config.mkdir(parents=True)
paths.settings_file.write_text('{"schema":1}\n', encoding="utf-8")
record = LauncherRemoteRelease(
version="1.8.0",
channel="stable",
tag="v1.8.0",
manifest_url="https://updates.example.test/v1.8.0/manifest.json",
manifest_signature_url=(
"https://updates.example.test/v1.8.0/manifest.json.sig"
),
release_date="2026-09-08T12:00:00Z",
release_notes_url="https://updates.example.test/releases/v1.8.0",
status="active",
title="Launcher 1.8.0",
)
history = LauncherRemoteHistory(
generated_at="2026-09-08T12:01:00Z",
history_url="https://updates.example.test/history.json",
releases=(record,),
history_sha256="b" * 64,
)
manifest = UpdateManifest(
schema=1,
channel="stable",
version="1.8.0",
minimum_updater_version="0.5.0",
archive_url="https://updates.example.test/v1.8.0/archive.tar.gz",
archive_sha256="a" * 64,
archive_size=123,
published_at="2026-09-08T12:00:00Z",
notes_url=record.release_notes_url,
tag="v1.8.0",
config_schema=3,
minimum_config_schema=2,
)
with (
patch("ptlinux.self_update.fetch_launcher_release_history", return_value=history),
patch("ptlinux.self_update._fetch_historical_manifest", return_value=manifest),
patch("ptlinux.self_update._download_archive") as download,
self.assertRaisesRegex(LauncherUpdateError, "config schemas 2 through 3"),
):
rollback_launcher_update(paths, target_version="1.8.0")
download.assert_not_called()
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
def test_rollback_refuses_to_mutate_while_a_managed_game_is_active(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
for release in (current, previous):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
paths.launcher_previous.symlink_to(Path("releases") / previous.name)
with (
patch("ptlinux.self_update.managed_game_process_is_active", return_value=True),
self.assertRaisesRegex(RuntimeError, "active managed game client"),
):
rollback_launcher_update(paths)
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
def test_precheck_rejects_insufficient_disk_space(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.9.0"
make_release(current, current.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
record = LauncherRemoteRelease(
version="1.8.0",
channel="stable",
tag="v1.8.0",
manifest_url="https://updates.example.test/v1.8.0/manifest.json",
manifest_signature_url=(
"https://updates.example.test/v1.8.0/manifest.json.sig"
),
release_date="2026-09-08T12:00:00Z",
release_notes_url="https://updates.example.test/releases/v1.8.0",
status="active",
title="Launcher 1.8.0",
)
history = LauncherRemoteHistory(
generated_at="2026-09-08T12:01:00Z",
history_url="https://updates.example.test/history.json",
releases=(record,),
history_sha256="b" * 64,
)
manifest = UpdateManifest(
schema=1,
channel="stable",
version="1.8.0",
minimum_updater_version="0.5.0",
archive_url="https://updates.example.test/v1.8.0/archive.tar.gz",
archive_sha256="a" * 64,
archive_size=123,
published_at="2026-09-08T12:00:00Z",
notes_url=record.release_notes_url,
tag="v1.8.0",
)
with (
patch("ptlinux.self_update.fetch_launcher_release_history", return_value=history),
patch("ptlinux.self_update._fetch_historical_manifest", return_value=manifest),
patch(
"ptlinux.self_update.shutil.disk_usage",
return_value=SimpleNamespace(free=0),
),
patch("ptlinux.self_update._download_archive") as download,
self.assertRaisesRegex(LauncherUpdateError, "insufficient free disk space"),
):
rollback_launcher_update(paths, target_version="1.8.0")
download.assert_not_called()
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
def test_failed_link_activation_restores_the_original_snapshot(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
target = paths.launcher_releases / "1.9.0"
for release in (current, previous, target):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
paths.launcher_previous.symlink_to(Path("releases") / previous.name)
from ptlinux import self_update
real_replace = self_update._replace_release_link
calls = 0
def fail_current_once(link: Path, release: Path, releases: Path) -> None:
nonlocal calls
calls += 1
if calls == 2:
raise OSError("simulated current-link failure")
real_replace(link, release, releases)
with (
patch(
"ptlinux.self_update._replace_release_link",
side_effect=fail_current_once,
),
self.assertRaisesRegex(OSError, "simulated current-link failure"),
):
_activate_release(paths, target)
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
self.assertEqual(paths.launcher_previous.resolve(), previous.resolve())
self.assertFalse(paths.launcher_update_transaction.exists())
def test_crash_journal_restores_original_links_even_if_target_is_missing(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
for release in (current, previous):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases/1.9.0"))
paths.launcher_previous.symlink_to(Path("releases") / current.name)
paths.launcher_update_transaction.parent.mkdir(parents=True, exist_ok=True)
paths.launcher_update_transaction.write_text(
json.dumps(
{
"schema": 1,
"state": "applying",
"current": current.name,
"previous": previous.name,
"target": "1.9.0",
}
),
encoding="utf-8",
)
with patch("ptlinux.self_update._send_launcher_release_event") as send:
_recover_interrupted_activation(paths)
self.assertEqual(paths.launcher_current.resolve(), current.resolve())
self.assertEqual(paths.launcher_previous.resolve(), previous.resolve())
self.assertFalse(paths.launcher_update_transaction.exists())
self.assertEqual(
[call.args[0] for call in send.call_args_list],
["rollback_restore_started", "rollback_restore_success"],
)
def test_completed_activation_journal_is_cleaned_without_rolling_back(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
target = paths.launcher_releases / "1.9.0"
for release in (current, previous, target):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases") / target.name)
paths.launcher_previous.symlink_to(Path("releases") / current.name)
paths.launcher_update_transaction.parent.mkdir(parents=True, exist_ok=True)
paths.launcher_update_transaction.write_text(
json.dumps(
{
"schema": 1,
"state": "activated",
"current": current.name,
"previous": previous.name,
"target": target.name,
}
),
encoding="utf-8",
)
with patch("ptlinux.self_update._send_launcher_release_event") as send:
_recover_interrupted_activation(paths)
self.assertEqual(paths.launcher_current.resolve(), target.resolve())
self.assertEqual(paths.launcher_previous.resolve(), current.resolve())
self.assertFalse(paths.launcher_update_transaction.exists())
send.assert_not_called()
def test_diagnostic_write_failure_does_not_reverse_completed_rollback(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = app_paths(root)
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
for release in (current, previous):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
paths.launcher_previous.symlink_to(Path("releases") / previous.name)
with patch(
"ptlinux.self_update._write_json",
side_effect=OSError("read-only diagnostics directory"),
):
result = rollback_launcher_update(paths)
self.assertEqual(paths.launcher_current.resolve(), previous.resolve())
self.assertFalse(result.state_recorded)
self.assertIn("could not be saved", result.warning or "")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
from scripts.release_lib import ReleaseToolError, sign_file, verify_signature
class SigningTests(unittest.TestCase):
def test_ed25519_metadata_signatures_and_tamper_rejection(self) -> None:
openssl = shutil.which("openssl")
if openssl is None:
self.skipTest("OpenSSL is required")
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
private_key = root / "manifest-ed25519-private.pem"
public_key = root / "manifest-ed25519-public.pem"
generated = subprocess.run(
[
openssl,
"genpkey",
"-algorithm",
"ED25519",
"-out",
str(private_key),
],
capture_output=True,
text=True,
check=False,
)
if generated.returncode != 0:
self.skipTest("installed OpenSSL does not support Ed25519")
subprocess.run(
[
openssl,
"pkey",
"-in",
str(private_key),
"-pubout",
"-out",
str(public_key),
],
capture_output=True,
text=True,
check=True,
)
private_key.chmod(0o600)
source = root / "manifest.json"
signature = root / "manifest.json.sig"
source.write_text('{"schema":1}\n', encoding="utf-8")
sign_file(source, signature, private_key)
verify_signature(source, signature, public_key)
self.assertEqual(signature.stat().st_mode & 0o777, 0o644)
source.write_text('{"schema":2}\n', encoding="utf-8")
with self.assertRaisesRegex(ReleaseToolError, "verification failed"):
verify_signature(source, signature, public_key)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,71 @@
from __future__ import annotations
import unittest
from ptlinux.ui.components.update_dialog import (
release_history_state,
release_notes_preview,
update_version_heading,
)
class UpdateDialogTests(unittest.TestCase):
def test_release_notes_preview_shows_changes_without_build_footer(self) -> None:
preview = release_notes_preview(
"# Launcher 1.8.0\n\n"
"## Highlights\n\n"
"- Added channel-aware updates\n"
"- Improved rollback\n\n"
"---\n\nVersion: `1.8.0`\n"
)
self.assertIn("## Highlights", preview)
self.assertIn("- Improved rollback", preview)
self.assertNotIn("Version:", preview)
self.assertNotIn("Launcher 1.8.0", preview)
def test_release_notes_preview_is_bounded(self) -> None:
preview = release_notes_preview(
"\n".join(f"- Change {index}" for index in range(30))
)
self.assertIn("- Change 0", preview)
self.assertNotIn("- Change 20", preview)
self.assertTrue(preview.endswith("…"))
def test_current_version_heading_uses_running_launcher(self) -> None:
self.assertEqual(
update_version_heading(
"0.9.11",
"0.9.10",
"stable",
update_available=False,
),
"Current version: 0.9.11\nLatest Stable release: 0.9.10",
)
def test_history_distinguishes_running_and_installed_versions(self) -> None:
self.assertEqual(
release_history_state(
"active",
is_running=True,
local_active=False,
local_retained=False,
is_older=False,
),
"Current",
)
self.assertEqual(
release_history_state(
"active",
is_running=False,
local_active=True,
local_retained=True,
is_older=False,
),
"Installed",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,172 @@
from __future__ import annotations
import unittest
from ptlinux.update_resolver import (
BLOCKED_STATUS,
PRERELEASE_CHANNEL,
STABLE_CHANNEL,
WITHDRAWN_STATUS,
ReleaseModelError,
ReleaseRecord,
SemanticVersion,
UpdateResolver,
previous_release_tag,
validate_channel_version,
validate_release_progression,
)
class SemanticVersionTests(unittest.TestCase):
def test_orders_beta_rc_final_and_multidigit_versions(self) -> None:
versions = [
SemanticVersion.parse(value)
for value in (
"1.9.0",
"1.9.0-rc.1",
"1.10.0",
"1.9.0-beta.2",
"1.9.0-beta.1",
"1.9.9",
)
]
self.assertEqual(
[str(value) for value in sorted(versions)],
[
"1.9.0-beta.1",
"1.9.0-beta.2",
"1.9.0-rc.1",
"1.9.0",
"1.9.9",
"1.10.0",
],
)
def test_rejects_unsupported_or_noncanonical_versions(self) -> None:
for value in (
"v1.2.3",
"01.2.3",
"1.02.3",
"1.2.03",
"1.2.3-alpha.1",
"1.2.3-beta",
"1.2.3+build",
):
with self.subTest(value=value):
with self.assertRaises(ReleaseModelError):
SemanticVersion.parse(value)
def test_rejects_channel_version_mismatches(self) -> None:
with self.assertRaisesRegex(ReleaseModelError, "belongs to prerelease"):
validate_channel_version(STABLE_CHANNEL, "1.9.0-beta.1")
with self.assertRaisesRegex(ReleaseModelError, "belongs to stable"):
validate_channel_version(PRERELEASE_CHANNEL, "1.9.0")
def test_selects_channel_specific_changelog_bases(self) -> None:
tags = ["not-a-release", "v1.7.4", "v1.9.0-beta.1", "v1.9.0-beta.2"]
self.assertEqual(
previous_release_tag(STABLE_CHANNEL, "1.8.0", tags),
"v1.7.4",
)
self.assertEqual(
previous_release_tag(PRERELEASE_CHANNEL, "1.9.0-rc.1", tags),
"v1.9.0-beta.2",
)
self.assertEqual(
previous_release_tag(PRERELEASE_CHANNEL, "2.0.0-beta.1", tags),
"v1.7.4",
)
def test_release_progression_cannot_move_a_channel_backwards(self) -> None:
validate_release_progression(
STABLE_CHANNEL,
"1.9.0",
["v1.8.0", "v2.0.0-beta.1"],
)
validate_release_progression(
PRERELEASE_CHANNEL,
"2.0.0-beta.2",
["v1.9.0", "v2.0.0-beta.1"],
)
with self.assertRaisesRegex(ReleaseModelError, "newer"):
validate_release_progression(
STABLE_CHANNEL,
"1.8.0",
["v1.9.0"],
)
with self.assertRaisesRegex(ReleaseModelError, "newer"):
validate_release_progression(
PRERELEASE_CHANNEL,
"1.9.0-rc.1",
["v2.0.0-beta.1"],
)
class UpdateResolverTests(unittest.TestCase):
def setUp(self) -> None:
self.resolver = UpdateResolver(
[
ReleaseRecord("1.7.4", STABLE_CHANNEL, tag="v1.7.4"),
ReleaseRecord("1.8.0", STABLE_CHANNEL, tag="v1.8.0"),
ReleaseRecord("1.9.0-beta.1", PRERELEASE_CHANNEL),
ReleaseRecord("1.9.0-beta.2", PRERELEASE_CHANNEL),
ReleaseRecord("1.9.0-rc.1", PRERELEASE_CHANNEL),
ReleaseRecord("2.0.0-beta.1", PRERELEASE_CHANNEL, WITHDRAWN_STATUS),
ReleaseRecord("2.0.0-beta.2", PRERELEASE_CHANNEL, BLOCKED_STATUS),
]
)
def test_stable_clients_never_receive_prereleases(self) -> None:
candidate = self.resolver.update_candidate("1.7.4", STABLE_CHANNEL)
self.assertIsNotNone(candidate)
assert candidate is not None
self.assertEqual(candidate.version, "1.8.0")
def test_prerelease_clients_receive_highest_active_semantic_version(self) -> None:
candidate = self.resolver.update_candidate(
"1.9.0-beta.1",
PRERELEASE_CHANNEL,
)
self.assertIsNotNone(candidate)
assert candidate is not None
self.assertEqual(candidate.version, "1.9.0-rc.1")
def test_prerelease_client_transitions_to_the_final_stable_release(self) -> None:
resolver = UpdateResolver(
[
ReleaseRecord("1.9.0-rc.1", PRERELEASE_CHANNEL),
ReleaseRecord("1.9.0", STABLE_CHANNEL),
]
)
candidate = resolver.update_candidate("1.9.0-rc.1", PRERELEASE_CHANNEL)
self.assertIsNotNone(candidate)
assert candidate is not None
self.assertEqual(candidate.version, "1.9.0")
def test_switching_to_stable_does_not_downgrade(self) -> None:
self.assertIsNone(
self.resolver.update_candidate("1.9.0-beta.2", STABLE_CHANNEL)
)
def test_channel_aware_rollback_candidates_are_real_releases(self) -> None:
stable = self.resolver.rollback_candidates("1.9.0", STABLE_CHANNEL)
prerelease = self.resolver.rollback_candidates(
"1.9.0-rc.1",
PRERELEASE_CHANNEL,
)
self.assertEqual([item.version for item in stable], ["1.8.0", "1.7.4"])
self.assertEqual(
[item.version for item in prerelease],
["1.9.0-beta.2", "1.9.0-beta.1", "1.8.0", "1.7.4"],
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,259 @@
from __future__ import annotations
from dataclasses import replace
import hashlib
import io
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from ptlinux.paths import AppPaths
from ptlinux.self_update import (
LauncherUpdateCheck,
UpdateManifest,
_download_archive,
_record_launcher_operation,
check_for_launcher_update,
install_launcher_update,
rollback_launcher_update,
)
def release_manifest(version: str, channel: str = "stable") -> UpdateManifest:
return UpdateManifest(
schema=1,
channel=channel,
version=version,
minimum_updater_version="0.5.0",
archive_url=f"https://updates.example.test/PristonTale-EU-{version}.tar.gz",
archive_sha256="a" * 64,
archive_size=123,
published_at="2026-09-08T12:00:00Z",
notes_url=f"https://updates.example.test/releases/tag/v{version}",
tag=f"v{version}",
manifest_sha256="b" * 64,
)
def app_paths(root: Path) -> AppPaths:
return AppPaths.from_environment({"HOME": str(root / "home")})
def make_release(path: Path, version: str) -> None:
files = {
"release.json": json.dumps(
{
"channel": "prerelease" if "-" in version else "stable",
"config_schema": 1,
"minimum_config_schema": 1,
"schema": 1,
"tag": f"v{version}",
"version": version,
}
).encode(),
"ptlinux/__init__.py": f'__version__ = "{version}"\n'.encode(),
"ptlinux/__main__.py": b"fixture\n",
"ptlinux/cli.py": b"fixture\n",
"ptlinux/self_update.py": b"fixture\n",
"ptlinux/launcher-update-public.pem": b"fixture\n",
"ptdiag/__init__.py": b"fixture\n",
"ptdiag/assbin.py": b"fixture\n",
"ptdiag/logs.py": b"fixture\n",
}
for relative, data in files.items():
target = path / relative
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
target.chmod(0o644)
for directory in (path, *path.rglob("*")):
if directory.is_dir():
directory.chmod(0o755)
class UpdateTelemetryTests(unittest.TestCase):
def test_local_operation_history_is_bounded_to_thirty_records(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
paths = app_paths(Path(temporary))
for index in range(35):
self.assertTrue(
_record_launcher_operation(
paths,
from_version=f"1.0.{index}",
to_version=f"1.0.{index + 1}",
from_channel="stable",
to_channel="stable",
selected_channel="stable",
result="success",
rollback=False,
reason="automatic_update",
)
)
value = json.loads(
paths.launcher_update_operations.read_text(encoding="utf-8")
)
self.assertEqual(len(value["operations"]), 30)
self.assertEqual(value["operations"][0]["from"], "1.0.5")
self.assertEqual(value["operations"][-1]["to"], "1.0.35")
def test_rollback_download_emits_success_and_failure_families(self) -> None:
content = b"authenticated rollback archive"
manifest = replace(
release_manifest("1.7.4"),
archive_size=len(content),
archive_sha256=hashlib.sha256(content).hexdigest(),
)
class Response(io.BytesIO):
def __init__(self, data: bytes) -> None:
super().__init__(data)
self.headers = {"Content-Length": str(len(data))}
def geturl(self) -> str:
return manifest.archive_url
def __enter__(self) -> Response:
return self
def __exit__(self, *_args: object) -> None:
self.close()
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
metadata = {
"from_version": "1.8.0",
"to_version": "1.7.4",
"rollback": True,
}
telemetry = MagicMock()
with (
patch("ptlinux.self_update.urlopen", return_value=Response(content)),
patch("ptlinux.self_update.current_telemetry", return_value=telemetry),
):
_download_archive(
manifest,
root / "success.tar.gz",
timeout_seconds=1,
event_family="rollback",
event_metadata=metadata,
)
self.assertEqual(
[call.args[0] for call in telemetry.send_event.call_args_list],
["rollback_download_started", "rollback_download_success"],
)
telemetry.reset_mock()
with (
patch("ptlinux.self_update.urlopen", return_value=Response(b"x" * len(content))),
patch("ptlinux.self_update.current_telemetry", return_value=telemetry),
self.assertRaisesRegex(RuntimeError, "SHA-256"),
):
_download_archive(
manifest,
root / "failure.tar.gz",
timeout_seconds=1,
event_family="rollback",
event_metadata=metadata,
)
self.assertEqual(
[call.args[0] for call in telemetry.send_event.call_args_list],
["rollback_download_started", "rollback_download_failed"],
)
def test_discovery_emits_release_identity_without_release_notes(self) -> None:
telemetry = MagicMock()
manifest = release_manifest("1.8.0")
with (
patch("ptlinux.self_update._fetch_authenticated_manifest", return_value=manifest),
patch("ptlinux.self_update.current_telemetry", return_value=telemetry),
):
check_for_launcher_update(
current_version="1.7.4",
manifest_url="https://updates.example.test/stable.json",
channel="stable",
)
call = telemetry.send_event.call_args
self.assertEqual(call.args[0], "update_release_discovered")
metadata = call.kwargs["metadata"]
self.assertEqual(metadata["current_version"], "1.7.4")
self.assertEqual(metadata["target_version"], "1.8.0")
self.assertEqual(metadata["current_channel"], "stable")
self.assertEqual(metadata["target_channel"], "stable")
self.assertEqual(metadata["selected_update_channel"], "stable")
self.assertEqual(metadata["release_tag"], "v1.8.0")
self.assertEqual(metadata["manifest_version"], 1)
self.assertEqual(metadata["manifest_hash"], "b" * 64)
self.assertFalse(metadata["rollback"])
self.assertNotIn("notes", metadata)
def test_update_apply_emits_started_and_success_with_shared_metadata(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
paths = app_paths(Path(temporary))
manifest = release_manifest("1.8.0")
check = LauncherUpdateCheck(
current_version="1.7.4",
latest_version="1.8.0",
available=True,
supported=True,
reason="Launcher 1.8.0 is available.",
manifest_url="https://updates.example.test/stable.json",
manifest=manifest,
selected_channel="stable",
)
release = paths.launcher_releases / "1.8.0"
telemetry = MagicMock()
with (
patch("ptlinux.self_update.check_for_launcher_update", return_value=check),
patch("ptlinux.self_update._precheck_release_install"),
patch("ptlinux.self_update._download_archive"),
patch("ptlinux.self_update._stage_and_install_release", return_value=release),
patch("ptlinux.self_update._activate_release", return_value=None),
patch("ptlinux.self_update.current_telemetry", return_value=telemetry),
):
result = install_launcher_update(paths, current_version="1.7.4")
self.assertTrue(result.changed)
events = [call.args[0] for call in telemetry.send_event.call_args_list]
self.assertEqual(events, ["update_apply_started", "update_apply_success"])
for call in telemetry.send_event.call_args_list:
metadata = call.kwargs["metadata"]
self.assertEqual(metadata["current_version"], "1.7.4")
self.assertEqual(metadata["target_version"], "1.8.0")
self.assertFalse(metadata["rollback"])
def test_local_rollback_emits_requested_and_apply_lifecycle(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
paths = app_paths(Path(temporary))
current = paths.launcher_releases / "1.8.0"
previous = paths.launcher_releases / "1.7.4"
for release in (current, previous):
make_release(release, release.name)
paths.launcher_current.symlink_to(Path("releases") / current.name)
paths.launcher_previous.symlink_to(Path("releases") / previous.name)
telemetry = MagicMock()
with patch("ptlinux.self_update.current_telemetry", return_value=telemetry):
rollback_launcher_update(paths, reason="support_recommendation")
events = [call.args[0] for call in telemetry.send_event.call_args_list]
self.assertEqual(
events,
[
"rollback_requested",
"rollback_apply_started",
"rollback_apply_success",
],
)
metadata = telemetry.send_event.call_args_list[0].kwargs["metadata"]
self.assertEqual(metadata["current_version"], "1.8.0")
self.assertEqual(metadata["target_version"], "1.7.4")
self.assertEqual(metadata["from_version"], "1.8.0")
self.assertEqual(metadata["to_version"], "1.7.4")
self.assertEqual(metadata["from_channel"], "stable")
self.assertEqual(metadata["to_channel"], "stable")
self.assertEqual(metadata["reason"], "support_recommendation")
self.assertTrue(metadata["rollback"])
if __name__ == "__main__":
unittest.main()