diff --git a/.github/scripts/tvos-release.py b/.github/scripts/tvos-release.py index d2fdf954a..b0de782fe 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,40 @@ 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" + 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 +502,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..8b7ac2a7e 100755 --- a/.github/scripts/tvos-tooling-tests.py +++ b/.github/scripts/tvos-tooling-tests.py @@ -15,6 +15,7 @@ import tempfile import unittest import uuid +import zipfile from unittest.mock import patch ROOT = Path(__file__).resolve().parents[2] @@ -154,6 +155,106 @@ 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) + result = release.normalize_exported_ipa(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_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): + 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.assertRaisesRegex(ValueError, "Info.plist"): + release.normalize_exported_ipa(export, {"marketing": "2.3.4"}, "456") + + 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):