feat(shorebird_cli): add export-method option to release ios command (#1446)
This commit is contained in:
@@ -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 = '''
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>manageAppVersionAndBuildNumber</key>
|
||||
<false/>
|
||||
<key>signingStyle</key>
|
||||
<string>automatic</string>
|
||||
<key>uploadBitcode</key>
|
||||
<false/>
|
||||
<key>method</key>
|
||||
<string>$exportMethod</string>
|
||||
</dict>
|
||||
</plist>
|
||||
''';
|
||||
final tempDir = Directory.systemTemp.createTempSync();
|
||||
final exportPlistFile = File(p.join(tempDir.path, 'ExportOptions.plist'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync(plistContents);
|
||||
return exportPlistFile;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> 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 = '''
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>manageAppVersionAndBuildNumber</key>
|
||||
<false/>
|
||||
<key>signingStyle</key>
|
||||
<string>automatic</string>
|
||||
<key>uploadBitcode</key>
|
||||
<false/>
|
||||
<key>method</key>
|
||||
<string>app-store</string>
|
||||
</dict>
|
||||
</plist>
|
||||
''';
|
||||
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')
|
||||
|
||||
@@ -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<String>;
|
||||
final exportOptionsPlistFile = File(
|
||||
capturedArgs
|
||||
.whereType<String>()
|
||||
.firstWhere((arg) => arg.contains('export-options-plist'))
|
||||
.split('=')
|
||||
.last,
|
||||
);
|
||||
expect(exportOptionsPlistFile.existsSync(), isTrue);
|
||||
final exportOptionsPlist =
|
||||
PropertyListSerialization.propertyListWithString(
|
||||
exportOptionsPlistFile.readAsStringSync(),
|
||||
) as Map<String, Object>;
|
||||
expect(exportOptionsPlist['manageAppVersionAndBuildNumber'], isFalse);
|
||||
expect(exportOptionsPlist['signingStyle'], 'automatic');
|
||||
expect(exportOptionsPlist['uploadBitcode'], isFalse);
|
||||
expect(exportOptionsPlist['method'], 'app-store');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<String>;
|
||||
final exportOptionsPlistFile = File(
|
||||
capturedArgs
|
||||
.whereType<String>()
|
||||
.firstWhere((arg) => arg.contains('export-options-plist'))
|
||||
.split('=')
|
||||
.last,
|
||||
);
|
||||
expect(exportOptionsPlistFile.existsSync(), isTrue);
|
||||
final exportOptionsPlist =
|
||||
PropertyListSerialization.propertyListWithString(
|
||||
exportOptionsPlistFile.readAsStringSync(),
|
||||
) as Map<String, Object>;
|
||||
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();
|
||||
|
||||
@@ -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<String>;
|
||||
final exportOptionsPlistFile = File(
|
||||
capturedArgs
|
||||
.whereType<String>()
|
||||
.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 = '''
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
</dict>
|
||||
</plist>
|
||||
''';
|
||||
|
||||
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<String>;
|
||||
final exportOptionsPlistFile = File(
|
||||
capturedArgs
|
||||
.whereType<String>()
|
||||
.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);
|
||||
|
||||
Reference in New Issue
Block a user