diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a91f6c765..d1f7fbec2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,7 @@ jobs: - 'scripts/test-mobile-release-contract.sh' - 'scripts/test-mobile-release-candidate-publisher.sh' - 'scripts/test-mobile-worktree-overrides.sh' + - 'scripts/test-ios-pbxproj-semantics.py' - '.github/workflows/mobile-release-candidate.yml' - '.github/workflows/ci.yml' - name: Release workflow source contract @@ -873,6 +874,8 @@ jobs: run: swift build --package-path mobile/ios/BuzzPushKit - name: Test run: swift test --package-path mobile/ios/BuzzPushKit + - name: Validate iOS project semantics + run: python3 scripts/test-ios-pbxproj-semantics.py security: name: Security diff --git a/scripts/test-ios-pbxproj-semantics.py b/scripts/test-ios-pbxproj-semantics.py new file mode 100755 index 0000000000..b332ea29ae --- /dev/null +++ b/scripts/test-ios-pbxproj-semantics.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Validate the semantic iOS target/configuration table via macOS plutil.""" + +import json +import subprocess +import sys +from pathlib import Path + +PBXPROJ = Path("mobile/ios/Runner.xcodeproj/project.pbxproj") +# This asserts plutil-resolved pbxproj declarations, not object-key uniqueness or +# the final xcconfig-expanded bundle identity. +EXPECTED_ROWS = """\ +NotificationService Debug Flutter/Debug.xcconfig JMTDPW9CG3 NotificationService/NotificationService.entitlements $(BUNDLE_IDENTIFIER).NotificationService +NotificationService Profile Flutter/Release.xcconfig EYF346PHUG NotificationService/NotificationService.entitlements $(BUNDLE_IDENTIFIER).NotificationService +NotificationService Release Flutter/Release.xcconfig EYF346PHUG NotificationService/NotificationService.entitlements $(BUNDLE_IDENTIFIER).NotificationService +Runner Debug Flutter/Debug.xcconfig JMTDPW9CG3 Runner/Runner.entitlements $(BUNDLE_IDENTIFIER) +Runner Profile Flutter/Release.xcconfig EYF346PHUG Runner/Runner.entitlements $(BUNDLE_IDENTIFIER) +Runner Release Flutter/Release.xcconfig EYF346PHUG Runner/Runner.entitlements $(BUNDLE_IDENTIFIER) +RunnerTests Debug Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig - - $(BUNDLE_IDENTIFIER).RunnerTests +RunnerTests Profile Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig - - $(BUNDLE_IDENTIFIER).RunnerTests +RunnerTests Release Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig - - $(BUNDLE_IDENTIFIER).RunnerTests +""".splitlines() + + +def parse_project(path: Path) -> dict: + result = subprocess.run( + ["plutil", "-convert", "json", "-o", "-", str(path)], + capture_output=True, + text=True, + ) + if result.returncode != 0: + detail = result.stderr.strip() or "no error detail" + raise RuntimeError(f"plutil failed to parse {path}: {detail}") + if not result.stdout: + raise RuntimeError(f"plutil returned no JSON for {path}") + return json.loads(result.stdout) + + +def semantic_rows(project: dict) -> list[str]: + objects = project["objects"] + root = objects[project["rootObject"]] + rows = [] + for target_id in root["targets"]: + target = objects[target_id] + if target.get("isa") != "PBXNativeTarget": + continue + configuration_list = objects[target["buildConfigurationList"]] + for configuration_id in configuration_list["buildConfigurations"]: + configuration = objects[configuration_id] + settings = configuration.get("buildSettings", {}) + base_reference = configuration.get("baseConfigurationReference") + base_path = objects.get(base_reference, {}).get("path", "-") + rows.append( + " ".join( + [ + target.get("name", "-"), + configuration.get("name", "-"), + base_path, + settings.get("DEVELOPMENT_TEAM", "-"), + settings.get("CODE_SIGN_ENTITLEMENTS", "-"), + settings.get("PRODUCT_BUNDLE_IDENTIFIER", "-"), + ] + ) + ) + return sorted(rows) + + +def main() -> int: + path = Path(sys.argv[1]) if len(sys.argv) > 1 else PBXPROJ + try: + actual = semantic_rows(parse_project(path)) + except (KeyError, TypeError, json.JSONDecodeError, RuntimeError) as error: + print(f"FAIL: {error}", file=sys.stderr) + return 1 + + if actual != EXPECTED_ROWS: + print("FAIL: unexpected iOS target configuration semantics", file=sys.stderr) + print("expected:", *EXPECTED_ROWS, sep="\n", file=sys.stderr) + print("actual:", *actual, sep="\n", file=sys.stderr) + return 1 + + print("iOS target configuration semantics match") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())