refactor(shorebird_cli): remove old release commands (#2051)

This commit is contained in:
Bryan Oltman
2024-05-09 13:29:17 -04:00
committed by GitHub
parent d94182ae53
commit 92a81adf36
31 changed files with 510 additions and 5364 deletions
@@ -138,9 +138,6 @@ class IosArchiveDiffer extends ArchiveDiffer {
if (Platform.isMacOS) {
Process.runSync('assetutil', ['--info', outPath, '-o', assetInfoPath]);
} else {
// This is just for testing
File(assetInfoPath).createSync(recursive: true);
}
// Remove the timestamp line from the json file
@@ -7,7 +7,6 @@ import 'package:mason_logger/mason_logger.dart';
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/commands/commands.dart';
import 'package:shorebird_cli/src/commands/patch_new/patch_new.dart';
import 'package:shorebird_cli/src/commands/release_new/release_new.dart';
import 'package:shorebird_cli/src/engine_config.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/platform.dart';
@@ -78,7 +77,6 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner<int> {
addCommand(PatchNewCommand());
addCommand(PreviewCommand());
addCommand(ReleaseCommand());
addCommand(ReleaseNewCommand());
addCommand(RunCommand());
addCommand(UpgradeCommand());
}
@@ -4,7 +4,7 @@ import 'package:mason_logger/mason_logger.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/artifact_builder.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/commands/release_new/releaser.dart';
import 'package:shorebird_cli/src/commands/release/releaser.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/platform.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
@@ -1,8 +1,8 @@
import 'package:mason_logger/mason_logger.dart';
import 'package:shorebird_cli/src/artifact_builder.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/commands/release_new/release_new.dart';
import 'package:shorebird_cli/src/commands/release_new/releaser.dart';
import 'package:shorebird_cli/src/commands/release/release.dart';
import 'package:shorebird_cli/src/commands/release/releaser.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/platform.dart';
@@ -5,7 +5,7 @@ import 'package:platform/platform.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/releaser.dart';
import 'package:shorebird_cli/src/commands/release/releaser.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/executables/xcodebuild.dart';
import 'package:shorebird_cli/src/logger.dart';
@@ -1,11 +1,12 @@
import 'package:mason_logger/mason_logger.dart';
import 'package:path/path.dart' as p;
import 'package:platform/platform.dart';
import 'package:pub_semver/pub_semver.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/releaser.dart';
import 'package:shorebird_cli/src/commands/release/releaser.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/executables/xcodebuild.dart';
import 'package:shorebird_cli/src/logger.dart';
@@ -61,6 +62,17 @@ class IosReleaser extends Releaser {
} on PreconditionFailedException catch (e) {
exit(e.exitCode.code);
}
final flutterVersionArg = argResults['flutter-version'] as String?;
if (flutterVersionArg != null) {
if (Version.parse(flutterVersionArg) <
minimumSupportedIosFlutterVersion) {
logger.err(
'''iOS releases are not supported with Flutter versions older than $minimumSupportedIosFlutterVersion.''',
);
exit(ExitCode.usage.code);
}
}
}
@override
@@ -1,5 +1,6 @@
export 'release_aar_command.dart';
export 'release_android_command.dart';
export 'aar_releaser.dart';
export 'android_releaser.dart';
export 'ios_framework_releaser.dart';
export 'ios_releaser.dart';
export 'release_command.dart';
export 'release_ios_command.dart';
export 'release_ios_framework_command.dart';
export 'releaser.dart';
@@ -1,324 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:archive/archive_io.dart';
import 'package:io/io.dart' show copyPath;
import 'package:mason_logger/mason_logger.dart';
import 'package:path/path.dart' as p;
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/commands/release/release.dart';
import 'package:shorebird_cli/src/config/config.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/platform.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/shorebird_artifact_mixin.dart';
import 'package:shorebird_cli/src/shorebird_build_mixin.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_flutter.dart';
import 'package:shorebird_cli/src/shorebird_release_version_mixin.dart';
import 'package:shorebird_cli/src/shorebird_validator.dart';
import 'package:shorebird_cli/src/version.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
/// {@template release_aar_command}
/// `shorebird release aar`
/// Create new Android archive releases.
/// {@endtemplate}
class ReleaseAarCommand extends ShorebirdCommand
with
ShorebirdBuildMixin,
ShorebirdReleaseVersionMixin,
ShorebirdArtifactMixin {
/// {@macro release_aar_command}
ReleaseAarCommand({
UnzipFn? unzipFn,
}) : _unzipFn = unzipFn ?? extractFileToDisk {
argParser
..addOption(
'release-version',
help: '''
The version of the associated release (e.g. "1.0.0"). This should be the version
of the Android app that is using this module.''',
mandatory: true,
)
// `flutter build aar` defaults to a build number of 1.0, so we do the
// same.
..addOption(
'build-number',
help: 'The build number of the aar',
defaultsTo: '1.0',
)
..addOption(
'flutter-version',
help: 'The Flutter version to use when building the app (e.g: 3.16.3).',
)
..addMultiOption(
'target-platform',
help: 'The target platform(s) for which the app is compiled.',
defaultsTo: Arch.values.map((arch) => arch.targetPlatformCliArg),
allowed: Arch.values.map((arch) => arch.targetPlatformCliArg),
);
}
@override
String get name => 'aar';
@override
String get description => '''
Builds and submits your Android archive to Shorebird.
Shorebird saves the compiled Dart code from your application in order to
make smaller updates to your app.
''';
final UnzipFn _unzipFn;
@override
Future<int> run() async {
try {
await shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: true,
checkShorebirdInitialized: true,
);
} on PreconditionFailedException catch (e) {
return e.exitCode.code;
}
if (shorebirdEnv.androidPackageName == null) {
logger.err('Could not find androidPackage in pubspec.yaml.');
return ExitCode.config.code;
}
const releasePlatform = ReleasePlatform.android;
final buildNumber = results['build-number'] as String;
final releaseVersion = results['release-version'] as String;
final flutterVersion = results['flutter-version'] as String?;
final shorebirdYaml = shorebirdEnv.getShorebirdYaml()!;
final appId = shorebirdYaml.getAppId();
final app = await codePushClientWrapper.getApp(appId: appId);
final architectures = (results['target-platform'] as List<String>)
.map(
(platform) => AndroidArch.availableAndroidArchs
.firstWhere((arch) => arch.targetPlatformCliArg == platform),
)
.toSet();
final existingRelease = await codePushClientWrapper.maybeGetRelease(
appId: appId,
releaseVersion: releaseVersion,
);
if (existingRelease != null) {
codePushClientWrapper.ensureReleaseIsNotActive(
release: existingRelease,
platform: releasePlatform,
);
}
var flutterRevisionForRelease = shorebirdEnv.flutterRevision;
if (flutterVersion != null) {
final String? revision;
try {
revision = await shorebirdFlutter.getRevisionForVersion(
flutterVersion,
);
} catch (error) {
logger.err(
'''
Unable to determine revision for Flutter version: $flutterVersion.
$error''',
);
return ExitCode.software.code;
}
if (revision == null) {
final openIssueLink = link(
uri: Uri.parse(
'https://github.com/shorebirdtech/shorebird/issues/new?assignees=&labels=feature&projects=&template=feature_request.md&title=feat%3A+',
),
message: 'open an issue',
);
logger.err('''
Version $flutterVersion not found. Please $openIssueLink to request a new version.
Use `shorebird flutter versions list` to list available versions.
''');
return ExitCode.software.code;
}
flutterRevisionForRelease = revision;
}
try {
await shorebirdFlutter.installRevision(
revision: flutterRevisionForRelease,
);
} catch (_) {
return ExitCode.software.code;
}
final releaseFlutterShorebirdEnv = shorebirdEnv.copyWith(
flutterRevisionOverride: flutterRevisionForRelease,
);
return await runScoped(
() async {
final flutterVersionString =
await shorebirdFlutter.getVersionAndRevision();
final buildProgress = logger.progress(
'Building release with Flutter $flutterVersionString',
);
try {
await buildAar(
buildNumber: buildNumber,
targetPlatforms: architectures,
);
} on ProcessException catch (error) {
buildProgress.fail('Failed to build: ${error.message}');
return ExitCode.software.code;
}
buildProgress.complete();
final archNames = architectures.map((arch) => arch.name);
final summary = [
'''📱 App: ${lightCyan.wrap(app.displayName)} ${lightCyan.wrap('(${app.appId})')}''',
'📦 Release Version: ${lightCyan.wrap(releaseVersion)}',
'''🕹️ Platform: ${lightCyan.wrap(releasePlatform.name)} ${lightCyan.wrap('(${archNames.join(', ')})')}''',
'🐦 Flutter Version: ${lightCyan.wrap(flutterVersionString)}',
];
logger.info('''
${styleBold.wrap(lightGreen.wrap('🚀 Ready to create a new release!'))}
${summary.join('\n')}
''');
if (shorebirdEnv.canAcceptUserInput) {
final confirm = logger.confirm('Would you like to continue?');
if (!confirm) {
logger.info('Aborting.');
return ExitCode.success.code;
}
}
final Release release;
if (existingRelease != null) {
release = existingRelease;
await codePushClientWrapper.updateReleaseStatus(
appId: appId,
releaseId: release.id,
platform: releasePlatform,
status: ReleaseStatus.draft,
);
} else {
release = await codePushClientWrapper.createRelease(
appId: appId,
version: releaseVersion,
flutterRevision: shorebirdEnv.flutterRevision,
platform: releasePlatform,
);
}
// Copy release AAR to a new directory to avoid overwriting with
// subsequent patch builds.
final sourceLibraryDirectory = Directory(aarLibraryPath);
final targetLibraryDirectory = Directory(
p.join(shorebirdEnv.getShorebirdProjectRoot()!.path, 'release'),
);
await copyPath(
sourceLibraryDirectory.path,
targetLibraryDirectory.path,
);
final extractAarProgress = logger.progress('Creating artifacts');
final extractedAarDir = await extractAar(
packageName: shorebirdEnv.androidPackageName!,
buildNumber: buildNumber,
unzipFn: _unzipFn,
);
extractAarProgress.complete();
await codePushClientWrapper.createAndroidArchiveReleaseArtifacts(
appId: app.appId,
releaseId: release.id,
platform: releasePlatform,
aarPath: aarArtifactPath(
packageName: shorebirdEnv.androidPackageName!,
buildNumber: buildNumber,
),
extractedAarDir: extractedAarDir,
architectures: architectures,
);
await codePushClientWrapper.updateReleaseStatus(
appId: app.appId,
releaseId: release.id,
platform: releasePlatform,
status: ReleaseStatus.active,
metadata: UpdateReleaseMetadata(
releasePlatform: releasePlatform,
flutterVersionOverride: flutterVersion,
generatedApks: false,
environment: BuildEnvironmentMetadata(
operatingSystem: platform.operatingSystem,
operatingSystemVersion: platform.operatingSystemVersion,
shorebirdVersion: packageVersion,
xcodeVersion: null,
),
),
);
logger
..success('\n✅ Published Release ${release.version}!')
..info('''
Your next steps:
1. Add the aar repo and Shorebird's maven url to your app's settings.gradle:
Note: The maven url needs to be a relative path from your settings.gradle file to the aar library. The code below assumes your Flutter module is in a sibling directory of your Android app.
${lightCyan.wrap('''
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
+ maven {
+ url '../${p.basename(shorebirdEnv.getShorebirdProjectRoot()!.path)}/${p.relative(targetLibraryDirectory.path)}'
+ }
+ maven {
- url 'https://storage.googleapis.com/download.flutter.io'
+ url 'https://download.shorebird.dev/download.flutter.io'
+ }
}
}
''')}
2. Add this module as a dependency in your app's build.gradle:
${lightCyan.wrap('''
dependencies {
// ...
releaseImplementation '${shorebirdEnv.androidPackageName}:flutter_release:$buildNumber'
// ...
}''')}
''');
ReleaseCommand.printPatchInstructions(
name: name,
releaseVersion: release.version,
requiresReleaseVersion: true,
);
return ExitCode.success.code;
},
values: {
shorebirdEnvRef.overrideWith(() => releaseFlutterShorebirdEnv),
},
);
}
}
@@ -1,343 +0,0 @@
import 'dart:io';
import 'package:mason_logger/mason_logger.dart';
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/commands/release/release.dart';
import 'package:shorebird_cli/src/config/shorebird_yaml.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/extensions/arg_results.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/platform.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/shorebird_build_mixin.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_flutter.dart';
import 'package:shorebird_cli/src/shorebird_release_version_mixin.dart';
import 'package:shorebird_cli/src/shorebird_validator.dart';
import 'package:shorebird_cli/src/version.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
/// {@template release_android_command}
/// `shorebird release android`
/// Create new app releases for Android.
/// {@endtemplate}
class ReleaseAndroidCommand extends ShorebirdCommand
with ShorebirdBuildMixin, ShorebirdReleaseVersionMixin {
/// {@macro release_android_command}
ReleaseAndroidCommand() {
argParser
..addOption(
'target',
abbr: 't',
help: 'The main entrypoint file of the application.',
)
..addOption(
'flavor',
help: 'The product flavor to use when building the app.',
)
..addOption(
'artifact',
help: 'They type of artifact to generate.',
allowed: ['aab', 'apk'],
defaultsTo: 'aab',
allowedHelp: {
'aab': 'Android App Bundle',
'apk': 'Android Package Kit',
},
)
..addOption(
'flutter-version',
help: 'The Flutter version to use when building the app (e.g: 3.16.3).',
)
..addFlag(
'split-per-abi',
help: 'Whether to split the APKs per ABIs. '
'To learn more, see: https://developer.android.com/studio/build/configure-apk-splits#configure-abi-split',
hide: true,
negatable: false,
)
..addMultiOption(
'target-platform',
help: 'The target platform(s) for which the app is compiled.',
defaultsTo: Arch.values.map((arch) => arch.targetPlatformCliArg),
allowed: Arch.values.map((arch) => arch.targetPlatformCliArg),
);
}
@override
String get description => '''
Builds and submits your Android app to Shorebird.
Shorebird saves the compiled Dart code from your application in order to
make smaller updates to your app.
''';
@override
String get name => 'android';
@override
Future<int> run() async {
try {
await shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: true,
checkShorebirdInitialized: true,
validators: doctor.androidCommandValidators,
);
} on PreconditionFailedException catch (e) {
return e.exitCode.code;
}
const releasePlatform = ReleasePlatform.android;
final flavor = results.findOption('flavor', argParser: argParser);
final target = results.findOption('target', argParser: argParser);
final generateApk = results['artifact'] as String == 'apk';
final splitApk = results['split-per-abi'] == true;
final flutterVersion = results['flutter-version'] as String?;
final architectures = (results['target-platform'] as List<String>)
.map(
(platform) => AndroidArch.availableAndroidArchs
.firstWhere((arch) => arch.targetPlatformCliArg == platform),
)
.toSet();
if (generateApk && splitApk) {
logger
..err(
'Shorebird does not support the split-per-abi option at this time',
)
..info(
'''
Split APKs are each given a different release version than what is specified in the pubspec.yaml.
See ${link(uri: Uri.parse('https://github.com/flutter/flutter/issues/39817'))} for more information about this issue.
Please comment and upvote ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebird/issues/1141'))} if you would like shorebird to support this.''',
);
return ExitCode.unavailable.code;
}
var flutterRevisionForRelease = shorebirdEnv.flutterRevision;
if (flutterVersion != null) {
final String? revision;
try {
revision = await shorebirdFlutter.getRevisionForVersion(
flutterVersion,
);
} catch (error) {
logger.err(
'''
Unable to determine revision for Flutter version: $flutterVersion.
$error''',
);
return ExitCode.software.code;
}
if (revision == null) {
final openIssueLink = link(
uri: Uri.parse(
'https://github.com/shorebirdtech/shorebird/issues/new?assignees=&labels=feature&projects=&template=feature_request.md&title=feat%3A+',
),
message: 'open an issue',
);
logger.err('''
Version $flutterVersion not found. Please $openIssueLink to request a new version.
Use `shorebird flutter versions list` to list available versions.
''');
return ExitCode.software.code;
}
flutterRevisionForRelease = revision;
}
try {
await shorebirdFlutter.installRevision(
revision: flutterRevisionForRelease,
);
} catch (_) {
return ExitCode.software.code;
}
final releaseFlutterShorebirdEnv = shorebirdEnv.copyWith(
flutterRevisionOverride: flutterRevisionForRelease,
);
return await runScoped(
() async {
final flutterVersionString =
await shorebirdFlutter.getVersionAndRevision();
final buildProgress = logger.progress(
'Building release with Flutter $flutterVersionString',
);
late final File apkFile;
final File aabFile;
try {
aabFile = await buildAppBundle(
flavor: flavor,
target: target,
targetPlatforms: architectures,
);
if (generateApk) {
apkFile = await buildApk(
flavor: flavor,
target: target,
targetPlatforms: architectures,
);
}
} on BuildException catch (error) {
buildProgress.fail(error.message);
return ExitCode.software.code;
}
buildProgress.complete();
final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!;
final shorebirdYaml = shorebirdEnv.getShorebirdYaml()!;
final appId = shorebirdYaml.getAppId(flavor: flavor);
final app = await codePushClientWrapper.getApp(appId: appId);
final String releaseVersion;
final detectReleaseVersionProgress = logger.progress(
'Detecting release version',
);
try {
releaseVersion = await extractReleaseVersionFromAppBundle(
aabFile.path,
);
detectReleaseVersionProgress.complete();
} catch (error) {
detectReleaseVersionProgress.fail('$error');
return ExitCode.software.code;
}
final existingRelease = await codePushClientWrapper.maybeGetRelease(
appId: appId,
releaseVersion: releaseVersion,
);
if (existingRelease != null) {
codePushClientWrapper.ensureReleaseIsNotActive(
release: existingRelease,
platform: releasePlatform,
);
// All artifacts associated with a given release must be built
// with the same Flutter revision.
if (existingRelease.flutterRevision != flutterRevisionForRelease) {
ReleaseCommand.printConflictingFlutterRevisionError(
existingFlutterRevision: existingRelease.flutterRevision,
currentFlutterRevision: flutterRevisionForRelease,
releaseVersion: releaseVersion,
);
return ExitCode.software.code;
}
}
final archNames = architectures.map((a) => a.name);
final summary = [
'''📱 App: ${lightCyan.wrap(app.displayName)} ${lightCyan.wrap('(${app.appId})')}''',
if (flavor != null) '🍧 Flavor: ${lightCyan.wrap(flavor)}',
'📦 Release Version: ${lightCyan.wrap(releaseVersion)}',
'''🕹️ Platform: ${lightCyan.wrap(releasePlatform.name)} ${lightCyan.wrap('(${archNames.join(', ')})')}''',
'🐦 Flutter Version: ${lightCyan.wrap(flutterVersionString)}',
];
logger.info('''
${styleBold.wrap(lightGreen.wrap('🚀 Ready to create a new release!'))}
${summary.join('\n')}
''');
if (shorebirdEnv.canAcceptUserInput) {
final confirm = logger.confirm('Would you like to continue?');
if (!confirm) {
logger.info('Aborting.');
return ExitCode.success.code;
}
}
final Release release;
if (existingRelease != null) {
release = existingRelease;
await codePushClientWrapper.updateReleaseStatus(
appId: appId,
releaseId: release.id,
platform: releasePlatform,
status: ReleaseStatus.draft,
);
} else {
release = await codePushClientWrapper.createRelease(
appId: appId,
version: releaseVersion,
flutterRevision: shorebirdEnv.flutterRevision,
platform: releasePlatform,
);
}
await codePushClientWrapper.createAndroidReleaseArtifacts(
appId: app.appId,
releaseId: release.id,
projectRoot: projectRoot.path,
aabPath: aabFile.path,
platform: releasePlatform,
architectures: architectures,
flavor: flavor,
);
await codePushClientWrapper.updateReleaseStatus(
appId: app.appId,
releaseId: release.id,
platform: releasePlatform,
status: ReleaseStatus.active,
metadata: UpdateReleaseMetadata(
releasePlatform: releasePlatform,
flutterVersionOverride: flutterVersion,
generatedApks: generateApk,
environment: BuildEnvironmentMetadata(
operatingSystem: platform.operatingSystem,
operatingSystemVersion: platform.operatingSystemVersion,
shorebirdVersion: packageVersion,
xcodeVersion: null,
),
),
);
// The extra newline before and no newline after is intentional. See
// unit tests for testing of output.
final apkText = generateApk
? '''
Or distribute the apk:
${lightCyan.wrap(apkFile.path)}
'''
: '';
logger
..success('\n✅ Published Release ${release.version}!')
..info('''
Your next step is to upload the app bundle to the Play Store:
${lightCyan.wrap(aabFile.path)}
$apkText
For information on uploading to the Play Store, see:
${link(uri: Uri.parse('https://support.google.com/googleplay/android-developer/answer/9859152?hl=en'))}
''');
ReleaseCommand.printPatchInstructions(
name: name,
flavor: flavor,
target: target,
releaseVersion: release.version,
);
return ExitCode.success.code;
},
values: {
shorebirdEnvRef.overrideWith(() => releaseFlutterShorebirdEnv),
},
);
}
}
@@ -1,58 +1,440 @@
import 'dart:async';
import 'package:mason_logger/mason_logger.dart';
import 'package:meta/meta.dart';
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/cache.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/commands/commands.dart';
import 'package:shorebird_cli/src/commands/release/release.dart';
import 'package:shorebird_cli/src/config/config.dart';
import 'package:shorebird_cli/src/extensions/arg_results.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/release_type.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_flutter.dart';
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
typedef ResolveReleaser = Releaser Function(ReleaseType releaseType);
/// {@template release_command}
/// `shorebird release`
/// Create new app releases.
/// Creates a new app release for the specified platform(s).
/// {@endtemplate}
class ReleaseCommand extends ShorebirdCommand {
/// {@macro release_command}
ReleaseCommand() {
addSubcommand(ReleaseAarCommand());
addSubcommand(ReleaseAndroidCommand());
addSubcommand(ReleaseIosCommand());
addSubcommand(ReleaseIosFrameworkCommand());
ReleaseCommand({ResolveReleaser? resolveReleaser}) {
_resolveReleaser = resolveReleaser ?? getReleaser;
argParser
..addOption(
'target',
abbr: 't',
help: 'The main entrypoint file of the application.',
)
..addOption(
'flavor',
help: 'The product flavor to use when building the app.',
)
..addOption(
'build-number',
help: '''
An identifier used as an internal version number.
Each build must have a unique identifier to differentiate it from previous builds.
It is used to determine whether one build is more recent than another, with higher numbers indicating more recent build.
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,
)
..addFlag(
'dry-run',
abbr: 'n',
negatable: false,
help: 'Validate but do not upload the release.',
)
..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).',
)
..addOption(
'android-artifact',
help:
'''The type of artifact to generate. Only relevant for Android releases.''',
allowed: ['aab', 'apk'],
defaultsTo: 'aab',
allowedHelp: {
'aab': 'Android App Bundle',
'apk': 'Android Package Kit',
},
)
..addMultiOption(
'platform',
abbr: 'p',
help: 'The platform(s) to to build this release for.',
allowed: ReleaseType.values.map((e) => e.cliName).toList(),
// TODO(bryanoltman): uncomment this once https://github.com/dart-lang/args/pull/273 lands
// mandatory: true.
)
..addOption(
'release-version',
help: '''
The version of the associated release (e.g. "1.0.0"). This should be the version
of the iOS app that is using this module.''',
)
..addMultiOption(
'target-platform',
help: 'The target platform(s) for which the app is compiled.',
defaultsTo: Arch.values.map((arch) => arch.targetPlatformCliArg),
allowed: Arch.values.map((arch) => arch.targetPlatformCliArg),
);
}
late final ResolveReleaser _resolveReleaser;
@override
String get description => 'Manage your Shorebird app releases.';
bool get hidden => true;
@override
String get description =>
'Creates a shorebird release for the provided target platforms';
@override
String get name => 'release';
static void printConflictingFlutterRevisionError({
required String existingFlutterRevision,
required String currentFlutterRevision,
required String releaseVersion,
}) {
logger.err(
'''
${styleBold.wrap(lightRed.wrap('A release with version $releaseVersion already exists but was built using a different Flutter revision.'))}
@override
Future<int> run() async {
final releaserFutures =
results.releaseTypes.map(_resolveReleaser).map(createRelease);
Existing release built with: ${lightCyan.wrap(existingFlutterRevision)}
Current release built with: ${lightCyan.wrap(currentFlutterRevision)}
for (final future in releaserFutures) {
await future;
}
return ExitCode.success.code;
}
@visibleForTesting
Releaser getReleaser(ReleaseType releaseType) {
switch (releaseType) {
case ReleaseType.android:
return AndroidReleaser(
argResults: results,
flavor: flavor,
target: target,
);
case ReleaseType.ios:
return IosReleaser(
argResults: results,
flavor: flavor,
target: target,
);
case ReleaseType.iosFramework:
return IosFrameworkReleaser(
argResults: results,
flavor: flavor,
target: target,
);
case ReleaseType.aar:
return AarReleaser(
argResults: results,
flavor: flavor,
target: target,
);
}
}
/// The shorebird app ID for the current project.
String get appId => shorebirdEnv.getShorebirdYaml()!.getAppId(flavor: flavor);
/// The build flavor, if provided.
late String? flavor = results.findOption('flavor', argParser: argParser);
/// The target script, if provided.
late String? target = results.findOption('target', argParser: argParser);
/// The flutter version specified by the user, if any.
late String? flutterVersionArg = results['flutter-version'] as String?;
/// The workflow to create a new release for a Shorebird app.
///
/// Expectations for methods invoked by this command:
/// - They perform their own logging. If an error occurs, they are
/// responsible for properly logging the error, cleaning up running
/// [Progress]es, etc.
/// - They handle their own exceptions and exit with a non-zero exit code if
/// an error occurs *instead of* throwing an exception.
@visibleForTesting
Future<void> createRelease(Releaser releaser) async {
await releaser.assertPreconditions();
await releaser.assertArgsAreValid();
await cache.updateAll();
// This command handles logging, we don't need to provide our own
// progress, error logs, etc.
final app = await codePushClientWrapper.getApp(appId: appId);
final targetFlutterRevision = await resolveTargetFlutterRevision();
try {
await shorebirdFlutter.installRevision(revision: targetFlutterRevision);
} catch (_) {
exit(ExitCode.software.code);
}
final releaseFlutterShorebirdEnv = shorebirdEnv.copyWith(
flutterRevisionOverride: targetFlutterRevision,
);
return await runScoped(
() async {
await cache.updateAll();
final releaseArtifact = await releaser.buildReleaseArtifacts();
final releaseVersion = await releaser.getReleaseVersion(
releaseArtifactRoot: releaseArtifact,
);
// Ensure we can create a release from what we've built.
await ensureVersionIsReleasable(
version: releaseVersion,
flutterRevision: targetFlutterRevision,
releasePlatform: releaser.releaseType.releasePlatform,
);
final dryRun = results['dry-run'] == true;
if (dryRun) {
logger
..info('No issues detected.')
..info('The server may enforce additional checks.');
exit(ExitCode.success.code);
}
// Ask the user to proceed (this is skipped when running via CI).
await confirmCreateRelease(
app: app,
releaseVersion: releaseVersion,
flutterVersion: targetFlutterRevision,
releasePlatform: releaser.releaseType.releasePlatform,
);
final release = await getOrCreateRelease(
version: releaseVersion,
releasePlatform: releaser.releaseType.releasePlatform,
);
await prepareRelease(release: release, releaser: releaser);
await releaser.uploadReleaseArtifacts(release: release, appId: appId);
await finalizeRelease(release: release, releaser: releaser);
logger
..success('''
✅ Published Release ${release.version}!''')
..info(releaser.postReleaseInstructions);
printPatchInstructions(
releaser: releaser,
releaseVersion: releaseVersion,
releaseType: releaser.releaseType,
flavor: flavor,
target: target,
);
},
values: {
shorebirdEnvRef.overrideWith(() => releaseFlutterShorebirdEnv),
},
);
}
/// Determines which Flutter version to use for the release. This will be
/// either the version specified by the user or the version provided by
/// [shorebirdEnv]. Will exit with [ExitCode.software] if the version
/// specified by the user is not found/supported.
Future<String> resolveTargetFlutterRevision() async {
if (flutterVersionArg != null) {
final String? revision;
try {
revision = await shorebirdFlutter.getRevisionForVersion(
flutterVersionArg!,
);
} catch (error) {
logger.err(
'''
Unable to determine revision for Flutter version: $flutterVersionArg.
$error''',
);
exit(ExitCode.software.code);
}
if (revision == null) {
final openIssueLink = link(
uri: Uri.parse(
'https://github.com/shorebirdtech/shorebird/issues/new?assignees=&labels=feature&projects=&template=feature_request.md&title=feat%3A+',
),
message: 'open an issue',
);
logger.err(
'''
Version $flutterVersionArg not found. Please $openIssueLink to request a new version.
Use `shorebird flutter versions list` to list available versions.
''',
);
exit(ExitCode.software.code);
}
return revision;
}
return shorebirdEnv.flutterRevision;
}
/// Asserts that a release with version [version] can be released using
/// flutter revision [flutterRevision]. If a release has already been
/// published with the given [version] for the platform [releasePlatform], or
/// if a release already exists with [version] but was compiled with a
/// different Flutter revision, an error will be thrown.
Future<void> ensureVersionIsReleasable({
required String version,
required String flutterRevision,
required ReleasePlatform releasePlatform,
}) async {
final existingRelease = await codePushClientWrapper.maybeGetRelease(
appId: appId,
releaseVersion: version,
);
if (existingRelease != null) {
codePushClientWrapper.ensureReleaseIsNotActive(
release: existingRelease,
platform: releasePlatform,
);
// All artifacts associated with a given release must be built
// with the same Flutter revision.
if (existingRelease.flutterRevision != flutterRevision) {
logger
..err('''
${styleBold.wrap(lightRed.wrap('A release with version $version already exists but was built using a different Flutter revision.'))}
''')
..info('''
Existing release built with: ${lightCyan.wrap(existingRelease.flutterRevision)}
Current release built with: ${lightCyan.wrap(flutterRevision)}
${styleBold.wrap(lightRed.wrap('All platforms for a given release must be built using the same Flutter revision.'))}
To resolve this issue, you can:
* Re-run the release command with "${lightCyan.wrap('--flutter-version=$existingFlutterRevision')}".
* Re-run the release command with "${lightCyan.wrap('--flutter-version=${existingRelease.flutterRevision}')}".
* Delete the existing release and re-run the release command with the desired Flutter version.
* Bump the release version and re-run the release command with the desired Flutter version.''',
* Bump the release version and re-run the release command with the desired Flutter version.''');
exit(ExitCode.software.code);
}
}
}
/// Prints a confirmation prompt with details about the release to be created.
/// If the user confirms, the release will be created. If the user cancels,
/// the command will exit with a success code. When running in a headless
/// or CI environment, this prompt will print but will not wait for user
/// confirmation.
Future<void> confirmCreateRelease({
required AppMetadata app,
required String releaseVersion,
required String flutterVersion,
required ReleasePlatform releasePlatform,
}) async {
final flutterVersionString = await shorebirdFlutter.getVersionAndRevision();
// TODO(bryanoltman): include archs in the summary for android
// (and other platforms?)
final summary = [
'''📱 App: ${lightCyan.wrap(app.displayName)} ${lightCyan.wrap('(${app.appId})')}''',
if (flavor != null) '🍧 Flavor: ${lightCyan.wrap(flavor)}',
'📦 Release Version: ${lightCyan.wrap(releaseVersion)}',
'🕹️ Platform: ${lightCyan.wrap(releasePlatform.name)}',
'🐦 Flutter Version: ${lightCyan.wrap(flutterVersionString)}',
];
logger.info('''
${styleBold.wrap(lightGreen.wrap('🚀 Ready to create a new release!'))}
${summary.join('\n')}
''');
if (shorebirdEnv.canAcceptUserInput) {
final confirm = logger.confirm('Would you like to continue?');
if (!confirm) {
logger.info('Aborting.');
exit(ExitCode.success.code);
}
}
}
/// Fetches the release with version [version] from the server or creates a
/// new release if none exists.
Future<Release> getOrCreateRelease({
required String version,
required ReleasePlatform releasePlatform,
}) async {
return await codePushClientWrapper.maybeGetRelease(
appId: appId,
releaseVersion: version,
) ??
await codePushClientWrapper.createRelease(
appId: appId,
version: version,
flutterRevision: shorebirdEnv.flutterRevision,
platform: releasePlatform,
);
}
/// Prepares the release by updating the release status to draft.
Future<void> prepareRelease({
required Release release,
required Releaser releaser,
}) async {
await codePushClientWrapper.updateReleaseStatus(
appId: appId,
releaseId: release.id,
platform: releaser.releaseType.releasePlatform,
status: ReleaseStatus.draft,
);
}
static void printPatchInstructions({
required String name,
/// Finalizes the release by updating the status to active.
Future<void> finalizeRelease({
required Release release,
required Releaser releaser,
}) async {
await codePushClientWrapper.updateReleaseStatus(
appId: appId,
releaseId: release.id,
platform: releaser.releaseType.releasePlatform,
status: ReleaseStatus.active,
metadata: await releaser.releaseMetadata(),
);
}
/// Instructions explaining how to patch the release that was just creatd.
void printPatchInstructions({
required Releaser releaser,
required String releaseVersion,
required ReleaseType releaseType,
String? flavor,
String? target,
bool requiresReleaseVersion = false,
}) {
final baseCommand = [
'shorebird patch',
name,
'--platform=${releaseType.cliName}',
if (flavor != null) '--flavor=$flavor',
if (target != null) '--target=$target',
].join(' ');
@@ -60,7 +442,7 @@ To resolve this issue, you can:
'''To create a patch for this release, run ${lightCyan.wrap('$baseCommand --release-version=$releaseVersion')}''',
);
if (!requiresReleaseVersion) {
if (!releaser.requiresReleaseVersionArg) {
logger.info(
'''
@@ -1,389 +0,0 @@
import 'dart:io' hide Platform;
import 'package:mason_logger/mason_logger.dart';
import 'package:path/path.dart' as p;
import 'package:platform/platform.dart';
import 'package:pub_semver/pub_semver.dart';
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/commands/release/release.dart';
import 'package:shorebird_cli/src/config/config.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/executables/xcodebuild.dart';
import 'package:shorebird_cli/src/extensions/arg_results.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/platform.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/shorebird_artifact_mixin.dart';
import 'package:shorebird_cli/src/shorebird_build_mixin.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_flutter.dart';
import 'package:shorebird_cli/src/shorebird_validator.dart';
import 'package:shorebird_cli/src/version.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
/// {@template release_ios_command}
/// `shorebird release ios`
/// Create new app releases for iOS.
/// {@endtemplate}
class ReleaseIosCommand extends ShorebirdCommand
with ShorebirdBuildMixin, ShorebirdArtifactMixin {
/// {@macro release_ios_command}
ReleaseIosCommand() {
argParser
..addOption(
'target',
abbr: 't',
help: 'The main entrypoint file of the application.',
)
..addOption(
'flavor',
help: 'The product flavor to use when building the app.',
)
..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.''',
)
..addOption(
'flutter-version',
help: 'The Flutter version to use when building the app (e.g: 3.19.5).',
)
..addFlag(
'codesign',
help: 'Codesign the application bundle.',
defaultsTo: true,
);
}
@override
String get name => 'ios';
@override
List<String> get aliases => ['ios-alpha'];
@override
String get description => '''
Builds and submits your iOS app to Shorebird.
Shorebird saves the compiled Dart code from your application in order to
make smaller updates to your app.
''';
@override
Future<int> run() async {
try {
await shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: true,
checkShorebirdInitialized: true,
validators: doctor.iosCommandValidators,
supportedOperatingSystems: {Platform.macOS},
);
} on PreconditionFailedException catch (e) {
return e.exitCode.code;
}
if (results.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'))}.''',
);
return ExitCode.usage.code;
}
final codesign = results['codesign'] == true;
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(results);
} catch (error) {
logger.err('$error');
return ExitCode.usage.code;
}
const releasePlatform = ReleasePlatform.ios;
final flavor = results.findOption('flavor', argParser: argParser);
final target = results.findOption('target', argParser: argParser);
final flutterVersion = results.findOption(
'flutter-version',
argParser: argParser,
);
final shorebirdYaml = shorebirdEnv.getShorebirdYaml()!;
final appId = shorebirdYaml.getAppId(flavor: flavor);
final app = await codePushClientWrapper.getApp(appId: appId);
var flutterRevisionForRelease = shorebirdEnv.flutterRevision;
if (flutterVersion != null) {
if (Version.parse(flutterVersion) < minimumSupportedIosFlutterVersion) {
logger.err(
'''iOS releases are not supported with Flutter versions older than $minimumSupportedIosFlutterVersion.''',
);
return ExitCode.usage.code;
}
final String? revision;
try {
revision = await shorebirdFlutter.getRevisionForVersion(
flutterVersion,
);
} catch (error) {
logger.err(
'''
Unable to determine revision for Flutter version: $flutterVersion.
$error''',
);
return ExitCode.software.code;
}
if (revision == null) {
final openIssueLink = link(
uri: Uri.parse(
'https://github.com/shorebirdtech/shorebird/issues/new?assignees=&labels=feature&projects=&template=feature_request.md&title=feat%3A+',
),
message: 'open an issue',
);
logger.err('''
Version $flutterVersion not found. Please $openIssueLink to request a new version.
Use `shorebird flutter versions list` to list available versions.
''');
return ExitCode.software.code;
}
flutterRevisionForRelease = revision;
}
try {
await shorebirdFlutter.installRevision(
revision: flutterRevisionForRelease,
);
} catch (_) {
return ExitCode.software.code;
}
final releaseFlutterShorebirdEnv = shorebirdEnv.copyWith(
flutterRevisionOverride: flutterRevisionForRelease,
);
return await runScoped(
() async {
final flutterVersionString =
await shorebirdFlutter.getVersionAndRevision();
final buildProgress = logger.progress(
'Building release with Flutter $flutterVersionString',
);
try {
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;
} on BuildException catch (error) {
buildProgress.fail('Failed to build');
logger.err(error.message);
return ExitCode.software.code;
}
buildProgress.complete();
final archiveDirectory = getXcarchiveDirectory();
if (archiveDirectory == null) {
logger.err('Unable to find .xcarchive directory');
return ExitCode.software.code;
}
final archivePath = archiveDirectory.path;
final appDirectory =
getAppDirectory(xcarchiveDirectory: archiveDirectory);
if (appDirectory == null) {
logger.err('Unable to find .app directory');
return ExitCode.software.code;
}
final runnerPath = appDirectory.path;
final plistFile = File(p.join(archivePath, 'Info.plist'));
if (!plistFile.existsSync()) {
logger.err('No Info.plist file found at ${plistFile.path}.');
return ExitCode.software.code;
}
final plist = Plist(file: plistFile);
final String releaseVersion;
try {
releaseVersion = plist.versionNumber;
} catch (error) {
logger.err(
'''Failed to determine release version from ${plistFile.path}: $error''',
);
return ExitCode.software.code;
}
final existingRelease = await codePushClientWrapper.maybeGetRelease(
appId: appId,
releaseVersion: releaseVersion,
);
if (existingRelease != null) {
codePushClientWrapper.ensureReleaseIsNotActive(
release: existingRelease,
platform: releasePlatform,
);
// All artifacts associated with a given release must be built
// with the same Flutter revision.
if (existingRelease.flutterRevision != flutterRevisionForRelease) {
ReleaseCommand.printConflictingFlutterRevisionError(
existingFlutterRevision: existingRelease.flutterRevision,
currentFlutterRevision: flutterRevisionForRelease,
releaseVersion: releaseVersion,
);
return ExitCode.software.code;
}
}
final summary = [
'''📱 App: ${lightCyan.wrap(app.displayName)} ${lightCyan.wrap('($appId)')}''',
if (flavor != null) '🍧 Flavor: ${lightCyan.wrap(flavor)}',
'📦 Release Version: ${lightCyan.wrap(releaseVersion)}',
'''🕹️ Platform: ${lightCyan.wrap(releasePlatform.name)}''',
'🐦 Flutter Version: ${lightCyan.wrap(flutterVersionString)}',
];
logger.info('''
${styleBold.wrap(lightGreen.wrap('🚀 Ready to create a new release!'))}
${summary.join('\n')}
''');
if (shorebirdEnv.canAcceptUserInput) {
final confirm = logger.confirm('Would you like to continue?');
if (!confirm) {
logger.info('Aborting.');
return ExitCode.success.code;
}
}
final Release release;
if (existingRelease != null) {
release = existingRelease;
await codePushClientWrapper.updateReleaseStatus(
appId: appId,
releaseId: release.id,
platform: releasePlatform,
status: ReleaseStatus.draft,
);
} else {
release = await codePushClientWrapper.createRelease(
appId: appId,
version: releaseVersion,
flutterRevision: shorebirdEnv.flutterRevision,
platform: releasePlatform,
);
}
await codePushClientWrapper.createIosReleaseArtifacts(
appId: app.appId,
releaseId: release.id,
xcarchivePath: archivePath,
runnerPath: runnerPath,
isCodesigned: codesign,
);
await codePushClientWrapper.updateReleaseStatus(
appId: app.appId,
releaseId: release.id,
platform: releasePlatform,
status: ReleaseStatus.active,
metadata: UpdateReleaseMetadata(
releasePlatform: releasePlatform,
flutterVersionOverride: flutterVersion,
generatedApks: false,
environment: BuildEnvironmentMetadata(
operatingSystem: platform.operatingSystem,
operatingSystemVersion: platform.operatingSystemVersion,
shorebirdVersion: packageVersion,
xcodeVersion: await xcodeBuild.version(),
),
),
);
logger.success('\n✅ Published Release ${release.version}!');
final relativeArchivePath = p.relative(archivePath);
if (codesign) {
// Ensure the ipa was built
final String ipaPath;
try {
ipaPath = getIpaPath();
} catch (error) {
logger.err('Could not find ipa file: $error');
return ExitCode.software.code;
}
final relativeIpaPath = p.relative(ipaPath);
logger.info('''
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 {
logger.info('''
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.')}
''');
}
ReleaseCommand.printPatchInstructions(
name: name,
flavor: flavor,
target: target,
releaseVersion: release.version,
);
return ExitCode.success.code;
},
values: {
shorebirdEnvRef.overrideWith(() => releaseFlutterShorebirdEnv),
},
);
}
}
@@ -1,270 +0,0 @@
import 'dart:io';
import 'package:io/io.dart' show copyPath;
import 'package:mason_logger/mason_logger.dart';
import 'package:path/path.dart' as p;
import 'package:platform/platform.dart';
import 'package:pub_semver/pub_semver.dart';
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/commands/release/release.dart';
import 'package:shorebird_cli/src/config/config.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_artifact_mixin.dart';
import 'package:shorebird_cli/src/shorebird_build_mixin.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_flutter.dart';
import 'package:shorebird_cli/src/shorebird_validator.dart';
import 'package:shorebird_cli/src/version.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
class ReleaseIosFrameworkCommand extends ShorebirdCommand
with ShorebirdArtifactMixin, ShorebirdBuildMixin {
ReleaseIosFrameworkCommand() {
argParser
..addOption(
'release-version',
help: '''
The version of the associated release (e.g. "1.0.0"). This should be the version
of the iOS app that is using this module.''',
mandatory: true,
)
..addOption(
'flutter-version',
help: 'The Flutter version to use when building the app (e.g: 3.16.3).',
);
}
@override
String get name => 'ios-framework';
@override
List<String> get aliases => ['ios-framework-alpha'];
@override
String get description =>
'Builds and submits your iOS framework to Shorebird.';
@override
Future<int> run() async {
try {
await shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: true,
checkShorebirdInitialized: true,
supportedOperatingSystems: {Platform.macOS},
validators: doctor.iosCommandValidators,
);
} on PreconditionFailedException catch (e) {
return e.exitCode.code;
}
const releasePlatform = ReleasePlatform.ios;
final releaseVersion = results['release-version'] as String;
final flutterVersion = results['flutter-version'] as String?;
final shorebirdYaml = shorebirdEnv.getShorebirdYaml()!;
final appId = shorebirdYaml.getAppId();
final app = await codePushClientWrapper.getApp(appId: appId);
final existingRelease = await codePushClientWrapper.maybeGetRelease(
appId: appId,
releaseVersion: releaseVersion,
);
if (existingRelease != null) {
codePushClientWrapper.ensureReleaseIsNotActive(
release: existingRelease,
platform: releasePlatform,
);
}
var flutterRevisionForRelease = shorebirdEnv.flutterRevision;
if (flutterVersion != null) {
if (Version.parse(flutterVersion) < minimumSupportedIosFlutterVersion) {
logger.err(
'''iOS releases are not supported with Flutter versions older than $minimumSupportedIosFlutterVersion.''',
);
return ExitCode.usage.code;
}
final String? revision;
try {
revision = await shorebirdFlutter.getRevisionForVersion(
flutterVersion,
);
} catch (error) {
logger.err(
'''
Unable to determine revision for Flutter version: $flutterVersion.
$error''',
);
return ExitCode.software.code;
}
if (revision == null) {
final openIssueLink = link(
uri: Uri.parse(
'https://github.com/shorebirdtech/shorebird/issues/new?assignees=&labels=feature&projects=&template=feature_request.md&title=feat%3A+',
),
message: 'open an issue',
);
logger.err('''
Version $flutterVersion not found. Please $openIssueLink to request a new version.
Use `shorebird flutter versions list` to list available versions.
''');
return ExitCode.software.code;
}
flutterRevisionForRelease = revision;
}
try {
await shorebirdFlutter.installRevision(
revision: flutterRevisionForRelease,
);
} catch (_) {
return ExitCode.software.code;
}
final releaseFlutterShorebirdEnv = shorebirdEnv.copyWith(
flutterRevisionOverride: flutterRevisionForRelease,
);
return await runScoped(
() async {
final flutterVersionString =
await shorebirdFlutter.getVersionAndRevision();
final buildProgress = logger.progress(
'Building iOS framework with Flutter $flutterVersionString',
);
try {
await buildIosFramework();
} catch (error) {
buildProgress.fail('Failed to build iOS framework: $error');
return ExitCode.software.code;
}
buildProgress.complete();
final summary = [
'''📱 App: ${lightCyan.wrap(app.displayName)} ${lightCyan.wrap('($appId)')}''',
'📦 Release Version: ${lightCyan.wrap(releaseVersion)}',
'''🕹️ Platform: ${lightCyan.wrap(releasePlatform.name)}''',
'🐦 Flutter Version: ${lightCyan.wrap(flutterVersionString)}',
];
logger.info('''
${styleBold.wrap(lightGreen.wrap('🚀 Ready to create a new release!'))}
${summary.join('\n')}
''');
if (shorebirdEnv.canAcceptUserInput) {
final confirm = logger.confirm('Would you like to continue?');
if (!confirm) {
logger.info('Aborting.');
return ExitCode.success.code;
}
}
// Copy release xcframework to a new directory to avoid overwriting with
// subsequent patch builds.
final sourceLibraryDirectory = getAppXcframeworkDirectory();
final targetLibraryDirectory = Directory(
p.join(shorebirdEnv.getShorebirdProjectRoot()!.path, 'release'),
);
if (targetLibraryDirectory.existsSync()) {
targetLibraryDirectory.deleteSync(recursive: true);
}
await copyPath(
sourceLibraryDirectory.path,
targetLibraryDirectory.path,
);
// Rename Flutter.xcframework to ShorebirdFlutter.xcframework to avoid
// Xcode warning users about the .xcframework signature changing.
Directory(
p.join(
targetLibraryDirectory.path,
'Flutter.xcframework',
),
).renameSync(
p.join(
targetLibraryDirectory.path,
'ShorebirdFlutter.xcframework',
),
);
final Release release;
if (existingRelease != null) {
release = existingRelease;
} else {
release = await codePushClientWrapper.createRelease(
appId: appId,
version: releaseVersion,
flutterRevision: shorebirdEnv.flutterRevision,
platform: releasePlatform,
);
}
await codePushClientWrapper.createIosFrameworkReleaseArtifacts(
appId: appId,
releaseId: release.id,
appFrameworkPath:
p.join(targetLibraryDirectory.path, 'App.xcframework'),
);
await codePushClientWrapper.updateReleaseStatus(
appId: app.appId,
releaseId: release.id,
platform: releasePlatform,
status: ReleaseStatus.active,
metadata: UpdateReleaseMetadata(
releasePlatform: releasePlatform,
flutterVersionOverride: flutterVersion,
generatedApks: false,
environment: BuildEnvironmentMetadata(
operatingSystem: platform.operatingSystem,
operatingSystemVersion: platform.operatingSystemVersion,
shorebirdVersion: packageVersion,
xcodeVersion: await xcodeBuild.version(),
),
),
);
final relativeFrameworkDirectoryPath =
p.relative(targetLibraryDirectory.path);
logger
..success('\n✅ Published Release ${release.version}!')
..info('''
Your next step is to add the .xcframework files found in the ${lightCyan.wrap(relativeFrameworkDirectoryPath)} directory to your iOS app.
To do this:
1. Add the relative path to the ${lightCyan.wrap(relativeFrameworkDirectoryPath)} directory to your app's Framework Search Paths in your Xcode build settings.
2. Embed the App.xcframework and ShorebirdFlutter.framework in your Xcode project.
Instructions for these steps can be found at https://docs.flutter.dev/add-to-app/ios/project-setup#option-b---embed-frameworks-in-xcode.
''');
ReleaseCommand.printPatchInstructions(
name: name,
releaseVersion: release.version,
requiresReleaseVersion: true,
);
return ExitCode.success.code;
},
values: {
shorebirdEnvRef.overrideWith(() => releaseFlutterShorebirdEnv),
},
);
}
}
@@ -1,6 +0,0 @@
export 'aar_releaser.dart';
export 'android_releaser.dart';
export 'ios_framework_releaser.dart';
export 'ios_releaser.dart';
export 'release_new_command.dart';
export 'releaser.dart';
@@ -1,454 +0,0 @@
import 'dart:async';
import 'package:mason_logger/mason_logger.dart';
import 'package:meta/meta.dart';
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/cache.dart';
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/release_new.dart';
import 'package:shorebird_cli/src/config/config.dart';
import 'package:shorebird_cli/src/extensions/arg_results.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/release_type.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_flutter.dart';
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
typedef ResolveReleaser = Releaser Function(ReleaseType releaseType);
/// {@template release_command}
/// Creates a new app release for the specified platform(s).
/// {@endtemplate}
class ReleaseNewCommand extends ShorebirdCommand {
/// {@macro release_command}
ReleaseNewCommand({ResolveReleaser? resolveReleaser}) {
_resolveReleaser = resolveReleaser ?? getReleaser;
argParser
..addOption(
'target',
abbr: 't',
help: 'The main entrypoint file of the application.',
)
..addOption(
'flavor',
help: 'The product flavor to use when building the app.',
)
..addOption(
'build-number',
help: '''
An identifier used as an internal version number.
Each build must have a unique identifier to differentiate it from previous builds.
It is used to determine whether one build is more recent than another, with higher numbers indicating more recent build.
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,
)
..addFlag(
'dry-run',
abbr: 'n',
negatable: false,
help: 'Validate but do not upload the release.',
)
..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).',
)
..addOption(
'android-artifact',
help:
'''The type of artifact to generate. Only relevant for Android releases.''',
allowed: ['aab', 'apk'],
defaultsTo: 'aab',
allowedHelp: {
'aab': 'Android App Bundle',
'apk': 'Android Package Kit',
},
)
..addMultiOption(
'platform',
abbr: 'p',
help: 'The platform(s) to to build this release for.',
allowed: ReleaseType.values.map((e) => e.cliName).toList(),
// TODO(bryanoltman): uncomment this once https://github.com/dart-lang/args/pull/273 lands
// mandatory: true.
)
..addOption(
'release-version',
help: '''
The version of the associated release (e.g. "1.0.0"). This should be the version
of the iOS app that is using this module.''',
)
..addMultiOption(
'target-platform',
help: 'The target platform(s) for which the app is compiled.',
defaultsTo: Arch.values.map((arch) => arch.targetPlatformCliArg),
allowed: Arch.values.map((arch) => arch.targetPlatformCliArg),
);
}
late final ResolveReleaser _resolveReleaser;
@override
bool get hidden => true;
@override
String get description =>
'Creates a shorebird release for the provided target platforms';
@override
String get name => 'release-new';
@override
Future<int> run() async {
final releaserFutures =
results.releaseTypes.map(_resolveReleaser).map(createRelease);
for (final future in releaserFutures) {
await future;
}
return ExitCode.success.code;
}
@visibleForTesting
Releaser getReleaser(ReleaseType releaseType) {
switch (releaseType) {
case ReleaseType.android:
return AndroidReleaser(
argResults: results,
flavor: flavor,
target: target,
);
case ReleaseType.ios:
return IosReleaser(
argResults: results,
flavor: flavor,
target: target,
);
case ReleaseType.iosFramework:
return IosFrameworkReleaser(
argResults: results,
flavor: flavor,
target: target,
);
case ReleaseType.aar:
return AarReleaser(
argResults: results,
flavor: flavor,
target: target,
);
}
}
/// The shorebird app ID for the current project.
String get appId => shorebirdEnv.getShorebirdYaml()!.getAppId(flavor: flavor);
/// The build flavor, if provided.
late String? flavor = results.findOption('flavor', argParser: argParser);
/// The target script, if provided.
late String? target = results.findOption('target', argParser: argParser);
/// The flutter version specified by the user, if any.
late String? flutterVersionArg = results['flutter-version'] as String?;
/// The workflow to create a new release for a Shorebird app.
///
/// Expectations for methods invoked by this command:
/// - They perform their own logging. If an error occurs, they are
/// responsible for properly logging the error, cleaning up running
/// [Progress]es, etc.
/// - They handle their own exceptions and exit with a non-zero exit code if
/// an error occurs *instead of* throwing an exception.
@visibleForTesting
Future<void> createRelease(Releaser releaser) async {
await releaser.assertPreconditions();
await releaser.assertArgsAreValid();
await cache.updateAll();
// This command handles logging, we don't need to provide our own
// progress, error logs, etc.
final app = await codePushClientWrapper.getApp(appId: appId);
final targetFlutterRevision = await resolveTargetFlutterRevision();
try {
await shorebirdFlutter.installRevision(revision: targetFlutterRevision);
} catch (_) {
exit(ExitCode.software.code);
}
final releaseFlutterShorebirdEnv = shorebirdEnv.copyWith(
flutterRevisionOverride: targetFlutterRevision,
);
return await runScoped(
() async {
await cache.updateAll();
final releaseArtifact = await releaser.buildReleaseArtifacts();
final releaseVersion = await releaser.getReleaseVersion(
releaseArtifactRoot: releaseArtifact,
);
// Ensure we can create a release from what we've built.
await ensureVersionIsReleasable(
version: releaseVersion,
flutterRevision: targetFlutterRevision,
releasePlatform: releaser.releaseType.releasePlatform,
);
final dryRun = results['dry-run'] == true;
if (dryRun) {
logger
..info('No issues detected.')
..info('The server may enforce additional checks.');
exit(ExitCode.success.code);
}
// Ask the user to proceed (this is skipped when running via CI).
await confirmCreateRelease(
app: app,
releaseVersion: releaseVersion,
flutterVersion: targetFlutterRevision,
releasePlatform: releaser.releaseType.releasePlatform,
);
final release = await getOrCreateRelease(
version: releaseVersion,
releasePlatform: releaser.releaseType.releasePlatform,
);
await prepareRelease(release: release, releaser: releaser);
await releaser.uploadReleaseArtifacts(release: release, appId: appId);
await finalizeRelease(release: release, releaser: releaser);
logger
..success('''
✅ Published Release ${release.version}!''')
..info(releaser.postReleaseInstructions);
printPatchInstructions(
releaser: releaser,
releaseVersion: releaseVersion,
releaseType: releaser.releaseType,
flavor: flavor,
target: target,
);
},
values: {
shorebirdEnvRef.overrideWith(() => releaseFlutterShorebirdEnv),
},
);
}
/// Determines which Flutter version to use for the release. This will be
/// either the version specified by the user or the version provided by
/// [shorebirdEnv]. Will exit with [ExitCode.software] if the version
/// specified by the user is not found/supported.
Future<String> resolveTargetFlutterRevision() async {
if (flutterVersionArg != null) {
final String? revision;
try {
revision = await shorebirdFlutter.getRevisionForVersion(
flutterVersionArg!,
);
} catch (error) {
logger.err(
'''
Unable to determine revision for Flutter version: $flutterVersionArg.
$error''',
);
exit(ExitCode.software.code);
}
if (revision == null) {
final openIssueLink = link(
uri: Uri.parse(
'https://github.com/shorebirdtech/shorebird/issues/new?assignees=&labels=feature&projects=&template=feature_request.md&title=feat%3A+',
),
message: 'open an issue',
);
logger.err(
'''
Version $flutterVersionArg not found. Please $openIssueLink to request a new version.
Use `shorebird flutter versions list` to list available versions.
''',
);
exit(ExitCode.software.code);
}
return revision;
}
return shorebirdEnv.flutterRevision;
}
/// Asserts that a release with version [version] can be released using
/// flutter revision [flutterRevision]. If a release has already been
/// published with the given [version] for the platform [releasePlatform], or
/// if a release already exists with [version] but was compiled with a
/// different Flutter revision, an error will be thrown.
Future<void> ensureVersionIsReleasable({
required String version,
required String flutterRevision,
required ReleasePlatform releasePlatform,
}) async {
final existingRelease = await codePushClientWrapper.maybeGetRelease(
appId: appId,
releaseVersion: version,
);
if (existingRelease != null) {
codePushClientWrapper.ensureReleaseIsNotActive(
release: existingRelease,
platform: releasePlatform,
);
// All artifacts associated with a given release must be built
// with the same Flutter revision.
if (existingRelease.flutterRevision != flutterRevision) {
logger
..err('''
${styleBold.wrap(lightRed.wrap('A release with version $version already exists but was built using a different Flutter revision.'))}
''')
..info('''
Existing release built with: ${lightCyan.wrap(existingRelease.flutterRevision)}
Current release built with: ${lightCyan.wrap(flutterRevision)}
${styleBold.wrap(lightRed.wrap('All platforms for a given release must be built using the same Flutter revision.'))}
To resolve this issue, you can:
* Re-run the release command with "${lightCyan.wrap('--flutter-version=${existingRelease.flutterRevision}')}".
* Delete the existing release and re-run the release command with the desired Flutter version.
* Bump the release version and re-run the release command with the desired Flutter version.''');
exit(ExitCode.software.code);
}
}
}
/// Prints a confirmation prompt with details about the release to be created.
/// If the user confirms, the release will be created. If the user cancels,
/// the command will exit with a success code. When running in a headless
/// or CI environment, this prompt will print but will not wait for user
/// confirmation.
Future<void> confirmCreateRelease({
required AppMetadata app,
required String releaseVersion,
required String flutterVersion,
required ReleasePlatform releasePlatform,
}) async {
final flutterVersionString = await shorebirdFlutter.getVersionAndRevision();
// TODO(bryanoltman): include archs in the summary for android
// (and other platforms?)
final summary = [
'''📱 App: ${lightCyan.wrap(app.displayName)} ${lightCyan.wrap('(${app.appId})')}''',
if (flavor != null) '🍧 Flavor: ${lightCyan.wrap(flavor)}',
'📦 Release Version: ${lightCyan.wrap(releaseVersion)}',
'🕹️ Platform: ${lightCyan.wrap(releasePlatform.name)}',
'🐦 Flutter Version: ${lightCyan.wrap(flutterVersionString)}',
];
logger.info('''
${styleBold.wrap(lightGreen.wrap('🚀 Ready to create a new release!'))}
${summary.join('\n')}
''');
if (shorebirdEnv.canAcceptUserInput) {
final confirm = logger.confirm('Would you like to continue?');
if (!confirm) {
logger.info('Aborting.');
exit(ExitCode.success.code);
}
}
}
/// Fetches the release with version [version] from the server or creates a
/// new release if none exists.
Future<Release> getOrCreateRelease({
required String version,
required ReleasePlatform releasePlatform,
}) async {
return await codePushClientWrapper.maybeGetRelease(
appId: appId,
releaseVersion: version,
) ??
await codePushClientWrapper.createRelease(
appId: appId,
version: version,
flutterRevision: shorebirdEnv.flutterRevision,
platform: releasePlatform,
);
}
/// Prepares the release by updating the release status to draft.
Future<void> prepareRelease({
required Release release,
required Releaser releaser,
}) async {
await codePushClientWrapper.updateReleaseStatus(
appId: appId,
releaseId: release.id,
platform: releaser.releaseType.releasePlatform,
status: ReleaseStatus.draft,
);
}
/// Finalizes the release by updating the status to active.
Future<void> finalizeRelease({
required Release release,
required Releaser releaser,
}) async {
await codePushClientWrapper.updateReleaseStatus(
appId: appId,
releaseId: release.id,
platform: releaser.releaseType.releasePlatform,
status: ReleaseStatus.active,
metadata: await releaser.releaseMetadata(),
);
}
/// Instructions explaining how to patch the release that was just creatd.
void printPatchInstructions({
required Releaser releaser,
required String releaseVersion,
required ReleaseType releaseType,
String? flavor,
String? target,
}) {
final baseCommand = [
'shorebird patch',
'--platform=${releaseType.cliName}',
if (flavor != null) '--flavor=$flavor',
if (target != null) '--target=$target',
].join(' ');
logger.info(
'''To create a patch for this release, run ${lightCyan.wrap('$baseCommand --release-version=$releaseVersion')}''',
);
if (!releaser.requiresReleaseVersionArg) {
logger.info(
'''
Note: ${lightCyan.wrap(baseCommand)} without the --release-version option will patch the current version of the app.
''',
);
}
}
}
@@ -112,42 +112,6 @@ mixin ShorebirdArtifactMixin on ShorebirdCommand {
.firstWhereOrNull((directory) => directory.path.endsWith('.app'));
}
/// Returns the path to the .ipa file generated by `flutter build ipa`. Throws
/// an exception if there is not exactly one .ipa file in the build directory,
/// or if there is no build directory.
String getIpaPath() {
final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!;
final ipaBuildDirectory = Directory(
p.join(
projectRoot.path,
'build',
'ios',
'ipa',
),
);
if (!ipaBuildDirectory.existsSync()) {
throw Exception('No directory found at ${ipaBuildDirectory.path}');
}
final ipaFiles = ipaBuildDirectory
.listSync(recursive: true)
.whereType<File>()
.where((f) => p.extension(f.path) == '.ipa');
if (ipaFiles.isEmpty) {
throw Exception('No .ipa files found in ${ipaBuildDirectory.path}');
}
if (ipaFiles.length > 1) {
throw Exception(
'More than one .ipa file found in ${ipaBuildDirectory.path}',
);
}
return ipaFiles.single.path;
}
static const String appXcframeworkName = 'App.xcframework';
/// Returns the path to the App.xcframework generated by
@@ -10,6 +10,7 @@ import 'package:shorebird_cli/src/shorebird_artifacts.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_process.dart';
// coverage:ignore-start
/// Used to wrap code that invokes `flutter build` with Shorebird's fork of
/// Flutter.
typedef ShorebirdBuildCommand = Future<void> Function();
@@ -128,9 +129,7 @@ mixin ShorebirdBuildMixin on ShorebirdCommand {
if (targetPlatformArgs != null) '--target-platform=$targetPlatformArgs',
// TODO(bryanoltman): reintroduce coverage when we can support this.
// See https://github.com/shorebirdtech/shorebird/issues/1141.
// coverage:ignore-start
if (splitPerAbi) '--split-per-abi',
// coverage:ignore-end
...results.rest,
];
@@ -345,3 +344,5 @@ Either run `flutter pub get` manually, or follow the steps in ${link(uri: Uri.pa
return File(outFilePath);
}
}
// coverage:ignore-end
@@ -272,5 +272,6 @@ void main() {
});
});
},
testOn: 'mac-os',
);
}
@@ -604,10 +604,11 @@ void main() {
)..createSync(recursive: true);
File(p.join(flutterBuildDir.path, 'app1', 'app.dill'))
..createSync(recursive: true)
..setLastModified(DateTime.now().subtract(const Duration(days: 1)));
..setLastModifiedSync(
DateTime.now().subtract(const Duration(days: 1)),
);
appDill2 = File(p.join(flutterBuildDir.path, 'app2', 'app.dill'))
..createSync(recursive: true)
..setLastModified(DateTime.now());
..createSync(recursive: true);
});
test('selects the most recently edited .app.dill file', () {
@@ -6,7 +6,7 @@ import 'package:platform/platform.dart';
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/artifact_builder.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/aar_releaser.dart';
import 'package:shorebird_cli/src/engine_config.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/os/operating_system_interface.dart';
@@ -6,7 +6,7 @@ import 'package:platform/platform.dart';
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/artifact_builder.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/commands/release_new/android_releaser.dart';
import 'package:shorebird_cli/src/commands/release/android_releaser.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/engine_config.dart';
import 'package:shorebird_cli/src/logger.dart';
@@ -7,7 +7,7 @@ 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_framework_releaser.dart';
import 'package:shorebird_cli/src/commands/release/ios_framework_releaser.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/executables/executables.dart';
import 'package:shorebird_cli/src/logger.dart';
@@ -7,7 +7,7 @@ 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/ios_releaser.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/executables/xcodebuild.dart';
import 'package:shorebird_cli/src/logger.dart';
@@ -178,6 +178,36 @@ void main() {
).called(1);
});
});
group('when specified flutter version is less than minimum', () {
setUp(() {
when(
() => shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated:
any(named: 'checkUserIsAuthenticated'),
checkShorebirdInitialized:
any(named: 'checkShorebirdInitialized'),
validators: any(named: 'validators'),
supportedOperatingSystems:
any(named: 'supportedOperatingSystems'),
),
).thenAnswer((_) async {});
when(() => argResults['flutter-version']).thenReturn('3.0.0');
});
test('logs error and exits with code 64', () async {
await expectLater(
() => runWithOverrides(iosReleaser.assertPreconditions),
exitsWithCode(ExitCode.usage),
);
verify(
() => logger.err(
'''iOS releases are not supported with Flutter versions older than $minimumSupportedIosFlutterVersion.''',
),
).called(1);
});
});
});
group('assertArgsAreValid', () {
@@ -1,638 +0,0 @@
import 'dart:io';
import 'package:args/args.dart';
import 'package:http/http.dart' as http;
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/auth/auth.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/commands/commands.dart';
import 'package:shorebird_cli/src/config/config.dart';
import 'package:shorebird_cli/src/engine_config.dart';
import 'package:shorebird_cli/src/executables/executables.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/platform.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/version.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
import 'package:test/test.dart';
import '../../fakes.dart';
import '../../mocks.dart';
void main() {
group(ReleaseAarCommand, () {
const appDisplayName = 'Test App';
const appId = 'test-app-id';
const shorebirdYaml = ShorebirdYaml(appId: appId);
const androidPackageName = 'com.example.my_flutter_module';
final appMetadata = AppMetadata(
appId: appId,
displayName: appDisplayName,
createdAt: DateTime(2023),
updatedAt: DateTime(2023),
);
const flutterRevision = '83305b5088e6fe327fb3334a73ff190828d85713';
const flutterVersionAndRevision = '3.10.6 (83305b5088)';
const versionName = '1.2.3';
const versionCode = '1';
const version = '$versionName+$versionCode';
const operatingSystem = 'macOS';
const operatingSystemVersion = '11.0.0';
final release = Release(
id: 0,
appId: appId,
version: version,
flutterRevision: flutterRevision,
displayName: '1.2.3+1',
platformStatuses: {},
createdAt: DateTime(2023),
updatedAt: DateTime(2023),
);
const releasePlatform = ReleasePlatform.android;
const buildNumber = '1.0';
late ArgResults argResults;
late http.Client httpClient;
late Auth auth;
late CodePushClientWrapper codePushClientWrapper;
late Directory shorebirdRoot;
late Directory projectRoot;
late Java java;
late OperatingSystemInterface operatingSystemInterface;
late Platform platform;
late Progress progress;
late ShorebirdLogger logger;
late ShorebirdProcessResult flutterBuildProcessResult;
late ShorebirdProcessResult flutterPubGetProcessResult;
late ShorebirdEnv shorebirdEnv;
late ShorebirdFlutter shorebirdFlutter;
late ShorebirdProcess shorebirdProcess;
late ShorebirdValidator shorebirdValidator;
late ReleaseAarCommand command;
R runWithOverrides<R>(R Function() body) {
return runScoped(
body,
values: {
authRef.overrideWith(() => auth),
codePushClientWrapperRef.overrideWith(() => codePushClientWrapper),
engineConfigRef.overrideWith(() => const EngineConfig.empty()),
javaRef.overrideWith(() => java),
loggerRef.overrideWith(() => logger),
osInterfaceRef.overrideWith(() => operatingSystemInterface),
platformRef.overrideWith(() => platform),
processRef.overrideWith(() => shorebirdProcess),
shorebirdEnvRef.overrideWith(() => shorebirdEnv),
shorebirdFlutterRef.overrideWith(() => shorebirdFlutter),
shorebirdValidatorRef.overrideWith(() => shorebirdValidator),
},
);
}
void setUpProjectRootArtifacts() {
final aarDir = p.join(
projectRoot.path,
'build',
'host',
'outputs',
'repo',
'com',
'example',
'my_flutter_module',
'flutter_release',
buildNumber,
);
final aarPath = p.join(aarDir, 'flutter_release-$buildNumber.aar');
for (final archMetadata in Arch.values) {
final artifactPath = p.join(
aarDir,
'flutter_release-$buildNumber',
'jni',
archMetadata.androidBuildPath,
'libapp.so',
);
File(artifactPath).createSync(recursive: true);
}
File(aarPath).createSync(recursive: true);
}
setUpAll(() {
registerFallbackValue(ReleasePlatform.android);
registerFallbackValue(ReleaseStatus.draft);
registerFallbackValue(FakeRelease());
registerFallbackValue(FakeShorebirdProcess());
});
setUp(() {
argResults = MockArgResults();
httpClient = MockHttpClient();
auth = MockAuth();
codePushClientWrapper = MockCodePushClientWrapper();
java = MockJava();
operatingSystemInterface = MockOperatingSystemInterface();
platform = MockPlatform();
progress = MockProgress();
logger = MockShorebirdLogger();
flutterBuildProcessResult = MockProcessResult();
flutterPubGetProcessResult = MockProcessResult();
shorebirdProcess = MockShorebirdProcess();
shorebirdRoot = Directory.systemTemp.createTempSync();
projectRoot = Directory.systemTemp.createTempSync();
shorebirdEnv = MockShorebirdEnv();
shorebirdFlutter = MockShorebirdFlutter();
shorebirdValidator = MockShorebirdValidator();
when(() => auth.client).thenReturn(httpClient);
when(() => argResults['build-number']).thenReturn(buildNumber);
when(() => argResults['release-version']).thenReturn(version);
when(() => argResults['target-platform'])
.thenReturn(Arch.values.map((a) => a.targetPlatformCliArg).toList());
when(() => argResults.rest).thenReturn([]);
when(() => auth.isAuthenticated).thenReturn(true);
when(() => logger.confirm(any())).thenReturn(true);
when(() => logger.progress(any())).thenReturn(progress);
when(
() => operatingSystemInterface.which('flutter'),
).thenReturn('/path/to/flutter');
when(() => platform.operatingSystem).thenReturn(operatingSystem);
when(() => platform.operatingSystemVersion)
.thenReturn(operatingSystemVersion);
when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(shorebirdYaml);
when(
() => shorebirdEnv.copyWith(
flutterRevisionOverride: any(named: 'flutterRevisionOverride'),
),
).thenAnswer((invocation) {
when(() => shorebirdEnv.flutterRevision).thenReturn(
invocation.namedArguments[#flutterRevisionOverride] as String,
);
return shorebirdEnv;
});
when(() => shorebirdEnv.shorebirdRoot).thenReturn(shorebirdRoot);
when(
() => shorebirdEnv.getShorebirdProjectRoot(),
).thenReturn(projectRoot);
when(
() => shorebirdEnv.androidPackageName,
).thenReturn(androidPackageName);
when(() => shorebirdEnv.flutterRevision).thenReturn(flutterRevision);
when(() => shorebirdEnv.canAcceptUserInput).thenReturn(true);
when(
() => shorebirdFlutter.getVersionAndRevision(),
).thenAnswer((_) async => flutterVersionAndRevision);
when(
() => shorebirdFlutter.installRevision(
revision: any(named: 'revision'),
),
).thenAnswer((_) async => {});
when(
() => flutterBuildProcessResult.exitCode,
).thenReturn(ExitCode.success.code);
when(
() => flutterPubGetProcessResult.exitCode,
).thenReturn(ExitCode.success.code);
when(
() => shorebirdProcess.run(
'flutter',
['--no-version-check', 'pub', 'get', '--offline'],
runInShell: any(named: 'runInShell'),
useVendedFlutter: false,
),
).thenAnswer((_) async => flutterPubGetProcessResult);
when(
() => shorebirdProcess.run(
any(),
any(that: containsAll(['build', 'aar'])),
runInShell: any(named: 'runInShell'),
),
).thenAnswer((invocation) async {
return flutterBuildProcessResult;
});
when(
() => codePushClientWrapper.getApp(appId: any(named: 'appId')),
).thenAnswer((_) async => appMetadata);
when(
() => codePushClientWrapper.maybeGetRelease(
appId: any(named: 'appId'),
releaseVersion: any(named: 'releaseVersion'),
),
).thenAnswer((_) async => null);
when(
() => codePushClientWrapper.ensureReleaseIsNotActive(
release: any(named: 'release'),
platform: any(named: 'platform'),
),
).thenAnswer((_) async => {});
when(
() => codePushClientWrapper.createRelease(
appId: any(named: 'appId'),
version: any(named: 'version'),
flutterRevision: any(named: 'flutterRevision'),
platform: any(named: 'platform'),
),
).thenAnswer((_) async => release);
when(
() => codePushClientWrapper.createAndroidArchiveReleaseArtifacts(
appId: any(named: 'appId'),
releaseId: any(named: 'releaseId'),
platform: any(named: 'platform'),
aarPath: any(named: 'aarPath'),
extractedAarDir: any(named: 'extractedAarDir'),
architectures: any(named: 'architectures'),
),
).thenAnswer((_) async => {});
when(
() => codePushClientWrapper.updateReleaseStatus(
appId: any(named: 'appId'),
releaseId: any(named: 'releaseId'),
platform: any(named: 'platform'),
status: any(named: 'status'),
metadata: any(named: 'metadata'),
),
).thenAnswer((_) async => {});
when(
() => shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'),
checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'),
),
).thenAnswer((_) async {});
command = runWithOverrides(
() => ReleaseAarCommand(unzipFn: (_, __) async {}),
)..testArgResults = argResults;
});
test('has correct description', () {
expect(command.description, isNotEmpty);
});
test('exits when validation fails', () async {
final exception = ValidationFailedException();
when(
() => shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'),
checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'),
),
).thenThrow(exception);
await expectLater(
runWithOverrides(command.run),
completion(equals(exception.exitCode.code)),
);
verify(
() => shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: true,
checkShorebirdInitialized: true,
),
).called(1);
});
test('exits with 78 if no module entry exists in pubspec.yaml', () async {
when(() => shorebirdEnv.androidPackageName).thenReturn(null);
final result = await runWithOverrides(command.run);
expect(result, ExitCode.config.code);
});
group('when target-platforms is specified', () {
setUp(() {
when(() => argResults['target-platform']).thenReturn(['android-arm']);
});
test('only creates artifcats for the specified archs', () async {
setUpProjectRootArtifacts();
final exitCode = await runWithOverrides(command.run);
expect(exitCode, equals(ExitCode.success.code));
verify(
() => logger.info(
any(
that: contains(
'''
🕹️ Platform: ${lightCyan.wrap('android')} ${lightCyan.wrap('(arm32)')}''',
),
),
),
).called(1);
verify(
() => shorebirdProcess.run(
'flutter',
[
'build',
'aar',
'--no-debug',
'--no-profile',
'--build-number=$buildNumber',
'--target-platform=android-arm',
],
runInShell: any(named: 'runInShell'),
),
).called(1);
});
});
group('when flutter-version is provided', () {
const flutterVersion = '3.16.3';
setUp(() {
when(() => argResults['flutter-version']).thenReturn(flutterVersion);
});
group('when unable to determine flutter revision', () {
final exception = Exception('oops');
setUp(() {
when(
() => shorebirdFlutter.getRevisionForVersion(any()),
).thenThrow(exception);
});
test('exits with code 70', () async {
final exitCode = await runWithOverrides(command.run);
expect(exitCode, equals(ExitCode.software.code));
verify(
() => logger.err(
'''
Unable to determine revision for Flutter version: $flutterVersion.
$exception''',
),
).called(1);
});
});
group('when flutter version is not supported', () {
setUp(() {
when(
() => shorebirdFlutter.getRevisionForVersion(any()),
).thenAnswer((_) async => null);
});
test('exits with code 70', () async {
final exitCode = await runWithOverrides(command.run);
expect(exitCode, equals(ExitCode.software.code));
verify(
() => logger.err(
any(that: contains('Version $flutterVersion not found.')),
),
).called(1);
});
});
group('when flutter version is supported', () {
const revision = '771d07b2cf';
setUp(() {
when(
() => shorebirdFlutter.getRevisionForVersion(any()),
).thenAnswer((_) async => revision);
});
test('uses specified flutter version to build', () async {
setUpProjectRootArtifacts();
when(
() => shorebirdProcess.run(
any(),
any(that: containsAll(['build', 'aar'])),
runInShell: any(named: 'runInShell'),
),
).thenAnswer((_) async {
// Ensure we're using the correct flutter revision.
expect(shorebirdEnv.flutterRevision, equals(revision));
return flutterBuildProcessResult;
});
await runWithOverrides(command.run);
verify(() => shorebirdFlutter.installRevision(revision: revision))
.called(1);
verify(
() => codePushClientWrapper.createRelease(
appId: appId,
version: version,
flutterRevision: revision,
platform: releasePlatform,
),
).called(1);
});
group('when flutter version install fails', () {
setUp(() {
when(
() => shorebirdFlutter.installRevision(
revision: any(named: 'revision'),
),
).thenThrow(Exception('oops'));
});
test('exits with code 70', () async {
setUpProjectRootArtifacts();
final result = await runWithOverrides(command.run);
expect(result, equals(ExitCode.software.code));
verify(
() => shorebirdFlutter.installRevision(revision: revision),
).called(1);
});
});
});
});
test('exits with code 70 when building aar fails', () async {
when(() => flutterBuildProcessResult.exitCode).thenReturn(1);
when(() => flutterBuildProcessResult.stderr).thenReturn('oops');
final result = await runWithOverrides(command.run);
expect(result, equals(ExitCode.software.code));
verify(
() => shorebirdProcess.run(
'flutter',
[
'build',
'aar',
'--no-debug',
'--no-profile',
'--build-number=$buildNumber',
'--target-platform=android-arm,android-arm64,android-x64',
],
runInShell: any(named: 'runInShell'),
),
).called(1);
verify(
() => progress.fail(any(that: contains('Failed to build'))),
).called(1);
});
test('aborts when user opts out', () async {
when(() => logger.confirm(any())).thenReturn(false);
when(
() => logger.prompt(
'What is the version of this release?',
defaultValue: any(named: 'defaultValue'),
),
).thenAnswer((_) => '1.0.0');
setUpProjectRootArtifacts();
final exitCode = await runWithOverrides(command.run);
expect(exitCode, ExitCode.success.code);
verify(() => logger.info('Aborting.')).called(1);
});
test('does not prompt for confirmation if unable to accpet user input',
() async {
when(() => shorebirdEnv.canAcceptUserInput).thenReturn(false);
setUpProjectRootArtifacts();
final exitCode = await runWithOverrides(command.run);
expect(exitCode, ExitCode.success.code);
verify(() => logger.success('\n✅ Published Release $version!')).called(1);
verifyNever(
() => logger.prompt(any(), defaultValue: any(named: 'defaultValue')),
);
});
test('succeeds when release is successful', () async {
setUpProjectRootArtifacts();
final exitCode = await runWithOverrides(command.run);
expect(exitCode, ExitCode.success.code);
verify(() => logger.success('\n✅ Published Release $version!')).called(1);
verify(
() => codePushClientWrapper.createAndroidArchiveReleaseArtifacts(
appId: appId,
releaseId: release.id,
platform: releasePlatform,
aarPath: any(
named: 'aarPath',
that: endsWith(
p.join(
'build',
'host',
'outputs',
'repo',
'com',
'example',
'my_flutter_module',
'flutter_release',
'1.0',
'flutter_release-1.0.aar',
),
),
),
extractedAarDir: any(
named: 'extractedAarDir',
that: endsWith(
p.join(
'build',
'host',
'outputs',
'repo',
'com',
'example',
'my_flutter_module',
'flutter_release',
'1.0',
'flutter_release-1.0',
),
),
),
architectures: any(named: 'architectures'),
),
).called(1);
verify(
() => codePushClientWrapper.updateReleaseStatus(
appId: appId,
releaseId: release.id,
platform: releasePlatform,
status: ReleaseStatus.active,
metadata: const UpdateReleaseMetadata(
releasePlatform: releasePlatform,
flutterVersionOverride: null,
generatedApks: false,
environment: BuildEnvironmentMetadata(
operatingSystem: operatingSystem,
operatingSystemVersion: operatingSystemVersion,
shorebirdVersion: packageVersion,
xcodeVersion: null,
),
),
),
).called(1);
verify(
() => logger.info(
'''To create a patch for this release, run ${lightCyan.wrap('shorebird patch aar --release-version=${release.version}')}''',
),
).called(1);
verifyNever(
() => logger.info(
'''
Note: ${lightCyan.wrap('shorebird patch aar')} without the --release-version option will patch the current version of the app.
''',
),
);
});
test('copies aar library to a releases folder', () async {
setUpProjectRootArtifacts();
final exitCode = await runWithOverrides(command.run);
expect(exitCode, ExitCode.success.code);
expect(
Directory(p.join(projectRoot.path, 'release')).existsSync(),
isTrue,
);
});
test('runs flutter pub get with system flutter after successful build',
() async {
setUpProjectRootArtifacts();
await runWithOverrides(command.run);
verify(
() => shorebirdProcess.run(
'flutter',
['--no-version-check', 'pub', 'get', '--offline'],
runInShell: any(named: 'runInShell'),
useVendedFlutter: false,
),
).called(1);
});
test('does not create new release if existing release is present',
() async {
when(
() => codePushClientWrapper.maybeGetRelease(
appId: any(named: 'appId'),
releaseVersion: any(named: 'releaseVersion'),
),
).thenAnswer((_) async => release);
setUpProjectRootArtifacts();
final exitCode = await runWithOverrides(command.run);
expect(exitCode, ExitCode.success.code);
verifyNever(
() => codePushClientWrapper.createRelease(
appId: any(named: 'appId'),
version: any(named: 'version'),
flutterRevision: any(named: 'flutterRevision'),
platform: any(named: 'platform'),
),
);
verify(
() => codePushClientWrapper.updateReleaseStatus(
appId: appId,
releaseId: release.id,
platform: releasePlatform,
status: ReleaseStatus.active,
metadata: any(named: 'metadata'),
),
).called(1);
});
});
}
@@ -4,7 +4,7 @@ import 'package:mocktail/mocktail.dart';
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/cache.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/commands/release_new/release_new.dart';
import 'package:shorebird_cli/src/commands/release/release.dart';
import 'package:shorebird_cli/src/config/config.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/release_type.dart';
@@ -18,7 +18,7 @@ import '../../matchers.dart';
import '../../mocks.dart';
void main() {
group(ReleaseNewCommand, () {
group(ReleaseCommand, () {
const appId = 'test-app-id';
const appDisplayName = 'Test App';
const flutterRevision = '83305b5088e6fe327fb3334a73ff190828d85713';
@@ -53,7 +53,7 @@ void main() {
late ShorebirdEnv shorebirdEnv;
late ShorebirdFlutter shorebirdFlutter;
late ReleaseNewCommand command;
late ReleaseCommand command;
R runWithOverrides<R>(R Function() body) {
return runScoped(
@@ -174,7 +174,7 @@ void main() {
),
).thenAnswer((_) async => {});
command = ReleaseNewCommand(resolveReleaser: (_) => releaser)
command = ReleaseCommand(resolveReleaser: (_) => releaser)
..testArgResults = argResults;
});
File diff suppressed because it is too large Load Diff
@@ -1,608 +0,0 @@
import 'dart:io' hide Platform;
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/auth/auth.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/commands/release/release.dart';
import 'package:shorebird_cli/src/config/config.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/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/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 '../../fakes.dart';
import '../../mocks.dart';
void main() {
group(
ReleaseIosFrameworkCommand,
() {
const appId = 'test-app-id';
const shorebirdYaml = ShorebirdYaml(appId: appId);
const flutterRevision = '83305b5088e6fe327fb3334a73ff190828d85713';
const flutterVersionAndRevision = '3.10.6 (83305b5088)';
const versionName = '1.2.3';
const versionCode = '1';
const version = '$versionName+$versionCode';
const operatingSystem = 'macOS';
const operatingSystemVersion = '11.0.0';
const xcodeVersion = '12.0';
const appDisplayName = 'Test App';
const releasePlatform = ReleasePlatform.ios;
final appMetadata = AppMetadata(
appId: appId,
displayName: appDisplayName,
createdAt: DateTime(2023),
updatedAt: DateTime(2023),
);
final release = Release(
id: 0,
appId: appId,
version: version,
flutterRevision: flutterRevision,
displayName: '1.2.3+1',
platformStatuses: {},
createdAt: DateTime(2023),
updatedAt: DateTime(2023),
);
const pubspecYamlContent = '''
name: example
version: $version
environment:
sdk: ">=2.19.0 <3.0.0"
flutter:
assets:
- shorebird.yaml''';
late ArgResults argResults;
late CodePushClientWrapper codePushClientWrapper;
late Directory shorebirdRoot;
late Directory projectRoot;
late Doctor doctor;
late Platform platform;
late Auth auth;
late Progress progress;
late ShorebirdLogger logger;
late OperatingSystemInterface operatingSystemInterface;
late ShorebirdProcessResult flutterBuildProcessResult;
late ShorebirdProcessResult flutterPubGetProcessResult;
late ShorebirdFlutterValidator flutterValidator;
late ShorebirdProcess shorebirdProcess;
late ShorebirdEnv shorebirdEnv;
late ShorebirdFlutter shorebirdFlutter;
late ShorebirdValidator shorebirdValidator;
late XcodeBuild xcodeBuild;
late ReleaseIosFrameworkCommand command;
R runWithOverrides<R>(R Function() body) {
return runScoped(
body,
values: {
authRef.overrideWith(() => auth),
codePushClientWrapperRef.overrideWith(() => codePushClientWrapper),
doctorRef.overrideWith(() => doctor),
loggerRef.overrideWith(() => logger),
osInterfaceRef.overrideWith(() => operatingSystemInterface),
platformRef.overrideWith(() => platform),
processRef.overrideWith(() => shorebirdProcess),
shorebirdEnvRef.overrideWith(() => shorebirdEnv),
shorebirdFlutterRef.overrideWith(() => shorebirdFlutter),
shorebirdValidatorRef.overrideWith(() => shorebirdValidator),
xcodeBuildRef.overrideWith(() => xcodeBuild),
},
);
}
void setUpProjectRoot() {
File(
p.join(projectRoot.path, 'pubspec.yaml'),
).writeAsStringSync(pubspecYamlContent);
File(
p.join(projectRoot.path, 'shorebird.yaml'),
).writeAsStringSync('app_id: $appId');
// Create an xcframework in the release directory to simulate running
// this command a subsequent time.
Directory(p.join(projectRoot.path, 'release', 'Flutter.xcframework'))
.createSync(recursive: true);
Directory(
p.join(
projectRoot.path,
'build',
'ios',
'framework',
'Release',
'Flutter.xcframework',
),
).createSync(recursive: true);
}
setUpAll(() {
registerFallbackValue(Directory(''));
registerFallbackValue(ReleasePlatform.ios);
registerFallbackValue(ReleaseStatus.draft);
registerFallbackValue(FakeRelease());
registerFallbackValue(FakeShorebirdProcess());
});
setUp(() {
argResults = MockArgResults();
codePushClientWrapper = MockCodePushClientWrapper();
doctor = MockDoctor();
platform = MockPlatform();
shorebirdRoot = Directory.systemTemp.createTempSync();
projectRoot = Directory.systemTemp.createTempSync();
auth = MockAuth();
progress = MockProgress();
logger = MockShorebirdLogger();
operatingSystemInterface = MockOperatingSystemInterface();
flutterBuildProcessResult = MockProcessResult();
flutterPubGetProcessResult = MockProcessResult();
flutterValidator = MockShorebirdFlutterValidator();
shorebirdProcess = MockShorebirdProcess();
shorebirdEnv = MockShorebirdEnv();
shorebirdFlutter = MockShorebirdFlutter();
shorebirdValidator = MockShorebirdValidator();
xcodeBuild = MockXcodeBuild();
when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(shorebirdYaml);
when(() => shorebirdEnv.shorebirdRoot).thenReturn(shorebirdRoot);
when(
() => shorebirdEnv.copyWith(
flutterRevisionOverride: any(named: 'flutterRevisionOverride'),
),
).thenAnswer((invocation) {
when(() => shorebirdEnv.flutterRevision).thenReturn(
invocation.namedArguments[#flutterRevisionOverride] as String,
);
return shorebirdEnv;
});
when(
() => shorebirdEnv.getShorebirdProjectRoot(),
).thenReturn(projectRoot);
when(() => shorebirdEnv.canAcceptUserInput).thenReturn(true);
when(() => shorebirdEnv.flutterRevision).thenReturn(flutterRevision);
when(
() => shorebirdFlutter.getVersionAndRevision(),
).thenAnswer((_) async => flutterVersionAndRevision);
when(
() => shorebirdFlutter.installRevision(
revision: any(named: 'revision'),
),
).thenAnswer((_) async => {});
when(
() => shorebirdProcess.run(
'flutter',
['--no-version-check', 'pub', 'get', '--offline'],
runInShell: any(named: 'runInShell'),
useVendedFlutter: false,
),
).thenAnswer((_) async => flutterPubGetProcessResult);
when(
() => shorebirdProcess.run(
'flutter',
any(),
runInShell: any(named: 'runInShell'),
),
).thenAnswer((_) async => flutterBuildProcessResult);
when(() => argResults['release-version']).thenReturn(version);
when(() => argResults.rest).thenReturn([]);
when(() => auth.isAuthenticated).thenReturn(true);
when(() => doctor.iosCommandValidators).thenReturn([flutterValidator]);
when(
() => flutterBuildProcessResult.exitCode,
).thenReturn(ExitCode.success.code);
when(() => flutterPubGetProcessResult.exitCode)
.thenReturn(ExitCode.success.code);
when(() => logger.progress(any())).thenReturn(progress);
when(() => logger.confirm(any())).thenReturn(true);
when(() => operatingSystemInterface.which('flutter'))
.thenReturn('/path/to/flutter');
when(() => platform.operatingSystem).thenReturn(operatingSystem);
when(() => platform.operatingSystemVersion)
.thenReturn(operatingSystemVersion);
when(
() => codePushClientWrapper.getApp(appId: any(named: 'appId')),
).thenAnswer((_) async => appMetadata);
when(
() => codePushClientWrapper.createIosFrameworkReleaseArtifacts(
appId: any(named: 'appId'),
releaseId: any(named: 'releaseId'),
appFrameworkPath: any(named: 'appFrameworkPath'),
),
).thenAnswer((_) async => {});
when(
() => codePushClientWrapper.createRelease(
appId: any(named: 'appId'),
version: any(named: 'version'),
flutterRevision: any(named: 'flutterRevision'),
platform: any(named: 'platform'),
),
).thenAnswer((_) async => release);
when(
() => codePushClientWrapper.maybeGetRelease(
appId: any(named: 'appId'),
releaseVersion: any(named: 'releaseVersion'),
),
).thenAnswer((_) async => null);
when(
() => codePushClientWrapper.ensureReleaseIsNotActive(
release: any(named: 'release'),
platform: any(named: 'platform'),
),
).thenAnswer((_) async => {});
when(
() => codePushClientWrapper.updateReleaseStatus(
appId: any(named: 'appId'),
releaseId: any(named: 'releaseId'),
platform: any(named: 'platform'),
status: any(named: 'status'),
metadata: any(named: 'metadata'),
),
).thenAnswer((_) async => {});
when(
() => shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'),
checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'),
validators: any(named: 'validators'),
supportedOperatingSystems: any(named: 'supportedOperatingSystems'),
),
).thenAnswer((_) async {});
when(() => xcodeBuild.version()).thenAnswer((_) async => xcodeVersion);
command = runWithOverrides(ReleaseIosFrameworkCommand.new)
..testArgResults = argResults;
});
test('supports alpha alias', () {
expect(command.aliases, contains('ios-framework-alpha'));
});
test('has a description', () {
expect(command.description, isNotEmpty);
});
test('exits when validation fails', () async {
final exception = ValidationFailedException();
when(
() => shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'),
checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'),
validators: any(named: 'validators'),
supportedOperatingSystems: any(named: 'supportedOperatingSystems'),
),
).thenThrow(exception);
await expectLater(
runWithOverrides(command.run),
completion(equals(exception.exitCode.code)),
);
verify(
() => shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: true,
checkShorebirdInitialized: true,
validators: any(named: 'validators'),
supportedOperatingSystems: {Platform.macOS},
),
).called(1);
});
group('when flutter-version is provided', () {
const flutterVersion = '3.19.5';
setUp(() {
when(() => argResults['flutter-version']).thenReturn(flutterVersion);
});
group('when unable to determine flutter revision', () {
final exception = Exception('oops');
setUp(() {
when(
() => shorebirdFlutter.getRevisionForVersion(any()),
).thenThrow(exception);
});
test('exits with code 70', () async {
final exitCode = await runWithOverrides(command.run);
expect(exitCode, equals(ExitCode.software.code));
verify(
() => logger.err(
'''
Unable to determine revision for Flutter version: $flutterVersion.
$exception''',
),
).called(1);
});
});
group('when flutter version is too old', () {
setUp(() {
when(() => argResults['flutter-version']).thenReturn('3.16.3');
});
test('prints error log and exits with code 64 (usage)', () async {
final exitCode = await runWithOverrides(command.run);
expect(exitCode, equals(ExitCode.usage.code));
verify(
() => logger.err(
'''iOS releases are not supported with Flutter versions older than 3.19.5.''',
),
).called(1);
});
});
group('when flutter version is not supported', () {
setUp(() {
when(
() => shorebirdFlutter.getRevisionForVersion(any()),
).thenAnswer((_) async => null);
});
test('exits with code 70', () async {
final exitCode = await runWithOverrides(command.run);
expect(exitCode, equals(ExitCode.software.code));
verify(
() => logger.err(
any(that: contains('Version $flutterVersion not found.')),
),
).called(1);
});
});
group('when flutter version is supported', () {
const revision = '771d07b2cf';
setUp(() {
when(
() => shorebirdFlutter.getRevisionForVersion(any()),
).thenAnswer((_) async => revision);
});
test('uses specified flutter version build', () async {
when(
() => shorebirdProcess.run(
'flutter',
any(),
runInShell: any(named: 'runInShell'),
),
).thenAnswer((_) async {
// Ensure we're using the correct flutter version.
expect(shorebirdEnv.flutterRevision, equals(revision));
return flutterBuildProcessResult;
});
setUpProjectRoot();
await runWithOverrides(command.run);
verify(() => shorebirdFlutter.installRevision(revision: revision))
.called(1);
verify(
() => codePushClientWrapper.createRelease(
appId: appId,
version: version,
flutterRevision: revision,
platform: releasePlatform,
),
).called(1);
verify(
() => codePushClientWrapper.updateReleaseStatus(
appId: appId,
releaseId: release.id,
platform: releasePlatform,
status: ReleaseStatus.active,
metadata: const UpdateReleaseMetadata(
releasePlatform: releasePlatform,
flutterVersionOverride: flutterVersion,
generatedApks: false,
environment: BuildEnvironmentMetadata(
operatingSystem: operatingSystem,
operatingSystemVersion: operatingSystemVersion,
shorebirdVersion: packageVersion,
xcodeVersion: xcodeVersion,
),
),
),
).called(1);
});
group('when flutter version install fails', () {
setUp(() {
when(
() => shorebirdFlutter.installRevision(
revision: any(named: 'revision'),
),
).thenThrow(Exception('oops'));
});
test('exits with code 70', () async {
setUpProjectRoot();
final result = await runWithOverrides(command.run);
expect(result, equals(ExitCode.software.code));
verify(
() => shorebirdFlutter.installRevision(revision: revision),
).called(1);
});
});
});
});
test('exits with code 70 when build fails with non-zero exit code',
() async {
when(() => flutterBuildProcessResult.exitCode).thenReturn(1);
when(() => flutterBuildProcessResult.stderr).thenReturn('oops');
setUpProjectRoot();
final exitCode = await runWithOverrides(command.run);
expect(exitCode, equals(ExitCode.software.code));
verify(
() => progress.fail(any(that: contains('Failed to build'))),
).called(1);
});
test('checks that release is not active if release exists', () async {
when(
() => codePushClientWrapper.maybeGetRelease(
appId: any(named: 'appId'),
releaseVersion: any(named: 'releaseVersion'),
),
).thenAnswer((_) async => release);
setUpProjectRoot();
await runWithOverrides(command.run);
verify(
() => codePushClientWrapper.ensureReleaseIsNotActive(
release: release,
platform: releasePlatform,
),
).called(1);
});
test('aborts when user opts out', () async {
when(() => logger.confirm(any())).thenReturn(false);
setUpProjectRoot();
final exitCode = await runWithOverrides(command.run);
expect(exitCode, ExitCode.success.code);
verify(() => logger.info('Aborting.')).called(1);
verifyNever(
() => codePushClientWrapper.createIosReleaseArtifacts(
appId: appId,
releaseId: release.id,
xcarchivePath: any(
named: 'xcarchivePath',
that: endsWith('.xcarchive'),
),
runnerPath: any(named: 'runnerPath', that: endsWith('Runner.app')),
isCodesigned: any(named: 'isCodesigned'),
),
);
});
test('does not prompt for confirmation if unable to accept user input',
() async {
when(() => shorebirdEnv.canAcceptUserInput).thenReturn(false);
when(() => argResults['release-version']).thenReturn(version);
setUpProjectRoot();
final exitCode = await runWithOverrides(command.run);
verify(() => logger.success('\n✅ Published Release $version!'))
.called(1);
expect(exitCode, ExitCode.success.code);
verifyNever(
() => logger.prompt(any(), defaultValue: any(named: 'defaultValue')),
);
verify(
() => codePushClientWrapper.updateReleaseStatus(
appId: appId,
releaseId: release.id,
platform: releasePlatform,
status: ReleaseStatus.active,
metadata: any(named: 'metadata'),
),
).called(1);
});
test('succeeds when release is successful', () async {
setUpProjectRoot();
final exitCode = await runWithOverrides(command.run);
verify(() => logger.success('\n✅ Published Release $version!'))
.called(1);
verify(
() => logger.info(
any(
that: stringContainsInOrder(
[
'Your next step is to add the .xcframework files found in',
'release',
'to your iOS app.',
'''Embed the App.xcframework and ShorebirdFlutter.framework in your Xcode project''',
],
),
),
),
).called(1);
verify(
() => codePushClientWrapper.createIosFrameworkReleaseArtifacts(
appId: appId,
releaseId: release.id,
appFrameworkPath: any(
named: 'appFrameworkPath',
that: endsWith(
p.join('release', 'App.xcframework'),
),
),
),
).called(1);
verify(
() => codePushClientWrapper.updateReleaseStatus(
appId: appId,
releaseId: release.id,
platform: releasePlatform,
status: ReleaseStatus.active,
metadata: const UpdateReleaseMetadata(
releasePlatform: releasePlatform,
flutterVersionOverride: null,
generatedApks: false,
environment: BuildEnvironmentMetadata(
operatingSystem: operatingSystem,
operatingSystemVersion: operatingSystemVersion,
shorebirdVersion: packageVersion,
xcodeVersion: xcodeVersion,
),
),
),
).called(1);
verify(
() => logger.info(
'''To create a patch for this release, run ${lightCyan.wrap('shorebird patch ios-framework --release-version=${release.version}')}''',
),
).called(1);
verifyNever(
() => logger.info(
'''
Note: ${lightCyan.wrap('shorebird patch ios-framework')} without the --release-version option will patch the current version of the app.
''',
),
);
expect(exitCode, ExitCode.success.code);
});
test('runs flutter pub get with system flutter after successful build',
() async {
setUpProjectRoot();
await runWithOverrides(command.run);
verify(
() => shorebirdProcess.run(
'flutter',
['--no-version-check', 'pub', 'get', '--offline'],
runInShell: any(named: 'runInShell'),
useVendedFlutter: false,
),
).called(1);
});
},
testOn: 'mac-os',
);
}
@@ -1,6 +1,6 @@
import 'dart:io';
import 'package:shorebird_cli/src/commands/release_new/releaser.dart';
import 'package:shorebird_cli/src/commands/release/releaser.dart';
import 'package:shorebird_cli/src/release_type.dart';
import 'package:shorebird_code_push_protocol/src/models/release.dart';
import 'package:shorebird_code_push_protocol/src/models/update_release_metadata.dart';
@@ -66,5 +66,37 @@ auto_update: true
expect(shorebirdYaml.baseUrl, isNull);
expect(shorebirdYaml.autoUpdate, isTrue);
});
group('AppIdExtension', () {
test('getAppId returns base app id when no flavor is provided', () {
const shorebirdYaml = ShorebirdYaml(
appId: 'test_app_id',
);
expect(shorebirdYaml.getAppId(), 'test_app_id');
});
test('getAppId returns base app id when flavor is not found', () {
const shorebirdYaml = ShorebirdYaml(
appId: 'test_app_id',
flavors: {
'development': 'test_app_id1',
'production': 'test_app_id2',
},
);
expect(shorebirdYaml.getAppId(flavor: 'staging'), 'test_app_id');
});
test('getAppId returns app id for flavor', () {
const shorebirdYaml = ShorebirdYaml(
appId: 'test_app_id',
flavors: {
'development': 'test_app_id1',
'production': 'test_app_id2',
},
);
expect(shorebirdYaml.getAppId(flavor: 'development'), 'test_app_id1');
expect(shorebirdYaml.getAppId(flavor: 'production'), 'test_app_id2');
});
});
});
}
+1 -1
View File
@@ -17,7 +17,7 @@ import 'package:shorebird_cli/src/auth/auth.dart';
import 'package:shorebird_cli/src/cache.dart' show Cache;
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/commands/patch_new/patch_new.dart';
import 'package:shorebird_cli/src/commands/release_new/releaser.dart';
import 'package:shorebird_cli/src/commands/release/releaser.dart';
import 'package:shorebird_cli/src/config/config.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/engine_config.dart';