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);