Files
plezy/scripts/release/test_deploy.py
T
edde746 aeb6108654 feat(scripts): add the deploy.py multi-channel release pipeline
One command releases to Play, Amazon, App Store Connect (iOS + tvOS), the GitHub build farm, Microsoft Store, and the GitHub release + cask, with checkpointed resume. Replaces the fastlane release flow; first used for 2.14.0.
2026-08-17 01:40:54 +02:00

288 lines
10 KiB
Python

import sys
import tempfile
import unittest
import xml.dom.minidom
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
import deploy
from deploy import (
DeployError,
State,
build_appcast,
bump_pubspec_text,
collect_release_files,
parse_env_text,
resolve_phases,
sanitize_msstore_pricing,
update_cask_text,
)
class ParseEnvTextTest(unittest.TestCase):
def test_parses_values_and_skips_comments_and_blanks(self) -> None:
text = """\
# App Store
DELIVER_USERNAME=user@example.com
export TOKEN=abc123
NOT A VALID LINE
"""
values = parse_env_text(text)
self.assertEqual(
values,
{"DELIVER_USERNAME": "user@example.com", "TOKEN": "abc123"},
)
def test_strips_matching_quotes(self) -> None:
values = parse_env_text("A=\"quoted value\"\nB='single'\nC=un\"quoted\n")
self.assertEqual(values["A"], "quoted value")
self.assertEqual(values["B"], "single")
self.assertEqual(values["C"], 'un"quoted')
def test_expands_references_to_earlier_keys(self) -> None:
# The real .env relies on this: SUPPLY_PACKAGE_NAME=${DELIVER_APP_IDENTIFIER}
text = "DELIVER_APP_IDENTIFIER=com.edde746.plezy\nSUPPLY_PACKAGE_NAME=${DELIVER_APP_IDENTIFIER}\n"
values = parse_env_text(text)
self.assertEqual(values["SUPPLY_PACKAGE_NAME"], "com.edde746.plezy")
def test_expands_from_base_environment(self) -> None:
values = parse_env_text("A=${HOME_LIKE}/sub\n", {"HOME_LIKE": "/base"})
self.assertEqual(values["A"], "/base/sub")
def test_unknown_reference_expands_empty(self) -> None:
self.assertEqual(parse_env_text("A=${MISSING}x\n")["A"], "x")
def test_single_quotes_suppress_expansion(self) -> None:
values = parse_env_text("A='${NOPE}'\n", {"NOPE": "value"})
self.assertEqual(values["A"], "${NOPE}")
class BumpPubspecTextTest(unittest.TestCase):
PUBSPEC = """\
name: plezy
description: A media client.
version: 2.12.1+230
environment:
sdk: ">=3.12.0 <4.0.0"
dependencies:
some_package:
version: 9.9.9+999
"""
def test_replaces_only_the_top_level_version(self) -> None:
result = bump_pubspec_text(self.PUBSPEC, "2.13.0", 231)
self.assertIn("version: 2.13.0+231\n", result)
self.assertIn(" version: 9.9.9+999\n", result)
self.assertNotIn("2.12.1+230", result)
self.assertEqual(result.count("\n"), self.PUBSPEC.count("\n"))
def test_rejects_pubspec_without_version(self) -> None:
with self.assertRaises(ValueError):
bump_pubspec_text("name: plezy\n", "2.13.0", 231)
class BuildAppcastTest(unittest.TestCase):
def test_full_appcast_has_both_enclosures(self) -> None:
xml_text = build_appcast("2.13.0", "231", "macsig", "1000", "winsig", "2000")
doc = xml.dom.minidom.parseString(xml_text)
enclosures = doc.getElementsByTagName("enclosure")
self.assertEqual(len(enclosures), 2)
macos, windows = enclosures
self.assertEqual(
macos.getAttribute("url"),
"https://github.com/edde746/plezy/releases/download/2.13.0/plezy-macos.dmg",
)
self.assertEqual(macos.getAttribute("sparkle:edSignature"), "macsig")
self.assertEqual(macos.getAttribute("length"), "1000")
self.assertEqual(
windows.getAttribute("url"),
"https://github.com/edde746/plezy/releases/download/2.13.0/plezy-windows-installer.exe",
)
self.assertEqual(windows.getAttribute("sparkle:installerArguments"), "/SILENT /SP-")
version_nodes = doc.getElementsByTagName("sparkle:version")
self.assertEqual(version_nodes[0].firstChild.nodeValue, "231")
def test_unsigned_appcast_has_no_enclosures(self) -> None:
xml_text = build_appcast("2.13.0", "231")
doc = xml.dom.minidom.parseString(xml_text)
self.assertEqual(len(doc.getElementsByTagName("enclosure")), 0)
notes = doc.getElementsByTagName("sparkle:releaseNotesLink")
self.assertIn("2.13.0", notes[0].firstChild.nodeValue)
class UpdateCaskTextTest(unittest.TestCase):
CASK = """\
cask "plezy" do
version "2.12.1"
sha256 "7a5e30d125d5a379108bf691ae8ce5e2af0a1acf98ee40897909a9576ebe3ef9"
url "https://github.com/edde746/plezy/releases/download/#{version}/plezy-macos.dmg"
name "Plezy"
end
"""
def test_updates_version_and_sha(self) -> None:
result = update_cask_text(self.CASK, "2.13.0", "f" * 64)
self.assertIn('version "2.13.0"', result)
self.assertIn(f'sha256 "{"f" * 64}"', result)
self.assertNotIn("2.12.1", result)
# Interpolated URL and other stanzas are untouched.
self.assertIn('#{version}/plezy-macos.dmg', result)
def test_missing_stanza_raises(self) -> None:
with self.assertRaises(DeployError):
update_cask_text('cask "plezy" do\nend\n', "2.13.0", "f" * 64)
class ResolvePhasesTest(unittest.TestCase):
def test_default_selects_all_phases_in_order(self) -> None:
self.assertEqual(resolve_phases(None, None), deploy.PHASES)
def test_only_keeps_preflight_and_canonical_order(self) -> None:
self.assertEqual(
resolve_phases(["msstore", "play"], None),
["preflight", "play", "msstore"],
)
def test_skip_removes_phases(self) -> None:
selected = resolve_phases(None, ["ios", "tvos", "asc"])
self.assertNotIn("ios", selected)
self.assertNotIn("tvos", selected)
self.assertNotIn("asc", selected)
self.assertIn("play", selected)
def test_preflight_can_be_skipped_explicitly(self) -> None:
self.assertEqual(resolve_phases(["release"], ["preflight"]), ["release"])
def test_unknown_phase_raises(self) -> None:
with self.assertRaises(DeployError):
resolve_phases(["appstore"], None)
with self.assertRaises(DeployError):
resolve_phases(None, ["nope"])
class CollectReleaseFilesTest(unittest.TestCase):
EXPECTED = [
"android-apk/plezy-android-arm64-v8a.tar.gz",
"android-apk/plezy-android-armeabi-v7a.tar.gz",
"android-apk/plezy-android-x86_64.tar.gz",
"ios-ipa/plezy-ios.ipa",
"macos-dmg/plezy-macos.dmg",
"windows-x64-portable/plezy-windows-x64-portable.7z",
"windows-arm64-portable/plezy-windows-arm64-portable.7z",
"windows-installer/plezy-windows-installer.exe",
"linux-x64/plezy-linux-x64.deb",
"linux-x64/plezy-linux-x64.tar.gz",
"linux-arm64/plezy-linux-arm64.deb",
"linux-arm64/plezy-linux-arm64.tar.gz",
]
def make_tree(self, root: Path, skip: str = "") -> None:
for rel in self.EXPECTED:
if rel == skip:
continue
path = root / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"artifact")
def test_collects_all_artifacts(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
self.make_tree(root)
files = collect_release_files(root)
relative = {str(p.relative_to(root)) for p in files}
self.assertEqual(relative, set(self.EXPECTED))
def test_missing_explicit_artifact_is_reported(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
self.make_tree(root, skip="macos-dmg/plezy-macos.dmg")
with self.assertRaisesRegex(DeployError, "plezy-macos.dmg"):
collect_release_files(root)
def test_empty_linux_directory_is_reported(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
self.make_tree(root)
for entry in (root / "linux-arm64").iterdir():
entry.unlink()
with self.assertRaisesRegex(DeployError, "linux-arm64"):
collect_release_files(root)
class StateTest(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self._original = deploy.STATE_PATH
deploy.STATE_PATH = Path(self._tmp.name) / "state.json"
def tearDown(self) -> None:
deploy.STATE_PATH = self._original
self._tmp.cleanup()
def test_round_trip(self) -> None:
state = State(version="2.13.0", build_number=231)
state.data["farm_run_id"] = 42
state.mark_done("changelog")
loaded = State.load()
self.assertIsNotNone(loaded)
self.assertEqual(loaded.version, "2.13.0")
self.assertEqual(loaded.build_number, 231)
self.assertEqual(loaded.done, ["changelog"])
self.assertEqual(loaded.data, {"farm_run_id": 42})
def test_mark_done_is_idempotent(self) -> None:
state = State(version="2.13.0", build_number=231)
state.mark_done("play")
state.mark_done("play")
self.assertEqual(State.load().done, ["play"])
def test_load_returns_none_without_file(self) -> None:
self.assertIsNone(State.load())
class SplitPhaseListsTest(unittest.TestCase):
def test_splits_commas_and_repeats(self) -> None:
self.assertEqual(
deploy._split_phase_lists(["play,amazon", "ios"]),
["play", "amazon", "ios"],
)
def test_none_passthrough(self) -> None:
self.assertIsNone(deploy._split_phase_lists(None))
self.assertIsNone(deploy._split_phase_lists([]))
class SanitizeMsstorePricingTest(unittest.TestCase):
def test_drops_price_id_and_advanced_flag_but_keeps_the_rest(self) -> None:
submission = {
"pricing": {
"trialPeriod": "SevenDays",
"marketSpecificPricings": {"LB": "NotAvailable"},
"sales": [],
"priceId": "Base",
"isAdvancedPricingModel": True,
}
}
sanitize_msstore_pricing(submission)
self.assertEqual(
submission["pricing"],
{
"trialPeriod": "SevenDays",
"marketSpecificPricings": {"LB": "NotAvailable"},
"sales": [],
},
)
def test_tolerates_missing_pricing(self) -> None:
submission: dict = {}
sanitize_msstore_pricing(submission)
self.assertEqual(submission, {})
if __name__ == "__main__":
unittest.main()