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