Add channel-aware release tooling and updater recovery
This commit is contained in:
1
release_tests/__init__.py
Normal file
1
release_tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tracked preflight tests for the automated release system."""
|
||||
96
release_tests/test_channel_discovery.py
Normal file
96
release_tests/test_channel_discovery.py
Normal 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()
|
||||
102
release_tests/test_channel_metadata.py
Normal file
102
release_tests/test_channel_metadata.py
Normal 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()
|
||||
42
release_tests/test_channel_settings.py
Normal file
42
release_tests/test_channel_settings.py
Normal 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()
|
||||
643
release_tests/test_gitea_publisher.py
Normal file
643
release_tests/test_gitea_publisher.py
Normal 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()
|
||||
38
release_tests/test_launcher_update_cli.py
Normal file
38
release_tests/test_launcher_update_cli.py
Normal 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()
|
||||
270
release_tests/test_release_artifacts.py
Normal file
270
release_tests/test_release_artifacts.py
Normal 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()
|
||||
318
release_tests/test_release_cli.py
Normal file
318
release_tests/test_release_cli.py
Normal 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()
|
||||
156
release_tests/test_release_history.py
Normal file
156
release_tests/test_release_history.py
Normal 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()
|
||||
53
release_tests/test_release_remote.py
Normal file
53
release_tests/test_release_remote.py
Normal 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()
|
||||
603
release_tests/test_remote_rollback.py
Normal file
603
release_tests/test_remote_rollback.py
Normal 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()
|
||||
65
release_tests/test_signing.py
Normal file
65
release_tests/test_signing.py
Normal 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()
|
||||
71
release_tests/test_update_dialog.py
Normal file
71
release_tests/test_update_dialog.py
Normal 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()
|
||||
172
release_tests/test_update_resolver.py
Normal file
172
release_tests/test_update_resolver.py
Normal 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()
|
||||
259
release_tests/test_update_telemetry.py
Normal file
259
release_tests/test_update_telemetry.py
Normal 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()
|
||||
Reference in New Issue
Block a user