Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion .github/scripts/tvos-release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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")
Comment thread
9thLevelSoftware marked this conversation as resolved.

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")
Expand Down Expand Up @@ -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:
Expand Down
101 changes: 101 additions & 0 deletions .github/scripts/tvos-tooling-tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import tempfile
import unittest
import uuid
import zipfile
from unittest.mock import patch

ROOT = Path(__file__).resolve().parents[2]
Expand Down Expand Up @@ -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'<?xml version="1.0"?><plist><dict>')
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):
Expand Down
Loading