From 4ed66e9f45809cffa633e28a8a72072d0e153472 Mon Sep 17 00:00:00 2001 From: Devil Date: Mon, 26 Oct 2026 17:10:33 -0400 Subject: [PATCH 1/2] Fix tvOS IPA export filename normalization --- .github/scripts/tvos-release.py | 40 +++++++++++- .github/scripts/tvos-tooling-tests.py | 87 +++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/.github/scripts/tvos-release.py b/.github/scripts/tvos-release.py index d2fdf954a..d227976f7 100755 --- a/.github/scripts/tvos-release.py +++ b/.github/scripts/tvos-release.py @@ -22,6 +22,8 @@ import time import urllib.parse import urllib.request +import zipfile +import zlib from xml.parsers.expat import ExpatError ROOT = Path(__file__).resolve().parents[2] @@ -368,6 +370,42 @@ def verify_app(app, versions, build, *, production): "Simulator-only telemetry fixture leaked into device binary") +def normalize_exported_ipa(export_root, versions, build): + """Validate the sole exported TV IPA and give downstream upload a stable name.""" + export_root = Path(export_root) + require(export_root.is_dir(), "TV IPA export directory is missing") + candidates = sorted(export_root.glob("*.ipa")) + require(len(candidates) == 1, f"Expected exactly one TV IPA export; found {len(candidates)}") + source = candidates[0] + require(source.is_file() and not source.is_symlink(), "TV IPA export is not a regular file") + + try: + with zipfile.ZipFile(source) as ipa: + app_infos = [name for name in ipa.namelist() + if re.fullmatch(r"Payload/[^/]+\.app/Info\.plist", name)] + require(len(app_infos) == 1, "TV IPA must contain exactly one Payload app Info.plist") + info_data = ipa.read(app_infos[0]) + except (zipfile.BadZipFile, OSError, KeyError, RuntimeError, EOFError, zlib.error): + raise ValueError("Exported TV IPA is not a readable ZIP archive") from None + + try: + info = plistlib.loads(info_data) + except (ValueError, TypeError, plistlib.InvalidFileException, ExpatError): + raise ValueError("Exported TV IPA app Info.plist is invalid") from None + require(isinstance(info, dict), "Exported TV IPA app Info.plist must contain a dictionary") + require(info.get("CFBundleIdentifier") == BUNDLE, "Exported IPA bundle ID mismatch") + require(info.get("DTPlatformName") == "appletvos" and info.get("UIDeviceFamily") == [3], + "Exported IPA platform mismatch") + require(info.get("CFBundleShortVersionString") == versions["marketing"] and + info.get("CFBundleVersion") == build, "Exported IPA version/build mismatch") + + stable = export_root / "PhoenixTV.ipa" + if source != stable: + require(not stable.exists(), "Stable TV IPA destination already exists") + source.replace(stable) + return stable + + def local(args): require(args.sdk == "appletvsimulator" or args.action == "build", "Tests require an Apple TV simulator") require(args.configuration == "Debug" or args.action == "build", "Phantom UI tests require Debug") @@ -466,7 +504,7 @@ def archive(args): "uploadSymbols": True, "provisioningProfiles": {BUNDLE: env["TVOS_PROVISIONING_PROFILE_NAME"]}})) run(["xcodebuild", "-quiet", "-exportArchive", "-archivePath", archive_path, "-exportPath", output / "export", "-exportOptionsPlist", options], "Export TV IPA", sensitive=True) - require((output / "export/PhoenixTV.ipa").is_file(), "TV IPA export missing") + normalize_exported_ipa(output / "export", versions, args.build_number) (output / "export/release.json").write_text(json.dumps({"source_sha": env["TVOS_SOURCE_SHA"], "bundle": BUNDLE, "platform": "TV_OS", "marketing": versions["marketing"], "build": args.build_number}, indent=2) + "\n") finally: diff --git a/.github/scripts/tvos-tooling-tests.py b/.github/scripts/tvos-tooling-tests.py index 4e35aa496..222d86da2 100755 --- a/.github/scripts/tvos-tooling-tests.py +++ b/.github/scripts/tvos-tooling-tests.py @@ -15,6 +15,8 @@ import tempfile import unittest import uuid +from typing import Callable, cast +import zipfile from unittest.mock import patch ROOT = Path(__file__).resolve().parents[2] @@ -154,6 +156,91 @@ def test_command_failure_preserves_real_exit(self): release.run(["/bin/sh", "-c", "exit 37"], "fixture failure") self.assertEqual(caught.exception.returncode, 37) + def export_ipa_fixture(self, path, *, changes=None, extra_app=False): + info = {"CFBundleIdentifier": release.BUNDLE, "CFBundleShortVersionString": "2.3.4", + "CFBundleVersion": "456", "DTPlatformName": "appletvos", "UIDeviceFamily": [3]} + info.update(changes or {}) + with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as ipa: + ipa.writestr("Payload/Phoenix TV.app/Info.plist", plistlib.dumps(info)) + if extra_app: + ipa.writestr("Payload/Another.app/Info.plist", plistlib.dumps(info)) + + def test_export_normalizes_display_name_ipa_after_metadata_validation(self): + export = self.root / "export" + export.mkdir() + source = export / "Phoenix TV.ipa" + self.export_ipa_fixture(source) + normalize = getattr(release, "normalize_exported_ipa", None) + if not callable(normalize): + self.fail("Expected the tvOS IPA normalization helper") + normalize = cast(Callable[[Path, dict[str, str], str], Path], normalize) + + result = normalize(export, {"marketing": "2.3.4"}, "456") + + self.assertEqual(result, export / "PhoenixTV.ipa") + self.assertTrue(result.is_file()) + self.assertFalse(source.exists()) + self.assertTrue(zipfile.is_zipfile(result)) + + def test_export_preserves_already_stable_ipa_name(self): + export = self.root / "export" + export.mkdir() + stable = export / "PhoenixTV.ipa" + self.export_ipa_fixture(stable) + + result = release.normalize_exported_ipa(export, {"marketing": "2.3.4"}, "456") + + self.assertEqual(result, stable) + self.assertTrue(stable.is_file()) + + def test_export_requires_exactly_one_ipa_candidate(self): + for names in ((), ("one.ipa", "two.ipa")): + with self.subTest(names=names): + export = self.root / ("export-" + str(len(names))) + export.mkdir() + for name in names: + self.export_ipa_fixture(export / name) + with self.assertRaisesRegex(ValueError, "exactly one"): + release.normalize_exported_ipa(export, {"marketing": "2.3.4"}, "456") + + def test_export_rejects_malformed_zip_and_multiple_payload_apps(self): + export = self.root / "export" + export.mkdir() + malformed = export / "broken.ipa" + malformed.write_bytes(b"not a zip archive") + with self.assertRaisesRegex(ValueError, "ZIP"): + release.normalize_exported_ipa(export, {"marketing": "2.3.4"}, "456") + + malformed.unlink() + with zipfile.ZipFile(malformed, "w", compression=zipfile.ZIP_DEFLATED) as ipa: + ipa.writestr("Payload/Phoenix TV.app/Info.plist", b'') + with self.assertRaises(Exception) as caught: + release.normalize_exported_ipa(export, {"marketing": "2.3.4"}, "456") + self.assertIsInstance(caught.exception, ValueError) + self.assertRegex(str(caught.exception), "Info.plist") + + malformed.unlink() + ambiguous = export / "ambiguous.ipa" + self.export_ipa_fixture(ambiguous, extra_app=True) + with self.assertRaisesRegex(ValueError, "exactly one.*app"): + release.normalize_exported_ipa(export, {"marketing": "2.3.4"}, "456") + + def test_export_rejects_mismatched_payload_identity(self): + cases = (({"CFBundleIdentifier": "com.example.other"}, "bundle"), + ({"DTPlatformName": "iphoneos", "UIDeviceFamily": [1]}, "platform"), + ({"CFBundleShortVersionString": "9.9.9"}, "version"), + ({"CFBundleVersion": "999"}, "build")) + for changes, identity in cases: + with self.subTest(identity=identity): + export = self.root / ("export-" + identity) + export.mkdir() + source = export / "Phoenix TV.ipa" + self.export_ipa_fixture(source, changes=changes) + with self.assertRaisesRegex(ValueError, identity): + release.normalize_exported_ipa(export, {"marketing": "2.3.4"}, "456") + self.assertTrue(source.is_file()) + self.assertFalse((export / "PhoenixTV.ipa").exists()) + def offline_cli(self, output, *, code=65, action="build", env=None): """Replace only the forbidden native executable; run a real child and CLI.""" def child(sdk, configuration, derived, config, native_action, destination): From 04ba97dca91a9c5e2ba0fdb2cacceab759791ddc Mon Sep 17 00:00:00 2001 From: Devil Date: Mon, 26 Oct 2026 17:28:20 -0400 Subject: [PATCH 2/2] fix(tvOS): normalize case-variant export IPA --- .github/scripts/tvos-release.py | 4 +--- .github/scripts/tvos-tooling-tests.py | 34 +++++++++++++++++++-------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/.github/scripts/tvos-release.py b/.github/scripts/tvos-release.py index d227976f7..b0de782fe 100755 --- a/.github/scripts/tvos-release.py +++ b/.github/scripts/tvos-release.py @@ -400,9 +400,7 @@ def normalize_exported_ipa(export_root, versions, build): info.get("CFBundleVersion") == build, "Exported IPA version/build mismatch") stable = export_root / "PhoenixTV.ipa" - if source != stable: - require(not stable.exists(), "Stable TV IPA destination already exists") - source.replace(stable) + source.replace(stable) return stable diff --git a/.github/scripts/tvos-tooling-tests.py b/.github/scripts/tvos-tooling-tests.py index 222d86da2..8b7ac2a7e 100755 --- a/.github/scripts/tvos-tooling-tests.py +++ b/.github/scripts/tvos-tooling-tests.py @@ -15,7 +15,6 @@ import tempfile import unittest import uuid -from typing import Callable, cast import zipfile from unittest.mock import patch @@ -170,12 +169,7 @@ def test_export_normalizes_display_name_ipa_after_metadata_validation(self): export.mkdir() source = export / "Phoenix TV.ipa" self.export_ipa_fixture(source) - normalize = getattr(release, "normalize_exported_ipa", None) - if not callable(normalize): - self.fail("Expected the tvOS IPA normalization helper") - normalize = cast(Callable[[Path, dict[str, str], str], Path], normalize) - - result = normalize(export, {"marketing": "2.3.4"}, "456") + result = release.normalize_exported_ipa(export, {"marketing": "2.3.4"}, "456") self.assertEqual(result, export / "PhoenixTV.ipa") self.assertTrue(result.is_file()) @@ -193,6 +187,28 @@ def test_export_preserves_already_stable_ipa_name(self): self.assertEqual(result, stable) self.assertTrue(stable.is_file()) + def test_export_normalizes_case_variant_name_on_filesystem(self): + export = self.root / "export" + export.mkdir() + self.export_ipa_fixture(export / "phoenixTV.ipa") + + result = release.normalize_exported_ipa(export, {"marketing": "2.3.4"}, "456") + + self.assertEqual(result, export / "PhoenixTV.ipa") + self.assertTrue(result.is_file()) + self.assertTrue(zipfile.is_zipfile(result)) + self.assertEqual([path.name for path in export.glob("*.ipa")], ["PhoenixTV.ipa"]) + + def test_export_rejects_symlink_ipa_candidate(self): + export = self.root / "export" + export.mkdir() + target = export / "actual.zip" + self.export_ipa_fixture(target) + (export / "linked.ipa").symlink_to(target) + + with self.assertRaisesRegex(ValueError, "regular file"): + release.normalize_exported_ipa(export, {"marketing": "2.3.4"}, "456") + def test_export_requires_exactly_one_ipa_candidate(self): for names in ((), ("one.ipa", "two.ipa")): with self.subTest(names=names): @@ -214,10 +230,8 @@ def test_export_rejects_malformed_zip_and_multiple_payload_apps(self): malformed.unlink() with zipfile.ZipFile(malformed, "w", compression=zipfile.ZIP_DEFLATED) as ipa: ipa.writestr("Payload/Phoenix TV.app/Info.plist", b'') - with self.assertRaises(Exception) as caught: + with self.assertRaisesRegex(ValueError, "Info.plist"): release.normalize_exported_ipa(export, {"marketing": "2.3.4"}, "456") - self.assertIsInstance(caught.exception, ValueError) - self.assertRegex(str(caught.exception), "Info.plist") malformed.unlink() ambiguous = export / "ambiguous.ipa"