Skip to content

Commit

Permalink
feat(shorebird_cli): add export-method option to release ios command (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
bryanoltman authored Nov 2, 2023
1 parent 3b6c28d commit 636c833
Show file tree
Hide file tree
Showing 5 changed files with 294 additions and 111 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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',
Expand Down Expand Up @@ -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?;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
}
44 changes: 8 additions & 36 deletions packages/shorebird_cli/lib/src/shorebird_build_mixin.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -185,20 +184,21 @@ 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',
'--release',
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,
];

Expand Down Expand Up @@ -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
Expand All @@ -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')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
});
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down
Loading

0 comments on commit 636c833

Please sign in to comment.