Add channel-aware release tooling and updater recovery

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

View File

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