diff --git a/packages/shorebird_cli/lib/src/commands/release/release_ios_command.dart b/packages/shorebird_cli/lib/src/commands/release/release_ios_command.dart index 5869bb53a..f97d18013 100644 --- a/packages/shorebird_cli/lib/src/commands/release/release_ios_command.dart +++ b/packages/shorebird_cli/lib/src/commands/release/release_ios_command.dart @@ -16,6 +16,39 @@ import 'package:shorebird_cli/src/shorebird_env.dart'; import 'package:shorebird_cli/src/shorebird_validator.dart'; import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; +const exportMethodArgName = 'export-method'; +const exportOptionsPlistArgName = 'export-options-plist'; + +/// {@template export_method} +/// The method used to export the IPA. +/// {@endtemplate} +enum ExportMethod { + appStore('app-store', 'Upload to the App Store'), + adHoc( + 'ad-hoc', + ''' +Test on designated devices that do not need to be registered with the Apple developer account. + Requires a distribution certificate.''', + ), + development( + 'development', + '''Test only on development devices registered with the Apple developer account.''', + ), + enterprise( + 'enterprise', + 'Distribute an app registered with the Apple Developer Enterprise Program.', + ); + + /// {@macro export_method} + const ExportMethod(this.argName, this.description); + + /// The command-line argument name for this export method. + final String argName; + + /// A description of this method and how/when it should be used. + final String description; +} + /// {@template release_ios_command} /// `shorebird release ios-alpha` /// Create new app releases for iOS. @@ -39,6 +72,21 @@ class ReleaseIosCommand extends ShorebirdCommand help: 'Codesign the application bundle.', defaultsTo: true, ) + ..addOption( + exportMethodArgName, + defaultsTo: ExportMethod.appStore.argName, + allowed: ExportMethod.values.map((e) => e.argName), + help: 'Specify how the IPA will be distributed.', + allowedHelp: { + for (final method in ExportMethod.values) + method.argName: method.description, + }, + ) + ..addOption( + exportOptionsPlistArgName, + help: + '''Export an IPA with these options. See "xcodebuild -h" for available exportOptionsPlist keys.''', + ) ..addFlag( 'force', abbr: 'f', @@ -83,6 +131,25 @@ make smaller updates to your app. ); } + final exportPlistArg = results[exportOptionsPlistArgName] as String?; + if (exportPlistArg != null && results.wasParsed(exportMethodArgName)) { + logger.err( + '''Cannot specify both --$exportMethodArgName and --$exportOptionsPlistArgName.''', + ); + return ExitCode.usage.code; + } + final exportOptionsPlist = exportPlistArg != null + ? File(exportPlistArg) + : _createExportOptionsPlist( + exportMethod: results[exportMethodArgName] as String, + ); + try { + _validateExportOptionsPlist(exportOptionsPlist); + } catch (error) { + logger.err('$error'); + return ExitCode.usage.code; + } + const releasePlatform = ReleasePlatform.ios; final flavor = results['flavor'] as String?; final target = results['target'] as String?; @@ -92,7 +159,12 @@ make smaller updates to your app. final buildProgress = logger.progress('Building release'); try { - await buildIpa(codesign: codesign, flavor: flavor, target: target); + await buildIpa( + codesign: codesign, + exportOptionsPlist: exportOptionsPlist, + flavor: flavor, + target: target, + ); } on ProcessException catch (error) { buildProgress.fail('Failed to build: ${error.message}'); return ExitCode.software.code; @@ -237,4 +309,57 @@ ${styleBold.wrap('Make sure to uncheck "Manage Version and Build Number", or els return ExitCode.success.code; } + + /// Verifies that [exportOptionsPlistFile] exists and sets + /// manageAppVersionAndBuildNumber to false, which prevents Xcode from + /// changing the version number out from under us. + /// + /// Throws an exception if validation fails, exits normally if validation + /// succeeds. + void _validateExportOptionsPlist(File exportOptionsPlistFile) { + if (!exportOptionsPlistFile.existsSync()) { + throw Exception( + '''Export options plist file ${exportOptionsPlistFile.path} does not exist''', + ); + } + + final plist = Plist(file: exportOptionsPlistFile); + if (plist.properties['manageAppVersionAndBuildNumber'] != false) { + throw Exception( + '''Export options plist ${exportOptionsPlistFile.path} does not set manageAppVersionAndBuildNumber to false. This is required for shorebird to work.''', + ); + } + } + + /// Creates an ExportOptions.plist file, which is used to tell xcodebuild to + /// not manage the app version and build number. If we don't do this, then + /// xcodebuild will increment the build number if it detects an App Store + /// Connect build with the same version and build number. This is a problem + /// for us when patching, as patches need to have the same version and build + /// number as the release they are patching. + /// See + /// https://developer.apple.com/forums/thread/690647?answerId=689925022#689925022 + File _createExportOptionsPlist({required String exportMethod}) { + final plistContents = ''' + + + + + manageAppVersionAndBuildNumber + + signingStyle + automatic + uploadBitcode + + method + $exportMethod + + +'''; + final tempDir = Directory.systemTemp.createTempSync(); + final exportPlistFile = File(p.join(tempDir.path, 'ExportOptions.plist')) + ..createSync(recursive: true) + ..writeAsStringSync(plistContents); + return exportPlistFile; + } } diff --git a/packages/shorebird_cli/lib/src/shorebird_build_mixin.dart b/packages/shorebird_cli/lib/src/shorebird_build_mixin.dart index ae8c7c978..23960a65d 100644 --- a/packages/shorebird_cli/lib/src/shorebird_build_mixin.dart +++ b/packages/shorebird_cli/lib/src/shorebird_build_mixin.dart @@ -2,7 +2,6 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:mason_logger/mason_logger.dart'; -import 'package:path/path.dart' as p; import 'package:shorebird_cli/src/command.dart'; import 'package:shorebird_cli/src/logger.dart'; import 'package:shorebird_cli/src/process.dart'; @@ -185,12 +184,12 @@ mixin ShorebirdBuildMixin on ShorebirdCommand { /// an .xcarchive and _not_ an .ipa. Future buildIpa({ required bool codesign, + File? exportOptionsPlist, String? flavor, String? target, }) async { return _runShorebirdBuildCommand(() async { const executable = 'flutter'; - final exportPlistFile = _createExportOptionsPlist(); final arguments = [ 'build', 'ipa', @@ -198,7 +197,8 @@ mixin ShorebirdBuildMixin on ShorebirdCommand { if (flavor != null) '--flavor=$flavor', if (target != null) '--target=$target', if (!codesign) '--no-codesign', - if (codesign) '--export-options-plist=${exportPlistFile.path}', + if (codesign && exportOptionsPlist != null) + '--export-options-plist=${exportOptionsPlist.path}', ...results.rest, ]; @@ -297,42 +297,14 @@ Either run `flutter pub get` manually, or follow the steps in ${link(uri: Uri.pa } } - /// Creates an ExportOptions.plist file, which is used to tell xcodebuild to - /// not manage the app version and build number. If we don't do this, then - /// xcodebuild will increment the build number if it detects an App Store - /// Connect build with the same version and build number. This is a problem - /// for us when patching, as patches need to have the same version and build - /// number as the release they are patching. - /// See - /// https://developer.apple.com/forums/thread/690647?answerId=689925022#689925022 - File _createExportOptionsPlist() { - const plistContents = ''' - - - - - manageAppVersionAndBuildNumber - - signingStyle - automatic - uploadBitcode - - method - app-store - - -'''; - final tempDir = Directory.systemTemp.createTempSync(); - final exportPlistFile = File(p.join(tempDir.path, 'ExportOptions.plist')) - ..createSync(recursive: true) - ..writeAsStringSync(plistContents); - return exportPlistFile; - } - String _failedToCreateIpaErrorMessage({required String stderr}) { // The full error text consists of many repeated lines of the format: // (newlines added for line length) // + // [ +1 ms] Encountered error while creating the IPA: + // [ ] error: exportArchive: Team "Team" does not have permission to + // create "iOS In House" provisioning profiles. + // error: exportArchive: No profiles for 'com.example.dev' were found // error: exportArchive: No signing certificate "iOS Distribution" found // error: exportArchive: Communication with Apple failed // error: exportArchive: No signing certificate "iOS Distribution" found @@ -342,7 +314,7 @@ Either run `flutter pub get` manually, or follow the steps in ${link(uri: Uri.pa // error: exportArchive: Communication with Apple failed // error: exportArchive: No signing certificate "iOS Distribution" found // error: exportArchive: Communication with Apple failed - final exportArchiveRegex = RegExp(r'^error: exportArchive: (.+)$'); + final exportArchiveRegex = RegExp(r'error: exportArchive: (.+)$'); return stderr .split('\n') diff --git a/packages/shorebird_cli/test/src/commands/build/build_ipa_command_test.dart b/packages/shorebird_cli/test/src/commands/build/build_ipa_command_test.dart index ca4346821..998420dd3 100644 --- a/packages/shorebird_cli/test/src/commands/build/build_ipa_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/build/build_ipa_command_test.dart @@ -4,7 +4,6 @@ import 'package:args/args.dart'; import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; import 'package:path/path.dart' as p; -import 'package:propertylistserialization/propertylistserialization.dart'; import 'package:scoped/scoped.dart'; import 'package:shorebird_cli/src/commands/build/build.dart'; import 'package:shorebird_cli/src/doctor.dart'; @@ -282,41 +281,5 @@ ${lightCyan.wrap(p.join('build', 'ios', 'ipa', 'Runner.ipa'))}''', ), ); }); - - test('provides appropriate ExportOptions.plist to build ipa command', - () async { - when(() => buildProcessResult.exitCode).thenReturn(ExitCode.success.code); - final tempDir = Directory.systemTemp.createTempSync(); - final result = await IOOverrides.runZoned( - () async => runWithOverrides(command.run), - getCurrentDirectory: () => tempDir, - ); - - expect(result, equals(ExitCode.success.code)); - expect(exitCode, ExitCode.success.code); - final capturedArgs = verify( - () => shorebirdProcess.run( - 'flutter', - captureAny(), - runInShell: any(named: 'runInShell'), - ), - ).captured.first as List; - final exportOptionsPlistFile = File( - capturedArgs - .whereType() - .firstWhere((arg) => arg.contains('export-options-plist')) - .split('=') - .last, - ); - expect(exportOptionsPlistFile.existsSync(), isTrue); - final exportOptionsPlist = - PropertyListSerialization.propertyListWithString( - exportOptionsPlistFile.readAsStringSync(), - ) as Map; - expect(exportOptionsPlist['manageAppVersionAndBuildNumber'], isFalse); - expect(exportOptionsPlist['signingStyle'], 'automatic'); - expect(exportOptionsPlist['uploadBitcode'], isFalse); - expect(exportOptionsPlist['method'], 'app-store'); - }); }); } diff --git a/packages/shorebird_cli/test/src/commands/patch/patch_ios_command_test.dart b/packages/shorebird_cli/test/src/commands/patch/patch_ios_command_test.dart index dc77fa2a8..85107d279 100644 --- a/packages/shorebird_cli/test/src/commands/patch/patch_ios_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/patch_ios_command_test.dart @@ -7,7 +7,6 @@ import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; import 'package:path/path.dart' as p; import 'package:platform/platform.dart'; -import 'package:propertylistserialization/propertylistserialization.dart'; import 'package:scoped/scoped.dart'; import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart'; import 'package:shorebird_cli/src/auth/auth.dart'; @@ -1082,42 +1081,6 @@ base_url: $baseUrl''', ); }); - test('provides appropriate ExportOptions.plist to build ipa command', - () async { - final tempDir = setUpTempDir(); - setUpTempArtifacts(tempDir); - - final exitCode = await IOOverrides.runZoned( - () => runWithOverrides(command.run), - getCurrentDirectory: () => tempDir, - ); - - expect(exitCode, ExitCode.success.code); - final capturedArgs = verify( - () => shorebirdProcess.run( - 'flutter', - captureAny(), - runInShell: any(named: 'runInShell'), - ), - ).captured.first as List; - final exportOptionsPlistFile = File( - capturedArgs - .whereType() - .firstWhere((arg) => arg.contains('export-options-plist')) - .split('=') - .last, - ); - expect(exportOptionsPlistFile.existsSync(), isTrue); - final exportOptionsPlist = - PropertyListSerialization.propertyListWithString( - exportOptionsPlistFile.readAsStringSync(), - ) as Map; - expect(exportOptionsPlist['manageAppVersionAndBuildNumber'], isFalse); - expect(exportOptionsPlist['signingStyle'], 'automatic'); - expect(exportOptionsPlist['uploadBitcode'], isFalse); - expect(exportOptionsPlist['method'], 'app-store'); - }); - test('does not prompt if running on CI', () async { when(() => shorebirdEnv.isRunningOnCI).thenReturn(true); final tempDir = setUpTempDir(); diff --git a/packages/shorebird_cli/test/src/commands/release/release_ios_command_test.dart b/packages/shorebird_cli/test/src/commands/release/release_ios_command_test.dart index ebc3ff882..0f428cbf2 100644 --- a/packages/shorebird_cli/test/src/commands/release/release_ios_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/release_ios_command_test.dart @@ -8,6 +8,7 @@ import 'package:path/path.dart' as p; import 'package:platform/platform.dart'; import 'package:propertylistserialization/propertylistserialization.dart'; import 'package:scoped/scoped.dart'; +import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart'; import 'package:shorebird_cli/src/auth/auth.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/commands/commands.dart'; @@ -216,6 +217,12 @@ flutter: when(() => argResults['arch']).thenReturn(arch); when(() => argResults['codesign']).thenReturn(true); when(() => argResults['platform']).thenReturn(releasePlatform); + when(() => argResults['export-options-plist']).thenReturn(null); + // This is the default value in ReleaseIosCommand. + when(() => argResults['export-method']).thenReturn( + ExportMethod.appStore.argName, + ); + when(() => argResults.wasParsed('export-method')).thenReturn(false); when(() => auth.isAuthenticated).thenReturn(true); when(() => logger.progress(any())).thenReturn(progress); when(() => logger.confirm(any())).thenReturn(true); @@ -496,6 +503,159 @@ flutter: }); }); + group('when both export-method and export-options-plist are provided', () { + setUp(() { + when(() => argResults.wasParsed(exportMethodArgName)).thenReturn(true); + when(() => argResults[exportOptionsPlistArgName]) + .thenReturn('/path/to/export.plist'); + }); + + test('logs error and exits with usage code', () async { + final tempDir = setUpTempDir(); + final result = await IOOverrides.runZoned( + () => runWithOverrides(command.run), + getCurrentDirectory: () => tempDir, + ); + + expect(result, equals(ExitCode.usage.code)); + verify( + () => logger.err( + 'Cannot specify both --export-method and --export-options-plist.', + ), + ).called(1); + }); + }); + + group('when export-method is provided', () { + setUp(() { + when(() => argResults.wasParsed(exportMethodArgName)).thenReturn(true); + when(() => argResults[exportMethodArgName]) + .thenReturn(ExportMethod.enterprise.argName); + when(() => argResults[exportOptionsPlistArgName]).thenReturn(null); + }); + + test('generates an export options plist with that export method', + () async { + final tempDir = setUpTempDir(); + await IOOverrides.runZoned( + () => runWithOverrides(command.run), + getCurrentDirectory: () => tempDir, + ); + + final capturedArgs = verify( + () => shorebirdProcess.run( + 'flutter', + captureAny(), + runInShell: any(named: 'runInShell'), + ), + ).captured.first as List; + final exportOptionsPlistFile = File( + capturedArgs + .whereType() + .firstWhere((arg) => arg.contains(exportOptionsPlistArgName)) + .split('=') + .last, + ); + final exportOptionsPlist = Plist(file: exportOptionsPlistFile); + expect( + exportOptionsPlist.properties['method'], + ExportMethod.enterprise.argName, + ); + }); + }); + + group('when export-options-plist is provided', () { + group('when file does not exist', () { + setUp(() { + when(() => argResults[exportOptionsPlistArgName]) + .thenReturn('/does/not/exist'); + }); + + test('exits with usage code', () async { + final tempDir = setUpTempDir(); + final result = await IOOverrides.runZoned( + () => runWithOverrides(command.run), + getCurrentDirectory: () => tempDir, + ); + + expect(result, equals(ExitCode.usage.code)); + verify( + () => logger.err( + 'Exception: Export options plist file /does/not/exist does not exist', + ), + ).called(1); + }); + }); + + group('when manageAppVersionAndBuildNumber is not set to false', () { + const exportPlistContent = ''' + + + + + + +'''; + + test('exits with usage code', () async { + final tempDir = setUpTempDir(); + final exportPlistFile = File( + p.join(tempDir.path, 'export.plist'), + )..writeAsStringSync(exportPlistContent); + when(() => argResults[exportOptionsPlistArgName]) + .thenReturn(exportPlistFile.path); + final result = await IOOverrides.runZoned( + () => runWithOverrides(command.run), + getCurrentDirectory: () => tempDir, + ); + + expect(result, equals(ExitCode.usage.code)); + verify( + () => logger.err( + '''Exception: Export options plist ${exportPlistFile.path} does not set manageAppVersionAndBuildNumber to false. This is required for shorebird to work.''', + ), + ).called(1); + }); + }); + }); + + group('when neither export-method nor export-options-plist is provided', + () { + setUp(() { + when(() => argResults.wasParsed(exportMethodArgName)).thenReturn(false); + when(() => argResults[exportOptionsPlistArgName]).thenReturn(null); + }); + + test('generates an export options plist with app-store export method', + () async { + final tempDir = setUpTempDir(); + await IOOverrides.runZoned( + () => runWithOverrides(command.run), + getCurrentDirectory: () => tempDir, + ); + + final capturedArgs = verify( + () => shorebirdProcess.run( + 'flutter', + captureAny(), + runInShell: any(named: 'runInShell'), + ), + ).captured.first as List; + final exportOptionsPlistFile = File( + capturedArgs + .whereType() + .firstWhere((arg) => arg.contains(exportOptionsPlistArgName)) + .split('=') + .last, + ); + final exportOptionsPlist = Plist(file: exportOptionsPlistFile); + expect( + exportOptionsPlist.properties['method'], + ExportMethod.appStore.argName, + ); + }); + }); + test('exits with code 70 when build fails with non-zero exit code', () async { when(() => flutterBuildProcessResult.exitCode).thenReturn(1);