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.
288 lines
10 KiB
Python
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()
|