refactor(shorebird_cli): add iOS support to new release command (#2008)

This commit is contained in:
Bryan Oltman
2024-05-07 14:16:00 -04:00
committed by GitHub
parent e42991c10d
commit 9d7a367da8
13 changed files with 947 additions and 39 deletions
@@ -140,7 +140,8 @@ class AarReleaser extends Releaser {
}
@override
UpdateReleaseMetadata get releaseMetadata => UpdateReleaseMetadata(
Future<UpdateReleaseMetadata> releaseMetadata() async =>
UpdateReleaseMetadata(
releasePlatform: releaseType.releasePlatform,
flutterVersionOverride: argResults['flutter-version'] as String?,
generatedApks: false,
@@ -164,7 +164,8 @@ Please comment and upvote ${link(uri: Uri.parse('https://github.com/shorebirdtec
}
@override
UpdateReleaseMetadata get releaseMetadata => UpdateReleaseMetadata(
Future<UpdateReleaseMetadata> releaseMetadata() async =>
UpdateReleaseMetadata(
releasePlatform: releaseType.releasePlatform,
flutterVersionOverride: argResults['flutter-version'] as String?,
generatedApks: generateApk,
@@ -0,0 +1,206 @@
import 'package:mason_logger/mason_logger.dart';
import 'package:path/path.dart' as p;
import 'package:platform/platform.dart';
import 'package:shorebird_cli/src/archive_analysis/plist.dart';
import 'package:shorebird_cli/src/artifact_builder.dart';
import 'package:shorebird_cli/src/artifact_manager.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/commands/release_new/release_type.dart';
import 'package:shorebird_cli/src/commands/release_new/releaser.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/executables/xcodebuild.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/platform.dart';
import 'package:shorebird_cli/src/platform/ios.dart';
import 'package:shorebird_cli/src/shorebird_flutter.dart';
import 'package:shorebird_cli/src/shorebird_validator.dart';
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart';
import 'package:shorebird_cli/src/version.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
/// {@template ios_releaser}
/// Functions to build and publish an iOS release.
/// {@endtemplate}
class IosReleaser extends Releaser {
/// {@macro ios_releaser}
IosReleaser({
required super.argResults,
required super.flavor,
required super.target,
});
/// Whether to codesign the release.
bool get codesign => argResults['codesign'] == true;
@override
ReleaseType get releaseType => ReleaseType.ios;
@override
Future<void> assertArgsAreValid() async {
if (argResults.rest.contains('--obfuscate')) {
// Obfuscated releases break patching, so we don't support them.
// See https://github.com/shorebirdtech/shorebird/issues/1619
logger
..err('Shorebird does not currently support obfuscation on iOS.')
..info(
'''We hope to support obfuscation in the future. We are tracking this work at ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebird/issues/1619'))}.''',
);
exit(ExitCode.unavailable.code);
}
}
@override
Future<void> assertPreconditions() async {
try {
await shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: true,
checkShorebirdInitialized: true,
validators: doctor.iosCommandValidators,
supportedOperatingSystems: {Platform.macOS},
);
} on PreconditionFailedException catch (e) {
exit(e.exitCode.code);
}
}
@override
Future<FileSystemEntity> buildReleaseArtifacts() async {
if (!codesign) {
logger
..info(
'''Building for device with codesigning disabled. You will have to manually codesign before deploying to device.''',
)
..warn(
'''shorebird preview will not work for releases created with "--no-codesign". However, you can still preview your app by signing the generated .xcarchive in Xcode.''',
);
}
final File exportOptionsPlist;
try {
exportOptionsPlist = ios.exportOptionsPlistFromArgs(argResults);
} catch (error) {
logger.err('$error');
exit(ExitCode.usage.code);
}
final flutterVersionString = await shorebirdFlutter.getVersionAndRevision();
final buildProgress =
logger.progress('Building ipa with Flutter $flutterVersionString');
try {
await artifactBuilder.buildIpa(
codesign: codesign,
exportOptionsPlist: exportOptionsPlist,
flavor: flavor,
target: target,
);
buildProgress.complete();
} on ArtifactBuildException catch (error) {
buildProgress.fail(error.message);
exit(ExitCode.software.code);
}
final xcarchiveDirectory = artifactManager.getXcarchiveDirectory();
if (xcarchiveDirectory == null) {
logger.err('Unable to find .xcarchive directory');
exit(ExitCode.software.code);
}
final appDirectory = artifactManager.getIosAppDirectory(
xcarchiveDirectory: xcarchiveDirectory,
);
if (appDirectory == null) {
logger.err('Unable to find .app directory');
exit(ExitCode.software.code);
}
return xcarchiveDirectory;
}
@override
Future<String> getReleaseVersion({
required FileSystemEntity releaseArtifactRoot,
}) async {
final plistFile = File(p.join(releaseArtifactRoot.path, 'Info.plist'));
if (!plistFile.existsSync()) {
logger.err('No Info.plist file found at ${plistFile.path}');
exit(ExitCode.software.code);
}
try {
return Plist(file: plistFile).versionNumber;
} catch (error) {
logger.err(
'''Failed to determine release version from ${plistFile.path}: $error''',
);
exit(ExitCode.software.code);
}
}
@override
Future<void> uploadReleaseArtifacts({
required Release release,
required String appId,
}) async {
final xcarchiveDirectory = artifactManager.getXcarchiveDirectory()!;
await codePushClientWrapper.createIosReleaseArtifacts(
appId: appId,
releaseId: release.id,
xcarchivePath: xcarchiveDirectory.path,
runnerPath: artifactManager
.getIosAppDirectory(xcarchiveDirectory: xcarchiveDirectory)!
.path,
isCodesigned: codesign,
);
}
@override
String get postReleaseInstructions {
final relativeArchivePath = p.relative(
artifactManager.getXcarchiveDirectory()!.path,
);
if (codesign) {
final ipa = artifactManager.getIpa();
if (ipa == null) {
logger.err('Could not find ipa file');
exit(ExitCode.software.code);
}
final relativeIpaPath = p.relative(ipa.path);
return '''
Your next step is to upload your app to App Store Connect.
To upload to the App Store, do one of the following:
1. Open ${lightCyan.wrap(relativeArchivePath)} in Xcode and use the "Distribute App" flow.
2. Drag and drop the ${lightCyan.wrap(relativeIpaPath)} bundle into the Apple Transporter macOS app (https://apps.apple.com/us/app/transporter/id1450874784).
3. Run ${lightCyan.wrap('xcrun altool --upload-app --type ios -f $relativeIpaPath --apiKey your_api_key --apiIssuer your_issuer_id')}.
See "man altool" for details about how to authenticate with the App Store Connect API key.
''';
} else {
return '''
Your next step is to submit the archive at ${lightCyan.wrap(relativeArchivePath)} to the App Store using Xcode.
You can open the archive in Xcode by running:
${lightCyan.wrap('open $relativeArchivePath')}
${styleBold.wrap('Make sure to uncheck "Manage Version and Build Number", or else shorebird will not work.')}
''';
}
}
@override
Future<UpdateReleaseMetadata> releaseMetadata() async =>
UpdateReleaseMetadata(
releasePlatform: releaseType.releasePlatform,
flutterVersionOverride: argResults['flutter-version'] as String?,
generatedApks: false,
environment: BuildEnvironmentMetadata(
operatingSystem: platform.operatingSystem,
operatingSystemVersion: platform.operatingSystemVersion,
shorebirdVersion: packageVersion,
xcodeVersion: await xcodeBuild.version(),
),
);
}
@@ -1,4 +1,5 @@
export 'aar_releaser.dart';
export 'android_releaser.dart';
export 'ios_releaser.dart';
export 'release_new_command.dart';
export 'releaser.dart';
@@ -7,6 +7,7 @@ import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/commands/release_new/aar_releaser.dart';
import 'package:shorebird_cli/src/commands/release_new/android_releaser.dart';
import 'package:shorebird_cli/src/commands/release_new/ios_releaser.dart';
import 'package:shorebird_cli/src/commands/release_new/release_type.dart';
import 'package:shorebird_cli/src/commands/release_new/releaser.dart';
import 'package:shorebird_cli/src/config/config.dart';
@@ -48,6 +49,16 @@ On Android it is used as "versionCode".
On Xcode builds it is used as "CFBundleVersion".''',
defaultsTo: '1.0',
)
..addFlag(
'codesign',
help: 'Codesign the application bundle.',
defaultsTo: true,
)
..addOption(
exportOptionsPlistArgName,
help:
'''Export an IPA with these options. See "xcodebuild -h" for available exportOptionsPlist keys.''',
)
..addOption(
'flutter-version',
help: 'The Flutter version to use when building the app (e.g: 3.16.3).',
@@ -125,7 +136,7 @@ of the iOS app that is using this module.''',
target: target,
);
case ReleaseType.ios:
throw UnimplementedError();
return IosReleaser(argResults: results, flavor: flavor, target: target);
case ReleaseType.iosFramework:
throw UnimplementedError();
case ReleaseType.aar:
@@ -201,12 +212,14 @@ of the iOS app that is using this module.''',
version: releaseVersion,
releasePlatform: releaser.releaseType.releasePlatform,
);
await prepareRelease(release: release, pipeline: releaser);
await prepareRelease(release: release, releaser: releaser);
await releaser.uploadReleaseArtifacts(release: release, appId: appId);
await finalizeRelease(release: release, pipeline: releaser);
await finalizeRelease(release: release, releaser: releaser);
logger
..success('✅ Published Release ${release.version}!')
..success('''
✅ Published Release ${release.version}!''')
..info(releaser.postReleaseInstructions);
printPatchInstructions(
@@ -369,12 +382,12 @@ ${summary.join('\n')}
/// Prepares the release by updating the release status to draft.
Future<void> prepareRelease({
required Release release,
required Releaser pipeline,
required Releaser releaser,
}) async {
await codePushClientWrapper.updateReleaseStatus(
appId: appId,
releaseId: release.id,
platform: pipeline.releaseType.releasePlatform,
platform: releaser.releaseType.releasePlatform,
status: ReleaseStatus.draft,
);
}
@@ -382,18 +395,18 @@ ${summary.join('\n')}
/// Finalizes the release by updating the status to active.
Future<void> finalizeRelease({
required Release release,
required Releaser pipeline,
required Releaser releaser,
}) async {
await codePushClientWrapper.updateReleaseStatus(
appId: appId,
releaseId: release.id,
platform: pipeline.releaseType.releasePlatform,
platform: releaser.releaseType.releasePlatform,
status: ReleaseStatus.active,
metadata: pipeline.releaseMetadata,
metadata: await releaser.releaseMetadata(),
);
}
/// Instructions explaining how to patch the release that was just created.
/// Instructions explaining how to patch the release that was just creatd.
void printPatchInstructions({
required Releaser releaser,
required String releaseVersion,
@@ -56,7 +56,7 @@ abstract class Releaser {
/// Metadata to attach to the release when creating it, used for debugging
/// and support.
UpdateReleaseMetadata get releaseMetadata;
Future<UpdateReleaseMetadata> releaseMetadata();
/// Instructions explaining next steps after running `shorebird release`. This
/// could include how to upload the generated artifact to a store and how to
@@ -46,7 +46,11 @@ class InvalidExportOptionsPlistException implements Exception {
/// {@macro invalid_export_options_plist_exception}
InvalidExportOptionsPlistException(this.message);
/// An explanation of this exception.
final String message;
@override
String toString() => message;
}
/// The minimum allowed Flutter version for creating iOS releases.
@@ -408,18 +408,20 @@ void main() {
.thenReturn(operatingSystemVersion);
});
test('returns expected metadata', () {
test('returns expected metadata', () async {
expect(
runWithOverrides(() => aarReleaser.releaseMetadata),
const UpdateReleaseMetadata(
releasePlatform: ReleasePlatform.android,
flutterVersionOverride: null,
generatedApks: false,
environment: BuildEnvironmentMetadata(
operatingSystem: operatingSystem,
operatingSystemVersion: operatingSystemVersion,
shorebirdVersion: packageVersion,
xcodeVersion: null,
await runWithOverrides(aarReleaser.releaseMetadata),
equals(
const UpdateReleaseMetadata(
releasePlatform: ReleasePlatform.android,
flutterVersionOverride: null,
generatedApks: false,
environment: BuildEnvironmentMetadata(
operatingSystem: operatingSystem,
operatingSystemVersion: operatingSystemVersion,
shorebirdVersion: packageVersion,
xcodeVersion: null,
),
),
),
);
@@ -471,9 +471,9 @@ void main() {
when(() => argResults['android-artifact']).thenReturn('apk');
});
test('returns expected metadata', () {
test('returns expected metadata', () async {
expect(
runWithOverrides(() => androidReleaser.releaseMetadata),
await runWithOverrides(() => androidReleaser.releaseMetadata()),
const UpdateReleaseMetadata(
releasePlatform: ReleasePlatform.android,
flutterVersionOverride: null,
@@ -494,9 +494,9 @@ void main() {
when(() => argResults['android-artifact']).thenReturn('aab');
});
test('returns expected metadata', () {
test('returns expected metadata', () async {
expect(
runWithOverrides(() => androidReleaser.releaseMetadata),
await runWithOverrides(() => androidReleaser.releaseMetadata()),
const UpdateReleaseMetadata(
releasePlatform: ReleasePlatform.android,
flutterVersionOverride: null,
@@ -0,0 +1,670 @@
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:platform/platform.dart';
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/artifact_builder.dart';
import 'package:shorebird_cli/src/artifact_manager.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/commands/release_new/ios_releaser.dart';
import 'package:shorebird_cli/src/commands/release_new/release_type.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/executables/xcodebuild.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/os/operating_system_interface.dart';
import 'package:shorebird_cli/src/platform.dart';
import 'package:shorebird_cli/src/platform/ios.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_flutter.dart';
import 'package:shorebird_cli/src/shorebird_process.dart';
import 'package:shorebird_cli/src/shorebird_validator.dart';
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart';
import 'package:shorebird_cli/src/validators/validators.dart';
import 'package:shorebird_cli/src/version.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
import 'package:test/test.dart';
import '../../matchers.dart';
import '../../mocks.dart';
void main() {
group(
IosReleaser,
() {
late ArgResults argResults;
late ArtifactBuilder artifactBuilder;
late ArtifactManager artifactManager;
late CodePushClientWrapper codePushClientWrapper;
late Directory projectRoot;
late Doctor doctor;
late Platform platform;
late Progress progress;
late Logger logger;
late Ios ios;
late OperatingSystemInterface operatingSystemInterface;
late ShorebirdFlutterValidator flutterValidator;
late ShorebirdProcess shorebirdProcess;
late ShorebirdEnv shorebirdEnv;
late ShorebirdFlutter shorebirdFlutter;
late ShorebirdValidator shorebirdValidator;
late XcodeBuild xcodeBuild;
late IosReleaser iosReleaser;
R runWithOverrides<R>(R Function() body) {
return runScoped(
body,
values: {
artifactBuilderRef.overrideWith(() => artifactBuilder),
artifactManagerRef.overrideWith(() => artifactManager),
codePushClientWrapperRef.overrideWith(() => codePushClientWrapper),
doctorRef.overrideWith(() => doctor),
iosRef.overrideWith(() => ios),
loggerRef.overrideWith(() => logger),
osInterfaceRef.overrideWith(() => operatingSystemInterface),
platformRef.overrideWith(() => platform),
processRef.overrideWith(() => shorebirdProcess),
shorebirdEnvRef.overrideWith(() => shorebirdEnv),
shorebirdFlutterRef.overrideWith(() => shorebirdFlutter),
shorebirdValidatorRef.overrideWith(() => shorebirdValidator),
xcodeBuildRef.overrideWith(() => xcodeBuild),
},
);
}
setUpAll(() {
registerFallbackValue(Directory(''));
registerFallbackValue(ReleasePlatform.android);
setExitFunctionForTests();
});
tearDownAll(restoreExitFunction);
setUp(() {
argResults = MockArgResults();
artifactBuilder = MockArtifactBuilder();
artifactManager = MockArtifactManager();
codePushClientWrapper = MockCodePushClientWrapper();
doctor = MockDoctor();
platform = MockPlatform();
projectRoot = Directory.systemTemp.createTempSync();
operatingSystemInterface = MockOperatingSystemInterface();
progress = MockProgress();
logger = MockLogger();
ios = MockIos();
flutterValidator = MockShorebirdFlutterValidator();
shorebirdProcess = MockShorebirdProcess();
shorebirdEnv = MockShorebirdEnv();
shorebirdFlutter = MockShorebirdFlutter();
shorebirdValidator = MockShorebirdValidator();
xcodeBuild = MockXcodeBuild();
when(() => argResults.rest).thenReturn([]);
when(() => logger.progress(any())).thenReturn(progress);
iosReleaser = IosReleaser(
argResults: argResults,
flavor: null,
target: null,
);
});
group('releaseType', () {
test('is ios', () {
expect(iosReleaser.releaseType, ReleaseType.ios);
});
});
group('assertPreconditions', () {
setUp(() {
when(() => doctor.iosCommandValidators)
.thenReturn([flutterValidator]);
when(flutterValidator.validate).thenAnswer((_) async => []);
});
group('when validation succeeds', () {
setUp(() {
when(
() => shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated:
any(named: 'checkUserIsAuthenticated'),
checkShorebirdInitialized:
any(named: 'checkShorebirdInitialized'),
validators: any(named: 'validators'),
supportedOperatingSystems:
any(named: 'supportedOperatingSystems'),
),
).thenAnswer((_) async {});
});
test('returns normally', () async {
await expectLater(
() => runWithOverrides(iosReleaser.assertPreconditions),
returnsNormally,
);
});
});
group('when validation fails', () {
final exception = ValidationFailedException();
setUp(() {
when(
() => shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated:
any(named: 'checkUserIsAuthenticated'),
checkShorebirdInitialized:
any(named: 'checkShorebirdInitialized'),
validators: any(named: 'validators'),
supportedOperatingSystems:
any(named: 'supportedOperatingSystems'),
),
).thenThrow(exception);
});
test('exits with code 70', () async {
await expectLater(
() => runWithOverrides(iosReleaser.assertPreconditions),
exitsWithCode(exception.exitCode),
);
verify(
() => shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: true,
checkShorebirdInitialized: true,
validators: [flutterValidator],
supportedOperatingSystems: {Platform.macOS},
),
).called(1);
});
});
});
group('assertArgsAreValid', () {
group('when --obfuscate is passed', () {
setUp(() {
when(() => argResults.rest).thenReturn(['--obfuscate']);
});
test('logs error and exits', () async {
await expectLater(
runWithOverrides(iosReleaser.assertArgsAreValid),
exitsWithCode(ExitCode.unavailable),
);
verify(
() => logger.err(
'Shorebird does not currently support obfuscation on iOS.',
),
).called(1);
verify(
() => logger.info(
'''We hope to support obfuscation in the future. We are tracking this work at ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebird/issues/1619'))}.''',
),
).called(1);
});
});
group('when --obfuscate is not passed', () {
test('returns normally', () async {
await expectLater(
runWithOverrides(iosReleaser.assertArgsAreValid),
completes,
);
});
});
});
group('buildReleaseArtifacts', () {
const flutterVersionAndRevision = '3.10.6 (83305b5088)';
late Directory xcarchiveDirectory;
late Directory iosAppDirectory;
setUp(() {
xcarchiveDirectory = Directory.systemTemp.createTempSync();
iosAppDirectory = Directory.systemTemp.createTempSync();
when(() => argResults['codesign']).thenReturn(true);
when(
() => artifactBuilder.buildIpa(
codesign: any(named: 'codesign'),
exportOptionsPlist: any(named: 'exportOptionsPlist'),
flavor: any(named: 'flavor'),
target: any(named: 'target'),
),
).thenAnswer((_) async => {});
when(
() => artifactManager.getIosAppDirectory(
xcarchiveDirectory: any(named: 'xcarchiveDirectory'),
),
).thenReturn(iosAppDirectory);
when(() => artifactManager.getXcarchiveDirectory())
.thenReturn(xcarchiveDirectory);
when(
() => ios.exportOptionsPlistFromArgs(argResults),
).thenReturn(File(''));
when(() => shorebirdEnv.getShorebirdProjectRoot())
.thenReturn(projectRoot);
when(
() => shorebirdFlutter.getVersionAndRevision(),
).thenAnswer((_) async => flutterVersionAndRevision);
});
group('when not codesigning', () {
setUp(() {
when(() => argResults['codesign']).thenReturn(false);
});
test('logs warning about patching', () async {
await runWithOverrides(iosReleaser.buildReleaseArtifacts);
verify(
() => logger.info(
'''Building for device with codesigning disabled. You will have to manually codesign before deploying to device.''',
),
).called(1);
verify(
() => logger.warn(
'''shorebird preview will not work for releases created with "--no-codesign". However, you can still preview your app by signing the generated .xcarchive in Xcode.''',
),
).called(1);
});
});
group('when export options plist fails to generate', () {
const error = 'error';
setUp(() {
when(
() => ios.exportOptionsPlistFromArgs(argResults),
).thenThrow(error);
});
test('logs error and exits with code 64', () async {
await expectLater(
() => runWithOverrides(iosReleaser.buildReleaseArtifacts),
exitsWithCode(ExitCode.usage),
);
verify(
() => logger.err(error),
).called(1);
});
});
group('when build fails', () {
setUp(() {
when(
() => artifactBuilder.buildIpa(
codesign: any(named: 'codesign'),
exportOptionsPlist: any(named: 'exportOptionsPlist'),
flavor: any(named: 'flavor'),
target: any(named: 'target'),
),
).thenThrow(ArtifactBuildException('Failed to build'));
});
test('logs error and exits with code 70', () async {
await expectLater(
() => runWithOverrides(iosReleaser.buildReleaseArtifacts),
exitsWithCode(ExitCode.software),
);
verify(
() => progress.fail('Failed to build'),
).called(1);
});
});
group('when build succeeds', () {
test('verifies artifacts exist and returns xcarchive path', () async {
expect(
await runWithOverrides(iosReleaser.buildReleaseArtifacts),
equals(xcarchiveDirectory),
);
verify(() => artifactManager.getXcarchiveDirectory()).called(1);
verify(
() => artifactManager.getIosAppDirectory(
xcarchiveDirectory: xcarchiveDirectory,
),
).called(1);
});
});
group('when xcarchive not found after build', () {
setUp(() {
when(() => artifactManager.getXcarchiveDirectory())
.thenReturn(null);
});
test('logs message and exits with code 70', () async {
await expectLater(
() => runWithOverrides(iosReleaser.buildReleaseArtifacts),
exitsWithCode(ExitCode.software),
);
verify(
() => logger.err('Unable to find .xcarchive directory'),
).called(1);
});
});
group('when app not found after build', () {
setUp(() {
when(
() => artifactManager.getIosAppDirectory(
xcarchiveDirectory: any(named: 'xcarchiveDirectory'),
),
).thenReturn(null);
});
test('logs message and exits with code 70', () async {
await expectLater(
() => runWithOverrides(iosReleaser.buildReleaseArtifacts),
exitsWithCode(ExitCode.software),
);
verify(
() => logger.err('Unable to find .app directory'),
).called(1);
});
});
});
group('getReleaseVersion', () {
late Directory xcarchiveDirectory;
setUp(() {
xcarchiveDirectory = Directory.systemTemp.createTempSync();
});
group('when plist does not exist', () {
test('logs error and exits', () async {
await expectLater(
() => runWithOverrides(
() => iosReleaser.getReleaseVersion(
releaseArtifactRoot: xcarchiveDirectory,
),
),
exitsWithCode(ExitCode.software),
);
verify(
() => logger.err(
'''No Info.plist file found at ${p.join(xcarchiveDirectory.path, 'Info.plist')}''',
),
).called(1);
});
});
group('when plist does not contain version number', () {
late File plist;
setUp(() {
plist = File(p.join(xcarchiveDirectory.path, 'Info.plist'))
..createSync()
..writeAsStringSync(
'''
<?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>ApplicationProperties</key>
<dict>
</dict>
</dict>
</plist>'
''',
);
});
test('logs error and exits', () async {
await expectLater(
() => runWithOverrides(
() => iosReleaser.getReleaseVersion(
releaseArtifactRoot: xcarchiveDirectory,
),
),
exitsWithCode(ExitCode.software),
);
verify(
() => logger.err(
any(
that: startsWith(
'Failed to determine release version from ${plist.path}',
),
),
),
).called(1);
});
});
group('when plist contains version number', () {
setUp(() {
File(p.join(xcarchiveDirectory.path, 'Info.plist'))
..createSync()
..writeAsStringSync(
'''
<?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>ApplicationProperties</key>
<dict>
<key>ApplicationPath</key>
<string>Applications/Runner.app</string>
<key>Architectures</key>
<array>
<string>arm64</string>
</array>
<key>CFBundleIdentifier</key>
<string>com.shorebird.timeShift</string>
<key>CFBundleShortVersionString</key>
<string>1.2.3</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
<key>ArchiveVersion</key>
<integer>2</integer>
<key>Name</key>
<string>Runner</string>
<key>SchemeName</key>
<string>Runner</string>
</dict>
</plist>''',
);
});
test('returns version number from plist', () async {
expect(
await runWithOverrides(
() => iosReleaser.getReleaseVersion(
releaseArtifactRoot: xcarchiveDirectory,
),
),
equals('1.2.3+1'),
);
});
});
});
group('uploadReleaseArtifacts', () {
const appId = 'appId';
const releaseVersion = '1.0.0';
const flutterRevision = 'deadbeef';
const codesign = true;
final release = Release(
id: 42,
appId: appId,
version: releaseVersion,
flutterRevision: flutterRevision,
displayName: '1.2.3+1',
platformStatuses: {},
createdAt: DateTime(2023),
updatedAt: DateTime(2023),
);
late Directory xcarchiveDirectory;
late Directory iosAppDirectory;
setUp(() {
when(() => argResults['codesign']).thenReturn(codesign);
xcarchiveDirectory = Directory.systemTemp.createTempSync();
iosAppDirectory = Directory.systemTemp.createTempSync();
when(artifactManager.getXcarchiveDirectory)
.thenReturn(xcarchiveDirectory);
when(
() => artifactManager.getIosAppDirectory(
xcarchiveDirectory: any(named: 'xcarchiveDirectory'),
),
).thenReturn(iosAppDirectory);
when(
() => codePushClientWrapper.createIosReleaseArtifacts(
appId: any(named: 'appId'),
releaseId: any(named: 'releaseId'),
xcarchivePath: any(named: 'xcarchivePath'),
runnerPath: any(named: 'runnerPath'),
isCodesigned: any(named: 'isCodesigned'),
),
).thenAnswer((_) async => {});
});
test('forwards call to codePushClientWrapper', () async {
await runWithOverrides(
() => iosReleaser.uploadReleaseArtifacts(
release: release,
appId: appId,
),
);
verify(
() => codePushClientWrapper.createIosReleaseArtifacts(
appId: appId,
releaseId: release.id,
xcarchivePath: xcarchiveDirectory.path,
runnerPath: iosAppDirectory.path,
isCodesigned: codesign,
),
).called(1);
});
});
group('releaseMetadata', () {
const operatingSystem = 'macOS';
const operatingSystemVersion = '11.0.0';
const xcodeVersion = '123';
const flutterVersionOverride = '1.2.3';
setUp(() {
when(() => platform.operatingSystem).thenReturn(operatingSystem);
when(() => platform.operatingSystemVersion)
.thenReturn(operatingSystemVersion);
when(() => xcodeBuild.version())
.thenAnswer((_) async => xcodeVersion);
when(() => argResults['flutter-version'])
.thenReturn(flutterVersionOverride);
});
test('returns expected metadata', () async {
expect(
await runWithOverrides(iosReleaser.releaseMetadata),
const UpdateReleaseMetadata(
releasePlatform: ReleasePlatform.ios,
flutterVersionOverride: flutterVersionOverride,
generatedApks: false,
environment: BuildEnvironmentMetadata(
operatingSystem: operatingSystem,
operatingSystemVersion: operatingSystemVersion,
shorebirdVersion: packageVersion,
xcodeVersion: xcodeVersion,
),
),
);
});
});
group('postReleaseInstructions', () {
late Directory xcarchiveDirectory;
setUp(() {
xcarchiveDirectory = Directory.systemTemp.createTempSync();
when(() => artifactManager.getXcarchiveDirectory())
.thenReturn(xcarchiveDirectory);
});
group('when codesigning', () {
setUp(() {
when(() => argResults['codesign']).thenReturn(true);
});
group('when no ipa found', () {
test('logs error and exits', () async {
await expectLater(
() => runWithOverrides(
() => iosReleaser.postReleaseInstructions,
),
exitsWithCode(ExitCode.software),
);
verify(
() => logger.err('Could not find ipa file'),
).called(1);
});
});
group('when ipa found', () {
late File ipa;
setUp(() {
final tempDir = Directory.systemTemp.createTempSync();
ipa = File(p.join(tempDir.path, 'ipa.ipa'))..createSync();
when(() => artifactManager.getIpa()).thenReturn(ipa);
});
test('prints ipa upload steps', () {
expect(
runWithOverrides(() => iosReleaser.postReleaseInstructions),
equals('''
Your next step is to upload your app to App Store Connect.
To upload to the App Store, do one of the following:
1. Open ${lightCyan.wrap(p.relative(xcarchiveDirectory.path))} in Xcode and use the "Distribute App" flow.
2. Drag and drop the ${lightCyan.wrap(p.relative(ipa.path))} bundle into the Apple Transporter macOS app (https://apps.apple.com/us/app/transporter/id1450874784).
3. Run ${lightCyan.wrap('xcrun altool --upload-app --type ios -f ${p.relative(ipa.path)} --apiKey your_api_key --apiIssuer your_issuer_id')}.
See "man altool" for details about how to authenticate with the App Store Connect API key.
'''),
);
});
});
});
group('when not codesigning', () {
setUp(() {
when(() => argResults['codesign']).thenReturn(false);
});
test('prints xcarchive upload steps', () {
expect(
runWithOverrides(() => iosReleaser.postReleaseInstructions),
equals(
'''
Your next step is to submit the archive at ${lightCyan.wrap(p.relative(xcarchiveDirectory.path))} to the App Store using Xcode.
You can open the archive in Xcode by running:
${lightCyan.wrap('open ${p.relative(xcarchiveDirectory.path)}')}
${styleBold.wrap('Make sure to uncheck "Manage Version and Build Number", or else shorebird will not work.')}
''',
),
);
});
});
});
},
testOn: 'mac-os',
);
}
@@ -3,11 +3,8 @@ import 'package:mason_logger/mason_logger.dart';
import 'package:mocktail/mocktail.dart';
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/commands/release_new/aar_releaser.dart';
import 'package:shorebird_cli/src/commands/release_new/android_releaser.dart';
import 'package:shorebird_cli/src/commands/release_new/release_new_command.dart';
import 'package:shorebird_cli/src/commands/release_new/release_new.dart';
import 'package:shorebird_cli/src/commands/release_new/release_type.dart';
import 'package:shorebird_cli/src/commands/release_new/releaser.dart';
import 'package:shorebird_cli/src/config/config.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
@@ -139,8 +136,8 @@ void main() {
when(() => releaser.postReleaseInstructions)
.thenReturn(postReleaseInstructions);
when(() => releaser.releaseType).thenReturn(ReleaseType.android);
when(() => releaser.releaseMetadata)
.thenReturn(UpdateReleaseMetadata.forTest());
when(() => releaser.releaseMetadata())
.thenAnswer((_) async => UpdateReleaseMetadata.forTest());
when(() => releaser.requiresReleaseVersionArg).thenReturn(false);
when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(shorebirdYaml);
@@ -189,8 +186,8 @@ void main() {
isA<AarReleaser>(),
);
expect(
() => command.getReleaser(ReleaseType.ios),
throwsA(isA<UnimplementedError>()),
command.getReleaser(ReleaseType.ios),
isA<IosReleaser>(),
);
expect(
() => command.getReleaser(ReleaseType.iosFramework),
@@ -215,7 +212,9 @@ void main() {
release: release,
appId: appId,
),
() => logger.success('✅ Published Release ${release.version}!'),
() => logger.success('''
✅ Published Release ${release.version}!'''),
() => logger.info(postReleaseInstructions),
() => logger.info(
'''To create a patch for this release, run ${lightCyan.wrap('shorebird patch --platform=android --release-version=${release.version}')}''',
@@ -275,7 +274,11 @@ Note: ${lightCyan.wrap('shorebird patch --platform=android')} without the --rele
release: release,
appId: appId,
),
() => logger.success('✅ Published Release ${release.version}!'),
() => logger.success(
'''
✅ Published Release ${release.version}!''',
),
() => logger.info(postReleaseInstructions),
() => logger.info(
'''To create a patch for this release, run ${lightCyan.wrap('shorebird patch --platform=android --flavor=$flavor --target=$target --release-version=${release.version}')}''',
@@ -46,7 +46,7 @@ class FakeReleaser extends Releaser {
String get postReleaseInstructions => throw UnimplementedError();
@override
UpdateReleaseMetadata get releaseMetadata => throw UnimplementedError();
Future<UpdateReleaseMetadata> releaseMetadata() => throw UnimplementedError();
@override
ReleaseType get releaseType => throw UnimplementedError();
@@ -11,6 +11,13 @@ import 'package:test/test.dart';
import '../mocks.dart';
void main() {
group(InvalidExportOptionsPlistException, () {
test('toString', () {
final exception = InvalidExportOptionsPlistException('message');
expect(exception.toString(), 'message');
});
});
group(Ios, () {
late Ios ios;