Add channel-aware release tooling and updater recovery
This commit is contained in:
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()
|
||||
Reference in New Issue
Block a user