refactor(shorebird_cli): delete old patch command and mixins (#2052)

This commit is contained in:
Bryan Oltman
2024-05-09 14:17:35 -04:00
committed by GitHub
parent 47bc66d566
commit 95f267deef
30 changed files with 386 additions and 7823 deletions
@@ -6,7 +6,6 @@ import 'package:cli_completion/cli_completion.dart';
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/engine_config.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/platform.dart';
@@ -74,7 +73,6 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner<int> {
addCommand(LoginCiCommand());
addCommand(LogoutCommand());
addCommand(PatchCommand());
addCommand(PatchNewCommand());
addCommand(PreviewCommand());
addCommand(ReleaseCommand());
addCommand(RunCommand());
@@ -1,11 +1,9 @@
import 'dart:io';
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/command.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/shorebird_build_mixin.dart';
import 'package:shorebird_cli/src/shorebird_validator.dart';
/// {@template build_ipa_command}
@@ -13,7 +11,7 @@ import 'package:shorebird_cli/src/shorebird_validator.dart';
/// Builds an .xcarchive and optionally .ipa for an iOS app to be generated for
/// App Store submission.
/// {@endtemplate}
class BuildIpaCommand extends ShorebirdCommand with ShorebirdBuildMixin {
class BuildIpaCommand extends ShorebirdCommand {
/// {@macro build_ipa_command}
BuildIpaCommand() {
argParser
@@ -63,12 +61,12 @@ Codesigning is disabled. You must manually codesign before deploying to devices.
final buildProgress = logger.progress('Building ipa');
try {
await buildIpa(
await artifactBuilder.buildIpa(
flavor: flavor,
target: target,
codesign: codesign,
);
} on ProcessException catch (error) {
} on ArtifactBuildException catch (error) {
buildProgress.fail('Failed to build: ${error.message}');
return ExitCode.software.code;
}
@@ -8,7 +8,7 @@ import 'package:shorebird_cli/src/archive_analysis/archive_differ.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/patch_new/patch_new.dart';
import 'package:shorebird_cli/src/commands/patch/patch.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/patch_diff_checker.dart';
import 'package:shorebird_cli/src/platform.dart';
@@ -6,7 +6,7 @@ import 'package:shorebird_cli/src/archive_analysis/archive_differ.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/patch_new/patcher.dart';
import 'package:shorebird_cli/src/commands/patch/patcher.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/patch_diff_checker.dart';
@@ -9,7 +9,7 @@ import 'package:shorebird_cli/src/archive_analysis/ios_archive_differ.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/patch_new/patch_new.dart';
import 'package:shorebird_cli/src/commands/patch/patch.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/executables/aot_tools.dart';
import 'package:shorebird_cli/src/executables/xcodebuild.dart';
@@ -9,7 +9,7 @@ import 'package:shorebird_cli/src/archive_analysis/archive_differ.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/patch_new/patcher.dart';
import 'package:shorebird_cli/src/commands/patch/patcher.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/executables/executables.dart';
import 'package:shorebird_cli/src/logger.dart';
@@ -1,5 +1,6 @@
export 'patch_aar_command.dart';
export 'patch_android_command.dart';
export 'aar_patcher.dart';
export 'android_patcher.dart';
export 'ios_framework_patcher.dart';
export 'ios_patcher.dart';
export 'patch_command.dart';
export 'patch_ios_command.dart';
export 'patch_ios_framework_command.dart';
export 'patcher.dart';
@@ -1,368 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:archive/archive_io.dart';
import 'package:collection/collection.dart';
import 'package:crypto/crypto.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:path/path.dart' as p;
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart';
import 'package:shorebird_cli/src/artifact_manager.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/patch/patch.dart';
import 'package:shorebird_cli/src/config/config.dart';
import 'package:shorebird_cli/src/deployment_track.dart';
import 'package:shorebird_cli/src/formatters/file_size_formatter.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/patch_diff_checker.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 patch_aar_command}
/// `shorebird patch aar`
/// Create a patch for an Android archive release.
/// {@endtemplate}
class PatchAarCommand extends ShorebirdCommand
with ShorebirdBuildMixin, ShorebirdArtifactMixin {
/// {@macro patch_aar_command}
PatchAarCommand({
HashFunction? hashFn,
UnzipFn? unzipFn,
AndroidArchiveDiffer? archiveDiffer,
}) : _archiveDiffer = archiveDiffer ?? AndroidArchiveDiffer(),
_hashFn = hashFn ?? ((m) => sha256.convert(m).toString()),
_unzipFn = unzipFn ?? extractFileToDisk {
argParser
..addOption(
'build-number',
help: 'The build number of the module (e.g. "1.0.0").',
defaultsTo: '1.0',
)
..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,
)
..addFlag(
'allow-native-diffs',
help: PatchCommand.allowNativeDiffsHelpText,
negatable: false,
)
..addFlag(
'allow-asset-diffs',
help: PatchCommand.allowAssetDiffsHelpText,
negatable: false,
)
..addFlag(
'dry-run',
abbr: 'n',
negatable: false,
help: 'Validate but do not upload the patch.',
);
}
@override
String get name => 'aar';
@override
String get description =>
'Publish new patches for a specific Android archive release to Shorebird';
final AndroidArchiveDiffer _archiveDiffer;
final HashFunction _hashFn;
final UnzipFn _unzipFn;
@override
Future<int> run() async {
try {
await shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: true,
checkShorebirdInitialized: true,
);
} on PreconditionFailedException catch (e) {
return e.exitCode.code;
}
final dryRun = results['dry-run'] == true;
final allowAssetDiffs = results['allow-asset-diffs'] == true;
final allowNativeDiffs = results['allow-native-diffs'] == true;
await cache.updateAll();
if (shorebirdEnv.androidPackageName == null) {
logger.err('Could not find androidPackage in pubspec.yaml.');
return ExitCode.config.code;
}
final shorebirdYaml = shorebirdEnv.getShorebirdYaml()!;
final appId = shorebirdYaml.getAppId();
final app = await codePushClientWrapper.getApp(appId: appId);
final releases = await codePushClientWrapper.getReleases(appId: appId);
if (releases.isEmpty) {
logger.info('No releases found');
return ExitCode.success.code;
}
final releaseVersion = results['release-version'] as String? ??
await _promptForReleaseVersion(releases);
final release = releases.firstWhereOrNull(
(r) => r.version == releaseVersion,
);
if (releaseVersion == null || release == null) {
logger.info('''
No release found for version $releaseVersion
Available release versions:
${releases.map((r) => r.version).join('\n')}''');
return ExitCode.success.code;
}
if (release.platformStatuses[ReleasePlatform.android] ==
ReleaseStatus.draft) {
logger.err('''
Release $releaseVersion is in an incomplete state. It's possible that the original release was terminated or failed to complete.
Please re-run the release command for this version or create a new release.''');
return ExitCode.software.code;
}
const releasePlatform = ReleasePlatform.android;
final releaseArtifacts = await codePushClientWrapper.getReleaseArtifacts(
appId: appId,
releaseId: release.id,
architectures: AndroidArch.availableAndroidArchs,
platform: releasePlatform,
);
final releaseAarArtifact = await codePushClientWrapper.getReleaseArtifact(
appId: appId,
releaseId: release.id,
arch: 'aar',
platform: releasePlatform,
);
final Map<Arch, String> releaseArtifactPaths;
try {
releaseArtifactPaths = await _downloadReleaseArtifacts(
releaseArtifacts: releaseArtifacts,
);
} catch (_) {
return ExitCode.software.code;
}
try {
await shorebirdFlutter.installRevision(revision: release.flutterRevision);
} catch (_) {
return ExitCode.software.code;
}
final releaseFlutterShorebirdEnv = shorebirdEnv.copyWith(
flutterRevisionOverride: release.flutterRevision,
);
return await runScoped(
() async {
final buildNumber = results['build-number'] as String;
final flutterVersionString =
await shorebirdFlutter.getVersionAndRevision();
final buildProgress = logger.progress(
'Building patch with Flutter $flutterVersionString',
);
try {
await buildAar(buildNumber: buildNumber);
buildProgress.complete();
} on ProcessException catch (error) {
buildProgress.fail('Failed to build: ${error.message}');
return ExitCode.software.code;
}
final extractedAarDir = await extractAar(
packageName: shorebirdEnv.androidPackageName!,
buildNumber: buildNumber,
unzipFn: _unzipFn,
);
final DiffStatus diffStatus;
try {
diffStatus =
await patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
localArtifact: File(
aarArtifactPath(
packageName: shorebirdEnv.androidPackageName!,
buildNumber: buildNumber,
),
),
releaseArtifact: await artifactManager
.downloadFile(Uri.parse(releaseAarArtifact.url)),
archiveDiffer: _archiveDiffer,
allowAssetChanges: allowAssetDiffs,
allowNativeChanges: allowNativeDiffs,
);
} on UserCancelledException {
return ExitCode.success.code;
} on UnpatchableChangeException {
logger.info('Exiting.');
return ExitCode.software.code;
}
final patchArtifactBundles = await _createPatchArtifacts(
releaseArtifactPaths: releaseArtifactPaths,
extractedAarDirectory: extractedAarDir,
);
if (patchArtifactBundles == null) {
return ExitCode.software.code;
}
final archMetadata = patchArtifactBundles.keys.map((arch) {
final size = formatBytes(patchArtifactBundles[arch]!.size);
return '${arch.name} ($size)';
});
if (dryRun) {
logger
..info('No issues detected.')
..info('The server may enforce additional checks.');
return ExitCode.success.code;
}
final summary = [
'''📱 App: ${lightCyan.wrap(app.displayName)} ${lightCyan.wrap('(${app.appId})')}''',
'📦 Release Version: ${lightCyan.wrap(releaseVersion)}',
'''🕹️ Platform: ${lightCyan.wrap(releasePlatform.name)} ${lightCyan.wrap('[${archMetadata.join(', ')}]')}''',
'🟢 Track: ${lightCyan.wrap('Production')}',
];
logger.info(
'''
${styleBold.wrap(lightGreen.wrap('🚀 Ready to publish a new patch!'))}
${summary.join('\n')}
''',
);
if (shorebirdEnv.canAcceptUserInput) {
final confirm = logger.confirm('Would you like to continue?');
if (!confirm) {
logger.info('Aborting.');
return ExitCode.success.code;
}
}
await codePushClientWrapper.publishPatch(
appId: appId,
releaseId: release.id,
platform: releasePlatform,
track: DeploymentTrack.production,
patchArtifactBundles: patchArtifactBundles,
metadata: CreatePatchMetadata(
releasePlatform: releasePlatform,
usedIgnoreAssetChangesFlag: allowAssetDiffs,
hasAssetChanges: diffStatus.hasAssetChanges,
usedIgnoreNativeChangesFlag: allowNativeDiffs,
hasNativeChanges: diffStatus.hasNativeChanges,
linkPercentage: null,
environment: BuildEnvironmentMetadata(
operatingSystem: platform.operatingSystem,
operatingSystemVersion: platform.operatingSystemVersion,
shorebirdVersion: packageVersion,
xcodeVersion: null,
),
),
);
return ExitCode.success.code;
},
values: {
shorebirdEnvRef.overrideWith(() => releaseFlutterShorebirdEnv),
},
);
}
Future<String?> _promptForReleaseVersion(List<Release> releases) async {
if (releases.isEmpty) return null;
final release = logger.chooseOne(
'Which release would you like to patch?',
choices: releases,
display: (release) => release.version,
);
return release.version;
}
Future<Map<Arch, PatchArtifactBundle>?> _createPatchArtifacts({
required Map<Arch, String> releaseArtifactPaths,
required String extractedAarDirectory,
}) async {
final patchArtifactBundles = <Arch, PatchArtifactBundle>{};
final createDiffProgress = logger.progress('Creating artifacts');
for (final releaseArtifactPath in releaseArtifactPaths.entries) {
final arch = releaseArtifactPath.key;
final artifactPath = p.join(
extractedAarDirectory,
'jni',
arch.androidBuildPath,
'libapp.so',
);
logger.detail('Creating artifact for $artifactPath');
final patchArtifact = File(artifactPath);
final hash = _hashFn(await patchArtifact.readAsBytes());
try {
final diffPath = await artifactManager.createDiff(
releaseArtifactPath: releaseArtifactPath.value,
patchArtifactPath: artifactPath,
);
patchArtifactBundles[arch] = PatchArtifactBundle(
arch: arch.arch,
path: diffPath,
hash: hash,
size: await File(diffPath).length(),
);
} catch (error) {
createDiffProgress.fail('$error');
return null;
}
}
createDiffProgress.complete();
return patchArtifactBundles;
}
Future<Map<Arch, String>> _downloadReleaseArtifacts({
required Map<Arch, ReleaseArtifact> releaseArtifacts,
}) async {
final releaseArtifactPaths = <Arch, String>{};
final downloadReleaseArtifactProgress = logger.progress(
'Downloading release artifacts',
);
for (final releaseArtifact in releaseArtifacts.entries) {
try {
final releaseArtifactFile = await artifactManager.downloadFile(
Uri.parse(releaseArtifact.value.url),
);
releaseArtifactPaths[releaseArtifact.key] = releaseArtifactFile.path;
} catch (error) {
downloadReleaseArtifactProgress.fail('$error');
rethrow;
}
}
downloadReleaseArtifactProgress.complete();
return releaseArtifactPaths;
}
}
@@ -1,387 +0,0 @@
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:path/path.dart' as p;
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart';
import 'package:shorebird_cli/src/archive_analysis/archive_differ.dart';
import 'package:shorebird_cli/src/artifact_manager.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/config/shorebird_yaml.dart';
import 'package:shorebird_cli/src/deployment_track.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/extensions/arg_results.dart';
import 'package:shorebird_cli/src/formatters/formatters.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/patch_diff_checker.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 patch_android_command}
/// `shorebird patch android`
/// Publish new patches for a specific Android release to the Shorebird code
/// push server.
/// {@endtemplate}
class PatchAndroidCommand extends ShorebirdCommand
with ShorebirdBuildMixin, ShorebirdReleaseVersionMixin {
/// {@macro patch_android_command}
PatchAndroidCommand({
HashFunction? hashFn,
AndroidArchiveDiffer? archiveDiffer,
}) : _archiveDiffer = archiveDiffer ?? AndroidArchiveDiffer(),
_hashFn = hashFn ?? ((m) => sha256.convert(m).toString()) {
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(
'release-version',
help: '''
The version of the release being patched (e.g. "1.0.0+1").
If this option is not provided, the version number will be determined from the patch artifact.''',
)
..addFlag(
'allow-native-diffs',
help: PatchCommand.allowNativeDiffsHelpText,
negatable: false,
)
..addFlag(
'allow-asset-diffs',
help: PatchCommand.allowAssetDiffsHelpText,
negatable: false,
)
..addFlag(
'dry-run',
abbr: 'n',
negatable: false,
help: 'Validate but do not upload the patch.',
)
..addFlag(
'staging',
negatable: false,
help: 'Whether to publish the patch to the staging environment.',
);
}
@override
String get description =>
'Publish new patches for a specific Android release to Shorebird.';
@override
String get name => 'android';
final ArchiveDiffer _archiveDiffer;
final HashFunction _hashFn;
@override
Future<int> run() async {
try {
await shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: true,
checkShorebirdInitialized: true,
validators: doctor.androidCommandValidators,
);
} on PreconditionFailedException catch (e) {
return e.exitCode.code;
}
final allowAssetDiffs = results['allow-asset-diffs'] == true;
final allowNativeDiffs = results['allow-native-diffs'] == true;
final dryRun = results['dry-run'] == true;
final isStaging = results['staging'] == true;
await cache.updateAll();
const releasePlatform = ReleasePlatform.android;
final flavor = results.findOption('flavor', argParser: argParser);
final target = results.findOption('target', argParser: argParser);
final shorebirdYaml = shorebirdEnv.getShorebirdYaml()!;
final appId = shorebirdYaml.getAppId(flavor: flavor);
final app = await codePushClientWrapper.getApp(appId: appId);
var hasBuiltWithActiveFlutter = false;
late File aabFile;
final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!;
final String releaseVersion;
final argReleaseVersion = results['release-version'] as String?;
if (argReleaseVersion != null) {
logger.detail('Using release version $argReleaseVersion from argument.');
releaseVersion = argReleaseVersion;
} else {
logger.detail('No release version provided. Determining from bundle.');
final flutterVersionString =
await shorebirdFlutter.getVersionAndRevision();
final buildProgress = logger.progress(
'Building patch with Flutter $flutterVersionString',
);
try {
aabFile = await buildAppBundle(flavor: flavor, target: target);
buildProgress.complete();
} on BuildException catch (error) {
buildProgress.fail(error.message);
return ExitCode.software.code;
}
hasBuiltWithActiveFlutter = true;
final detectReleaseVersionProgress = logger.progress(
'Detecting release version',
);
try {
releaseVersion = await extractReleaseVersionFromAppBundle(aabFile.path);
detectReleaseVersionProgress.complete(
'Detected release version $releaseVersion',
);
} catch (error) {
detectReleaseVersionProgress.fail('$error');
return ExitCode.software.code;
}
}
final release = await codePushClientWrapper.getRelease(
appId: appId,
releaseVersion: releaseVersion,
);
if (release.platformStatuses[ReleasePlatform.android] ==
ReleaseStatus.draft) {
logger.err('''
Release $releaseVersion is in an incomplete state. It's possible that the original release was terminated or failed to complete.
Please re-run the release command for this version or create a new release.''');
return ExitCode.software.code;
}
final currentFlutterRevision = shorebirdEnv.flutterRevision;
if (release.flutterRevision != currentFlutterRevision) {
logger.info(
'Release was built with Flutter revision ${release.flutterRevision}',
);
}
try {
await shorebirdFlutter.installRevision(revision: release.flutterRevision);
} catch (_) {
return ExitCode.software.code;
}
final releaseFlutterShorebirdEnv = shorebirdEnv.copyWith(
flutterRevisionOverride: release.flutterRevision,
);
return await runScoped(
() async {
if (!hasBuiltWithActiveFlutter ||
currentFlutterRevision != release.flutterRevision) {
final buildProgress = logger.progress('Building patch');
try {
aabFile = await buildAppBundle(flavor: flavor, target: target);
buildProgress.complete();
} on BuildException catch (error) {
buildProgress.fail(error.message);
return ExitCode.software.code;
}
}
final patchArchsBuildDir = ArtifactManager.androidArchsDirectory(
projectRoot: projectRoot,
flavor: flavor,
);
if (patchArchsBuildDir == null) {
logger
..err('Cannot find patch build artifacts.')
..info(
'''
Please run `shorebird cache clean` and try again. If the issue persists, please
file a bug report at https://github.com/shorebirdtech/shorebird/issues/new.
Looked in:
- build/app/intermediates/stripped_native_libs/stripReleaseDebugSymbols/release/out/lib
- build/app/intermediates/stripped_native_libs/strip{flavor}ReleaseDebugSymbols/{flavor}Release/out/lib
- build/app/intermediates/stripped_native_libs/release/out/lib
- build/app/intermediates/stripped_native_libs/{flavor}Release/out/lib''',
);
return ExitCode.software.code;
}
final releaseArtifacts =
await codePushClientWrapper.getReleaseArtifacts(
appId: app.appId,
releaseId: release.id,
architectures: AndroidArch.availableAndroidArchs,
platform: releasePlatform,
);
final releaseAabArtifact =
await codePushClientWrapper.getReleaseArtifact(
appId: app.appId,
releaseId: release.id,
arch: 'aab',
platform: releasePlatform,
);
final releaseArtifactPaths = <Arch, String>{};
final downloadReleaseArtifactProgress = logger.progress(
'Downloading release artifacts',
);
for (final releaseArtifact in releaseArtifacts.entries) {
try {
final releaseArtifactFile = await artifactManager.downloadFile(
Uri.parse(releaseArtifact.value.url),
);
releaseArtifactPaths[releaseArtifact.key] =
releaseArtifactFile.path;
} catch (error) {
downloadReleaseArtifactProgress.fail('$error');
return ExitCode.software.code;
}
}
final releaseAabArtifactFile = await artifactManager.downloadFile(
Uri.parse(releaseAabArtifact.url),
);
downloadReleaseArtifactProgress.complete();
final DiffStatus diffStatus;
try {
diffStatus =
await patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
localArtifact: aabFile,
releaseArtifact: releaseAabArtifactFile,
archiveDiffer: _archiveDiffer,
allowAssetChanges: allowAssetDiffs,
allowNativeChanges: allowNativeDiffs,
);
} on UserCancelledException {
return ExitCode.success.code;
} on UnpatchableChangeException {
logger.info('Exiting.');
return ExitCode.software.code;
}
final patchArtifactBundles = <Arch, PatchArtifactBundle>{};
final createDiffProgress = logger.progress('Creating artifacts');
for (final releaseArtifactPath in releaseArtifactPaths.entries) {
final arch = releaseArtifactPath.key;
final patchArtifactPath = p.join(
patchArchsBuildDir.path,
arch.androidBuildPath,
'libapp.so',
);
logger.detail('Creating artifact for $patchArtifactPath');
final patchArtifact = File(patchArtifactPath);
final hash = _hashFn(await patchArtifact.readAsBytes());
try {
final diffPath = await artifactManager.createDiff(
releaseArtifactPath: releaseArtifactPath.value,
patchArtifactPath: patchArtifactPath,
);
patchArtifactBundles[releaseArtifactPath.key] = PatchArtifactBundle(
arch: arch.arch,
path: diffPath,
hash: hash,
size: await File(diffPath).length(),
);
} catch (error) {
createDiffProgress.fail('$error');
return ExitCode.software.code;
}
}
createDiffProgress.complete();
final archMetadata = patchArtifactBundles.keys.map((arch) {
final size = formatBytes(patchArtifactBundles[arch]!.size);
return '${arch.name} ($size)';
});
if (dryRun) {
logger
..info('No issues detected.')
..info('The server may enforce additional checks.');
return ExitCode.success.code;
}
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('[${archMetadata.join(', ')}]')}''',
if (isStaging)
'🟠 Track: ${lightCyan.wrap('Staging')}'
else
'🟢 Track: ${lightCyan.wrap('Production')}',
];
logger.info(
'''
${styleBold.wrap(lightGreen.wrap('🚀 Ready to publish a new patch!'))}
${summary.join('\n')}
''',
);
if (shorebirdEnv.canAcceptUserInput) {
final confirm = logger.confirm('Would you like to continue?');
if (!confirm) {
logger.info('Aborting.');
return ExitCode.success.code;
}
}
await codePushClientWrapper.publishPatch(
appId: appId,
releaseId: release.id,
metadata: CreatePatchMetadata(
releasePlatform: releasePlatform,
usedIgnoreAssetChangesFlag: allowAssetDiffs,
hasAssetChanges: diffStatus.hasAssetChanges,
usedIgnoreNativeChangesFlag: allowNativeDiffs,
hasNativeChanges: diffStatus.hasNativeChanges,
linkPercentage: null,
environment: BuildEnvironmentMetadata(
operatingSystem: platform.operatingSystem,
operatingSystemVersion: platform.operatingSystemVersion,
shorebirdVersion: packageVersion,
xcodeVersion: null,
),
),
platform: releasePlatform,
track:
isStaging ? DeploymentTrack.staging : DeploymentTrack.production,
patchArtifactBundles: patchArtifactBundles,
);
return ExitCode.success.code;
},
values: {
shorebirdEnvRef.overrideWith(() => releaseFlutterShorebirdEnv),
},
);
}
}
@@ -1,18 +1,96 @@
import 'package:mason_logger/mason_logger.dart';
import 'package:meta/meta.dart';
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/archive_analysis/archive_differ.dart';
import 'package:shorebird_cli/src/artifact_manager.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/patch/patch.dart';
import 'package:shorebird_cli/src/config/config.dart';
import 'package:shorebird_cli/src/deployment_track.dart';
import 'package:shorebird_cli/src/extensions/arg_results.dart';
import 'package:shorebird_cli/src/formatters/formatters.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/patch_diff_checker.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/third_party/flutter_tools/lib/flutter_tools.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
typedef ResolvePatcher = Patcher Function(ReleaseType releaseType);
/// {@template patch_command}
/// `shorebird patch`
/// Create new app release patch.
/// {@endtemplate}
class PatchCommand extends ShorebirdCommand {
/// {@macro patch_command}
PatchCommand() {
addSubcommand(PatchAarCommand());
addSubcommand(PatchAndroidCommand());
addSubcommand(PatchIosCommand());
addSubcommand(PatchIosFrameworkCommand());
PatchCommand({
ResolvePatcher? resolvePatcher,
}) {
_resolvePatcher = resolvePatcher ?? getPatcher;
argParser
..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(
'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',
)
..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(
'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.''',
)
..addFlag(
'allow-native-diffs',
help: allowNativeDiffsHelpText,
negatable: false,
)
..addFlag(
'allow-asset-diffs',
help: allowAssetDiffsHelpText,
negatable: false,
)
..addFlag(
'staging',
negatable: false,
help: 'Whether to publish the patch to the staging environment.',
)
..addOption(
exportOptionsPlistArgName,
help:
'''Export an IPA with these options. See "xcodebuild -h" for available exportOptionsPlist keys (iOS only).''',
)
..addFlag(
'codesign',
help: 'Codesign the application bundle (iOS only).',
defaultsTo: true,
)
..addFlag(
'dry-run',
abbr: 'n',
negatable: false,
help: 'Validate but do not upload the patch.',
);
}
static final allowNativeDiffsHelpText = '''
@@ -23,10 +101,261 @@ NOTE: this is ${styleBold.wrap('not')} recommended. Native code changes cannot b
Patch even if asset diffs are detected.
NOTE: this is ${styleBold.wrap('not')} recommended. Asset changes cannot be included in a patch can cause your app to behave unexpectedly.''';
late final ResolvePatcher _resolvePatcher;
@override
String get description =>
'Manage patches for a specific release in Shorebird.';
'Creates a shorebird patch for the provided target platforms';
@override
String get name => 'patch';
/// 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);
bool get isStaging => results['staging'] == true;
@override
Future<int> run() async {
final patcherFutures =
results.releaseTypes.map(_resolvePatcher).map(createPatch);
for (final patcherFuture in patcherFutures) {
await patcherFuture;
}
return ExitCode.success.code;
}
@visibleForTesting
Patcher getPatcher(ReleaseType releaseType) {
switch (releaseType) {
case ReleaseType.android:
return AndroidPatcher(
argResults: results,
flavor: flavor,
target: target,
);
case ReleaseType.ios:
return IosPatcher(
argResults: results,
flavor: flavor,
target: target,
);
case ReleaseType.iosFramework:
return IosFrameworkPatcher(
argResults: results,
flavor: flavor,
target: target,
);
case ReleaseType.aar:
return AarPatcher(
argResults: results,
flavor: flavor,
target: target,
);
}
}
bool get allowAssetDiffs => results['allow-asset-diffs'] == true;
bool get allowNativeDiffs => results['allow-native-diffs'] == true;
String? lastBuiltFlutterRevision;
@visibleForTesting
Future<void> createPatch(Patcher patcher) async {
await patcher.assertPreconditions();
await patcher.assertArgsAreValid();
await cache.updateAll();
final app = await codePushClientWrapper.getApp(appId: appId);
File? patchArtifact;
final String releaseVersion;
if (results.wasParsed('release-version')) {
releaseVersion = results['release-version'] as String;
} else {
patchArtifact = await patcher.buildPatchArtifact();
lastBuiltFlutterRevision = shorebirdEnv.flutterRevision;
releaseVersion = await patcher.extractReleaseVersionFromArtifact(
patchArtifact,
);
}
final release = await getRelease(
releaseVersion: releaseVersion,
patcher: patcher,
);
final releaseArtifact = await downloadPrimaryReleaseArtifact(
release: release,
patcher: patcher,
);
final releaseFlutterShorebirdEnv = shorebirdEnv.copyWith(
flutterRevisionOverride: release.flutterRevision,
);
return await runScoped(
() async {
await cache.updateAll();
// Don't built the patch artifact twice with the same Flutter revision.
if (lastBuiltFlutterRevision != release.flutterRevision) {
patchArtifact = await patcher.buildPatchArtifact();
}
final diffStatus = await assertUnpatchableDiffs(
releaseArtifact: releaseArtifact,
patchArtifact: patchArtifact!,
archiveDiffer: patcher.archiveDiffer,
);
final patchArtifactBundles = await patcher.createPatchArtifacts(
appId: appId,
releaseId: release.id,
);
final dryRun = results['dry-run'] == true;
if (dryRun) {
logger
..info('No issues detected.')
..info('The server may enforce additional checks.');
exit(ExitCode.success.code);
}
await confirmCreatePatch(
app: app,
releaseVersion: releaseVersion,
patcher: patcher,
patchArtifactBundles: patchArtifactBundles,
);
await codePushClientWrapper.publishPatch(
appId: appId,
releaseId: release.id,
metadata: await patcher.createPatchMetadata(diffStatus),
platform: patcher.releaseType.releasePlatform,
track:
isStaging ? DeploymentTrack.staging : DeploymentTrack.production,
patchArtifactBundles: patchArtifactBundles,
);
},
values: {
shorebirdEnvRef.overrideWith(() => releaseFlutterShorebirdEnv),
},
);
}
Future<DiffStatus> assertUnpatchableDiffs({
required File releaseArtifact,
required File patchArtifact,
required ArchiveDiffer archiveDiffer,
}) async {
try {
return patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
localArtifact: patchArtifact,
releaseArtifact: releaseArtifact,
archiveDiffer: archiveDiffer,
allowAssetChanges: allowAssetDiffs,
allowNativeChanges: allowNativeDiffs,
);
} on UserCancelledException {
exit(ExitCode.success.code);
} on UnpatchableChangeException {
logger.info('Exiting.');
exit(ExitCode.software.code);
}
}
Future<void> confirmCreatePatch({
required AppMetadata app,
required String releaseVersion,
required Patcher patcher,
required Map<Arch, PatchArtifactBundle> patchArtifactBundles,
}) async {
final archMetadata = patchArtifactBundles.keys.map((arch) {
final size = formatBytes(patchArtifactBundles[arch]!.size);
return '${arch.name} ($size)';
});
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(patcher.releaseType.releasePlatform.name)} ${lightCyan.wrap('[${archMetadata.join(', ')}]')}''',
if (isStaging)
'🟠 Track: ${lightCyan.wrap('Staging')}'
else
'🟢 Track: ${lightCyan.wrap('Production')}',
];
logger.info(
'''
${styleBold.wrap(lightGreen.wrap('🚀 Ready to publish a new patch!'))}
${summary.join('\n')}
''',
);
if (shorebirdEnv.canAcceptUserInput) {
final confirm = logger.confirm('Would you like to continue?');
if (!confirm) {
logger.info('Aborting.');
exit(ExitCode.success.code);
}
}
}
Future<Release> getRelease({
required String releaseVersion,
required Patcher patcher,
}) async {
final release = await codePushClientWrapper.getRelease(
appId: appId,
releaseVersion: releaseVersion,
);
final releaseStatus =
release.platformStatuses[patcher.releaseType.releasePlatform];
if (releaseStatus != ReleaseStatus.active) {
logger.err('''
Release ${release.version} is in an incomplete state. It's possible that the original release was terminated or failed to complete.
Please re-run the release command for this version or create a new release.''');
exit(ExitCode.software.code);
}
return release;
}
Future<File> downloadPrimaryReleaseArtifact({
required Release release,
required Patcher patcher,
}) async {
final artifact = await codePushClientWrapper.getReleaseArtifact(
appId: appId,
releaseId: release.id,
arch: patcher.primaryReleaseArtifactArch,
platform: patcher.releaseType.releasePlatform,
);
final downloadProgress =
logger.progress('Downloading ${patcher.primaryReleaseArtifactArch}');
final File artifactFile;
try {
artifactFile =
await artifactManager.downloadFile(Uri.parse(artifact.url));
} catch (e) {
downloadProgress.fail(e.toString());
exit(ExitCode.software.code);
}
downloadProgress.complete();
return artifactFile;
}
}
@@ -1,600 +0,0 @@
import 'dart:async';
import 'dart:io' hide Platform;
import 'package:crypto/crypto.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:path/path.dart' as p;
import 'package:platform/platform.dart';
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/archive/directory_archive.dart';
import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart';
import 'package:shorebird_cli/src/artifact_manager.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/config/config.dart';
import 'package:shorebird_cli/src/deployment_track.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/executables/executables.dart';
import 'package:shorebird_cli/src/extensions/arg_results.dart';
import 'package:shorebird_cli/src/formatters/file_size_formatter.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/patch_diff_checker.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_artifacts.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 patch_ios_command}
/// `shorebird patch ios` command.
/// {@endtemplate}
class PatchIosCommand extends ShorebirdCommand
with ShorebirdBuildMixin, ShorebirdArtifactMixin {
/// {@macro patch_ios_command}
PatchIosCommand({
HashFunction? hashFn,
IosArchiveDiffer? archiveDiffer,
}) : _hashFn = hashFn ?? ((m) => sha256.convert(m).toString()),
_archiveDiffer = archiveDiffer ?? IosArchiveDiffer() {
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(
'release-version',
help: '''
The version of the release being patched (e.g. "1.0.0+1").
If this option is not provided, the version number will be determined from the patch artifact.''',
)
..addFlag(
'codesign',
help: 'Codesign the application bundle.',
defaultsTo: true,
)
..addOption(
exportOptionsPlistArgName,
help:
'''Export an IPA with these options. See "xcodebuild -h" for available exportOptionsPlist keys.''',
)
..addFlag(
'allow-native-diffs',
help: PatchCommand.allowNativeDiffsHelpText,
negatable: false,
)
..addFlag(
'allow-asset-diffs',
help: PatchCommand.allowAssetDiffsHelpText,
negatable: false,
)
..addFlag(
'dry-run',
abbr: 'n',
negatable: false,
help: 'Validate but do not upload the patch.',
)
..addFlag(
'staging',
negatable: false,
help: 'Whether to publish the patch to the staging environment.',
)
..addFlag(
'debug-linker',
defaultsTo: true,
help: 'Collects linker diagnostic information to help troubleshoot low '
'link percentages. File is saved to build/$_linkDebugInfoFileName.',
);
}
@override
String get name => 'ios';
@override
List<String> get aliases => ['ios-alpha'];
@override
String get description =>
'Publish new patches for a specific iOS release to Shorebird.';
final HashFunction _hashFn;
final IosArchiveDiffer _archiveDiffer;
// Link percentage that is considered the minimum before a user might notice.
// Our early testing has shown that about:
// - 1/3rd of patches link at 99%
// - 1/3rd of patches link between 20% and 99%
// - 1/3rd of patches link below 20%
// Most lowering is likely due to:
// https://github.com/shorebirdtech/shorebird/issues/1825
static const double minLinkPercentage = 75;
static String lowLinkPercentageWarning(double linkPercentage) {
return '''
${lightCyan.wrap('shorebird patch')} was only able to share ${linkPercentage.toStringAsFixed(1)}% of Dart code with the released app.
This means the patched code may execute slower than expected.
https://docs.shorebird.dev/status#link-percentage-ios
''';
}
@override
Future<int> run() async {
try {
await shorebirdValidator.validatePreconditions(
checkShorebirdInitialized: true,
checkUserIsAuthenticated: true,
validators: doctor.iosCommandValidators,
supportedOperatingSystems: {Platform.macOS},
);
} on PreconditionFailedException catch (error) {
return error.exitCode.code;
}
final allowAssetDiffs = results['allow-asset-diffs'] == true;
final allowNativeDiffs = results['allow-native-diffs'] == true;
final dryRun = results['dry-run'] == true;
final isStaging = results['staging'] == true;
const arch = 'aarch64';
const releasePlatform = ReleasePlatform.ios;
final flavor = results.findOption('flavor', argParser: argParser);
final target = results.findOption('target', argParser: argParser);
final shorebirdYaml = shorebirdEnv.getShorebirdYaml()!;
final appId = shorebirdYaml.getAppId(flavor: flavor);
final app = await codePushClientWrapper.getApp(appId: appId);
var hasBuiltWithActiveFlutter = false;
final File exportOptionsPlist;
try {
exportOptionsPlist = ios.exportOptionsPlistFromArgs(results);
} catch (error) {
logger.err('$error');
return ExitCode.usage.code;
}
final String releaseVersion;
final argReleaseVersion = results['release-version'] as String?;
if (argReleaseVersion != null) {
logger.detail('Using release version $argReleaseVersion from argument.');
releaseVersion = argReleaseVersion;
} else {
logger.detail('No release version provided. Determining from archive.');
try {
await _buildPatch(
exportOptionsPlist: exportOptionsPlist,
flavor: flavor,
target: target,
);
} catch (_) {
return ExitCode.software.code;
}
hasBuiltWithActiveFlutter = true;
try {
releaseVersion = _readVersionFromPlist();
logger.info('Detected release version $releaseVersion');
} on _ReadVersionException catch (error) {
logger.err(error.message);
return ExitCode.software.code;
}
}
final release = await codePushClientWrapper.getRelease(
appId: appId,
releaseVersion: releaseVersion,
);
if (release.platformStatuses[ReleasePlatform.ios] == null) {
logger.err('No iOS release found for $releaseVersion.');
return ExitCode.software.code;
} else if (release.platformStatuses[ReleasePlatform.ios] ==
ReleaseStatus.draft) {
logger.err('''
Release $releaseVersion is in an incomplete state. It's possible that the original release was terminated or failed to complete.
Please re-run the release command for this version or create a new release.''');
return ExitCode.software.code;
}
final currentFlutterRevision = shorebirdEnv.flutterRevision;
if (release.flutterRevision != currentFlutterRevision) {
logger.info(
'Release was built with Flutter revision ${release.flutterRevision}',
);
}
try {
await shorebirdFlutter.installRevision(revision: release.flutterRevision);
} catch (_) {
return ExitCode.software.code;
}
final releaseFlutterShorebirdEnv = shorebirdEnv.copyWith(
flutterRevisionOverride: release.flutterRevision,
);
return await runScoped(
() async {
if (!hasBuiltWithActiveFlutter ||
release.flutterRevision != currentFlutterRevision) {
try {
await _buildPatch(
exportOptionsPlist: exportOptionsPlist,
flavor: flavor,
target: target,
);
} catch (_) {
return ExitCode.software.code;
}
}
final archivePath = getXcarchiveDirectory()?.path;
if (archivePath == null) {
logger.err('Unable to find .xcarchive directory');
return ExitCode.software.code;
}
final releaseArtifact = await codePushClientWrapper.getReleaseArtifact(
appId: appId,
releaseId: release.id,
arch: 'xcarchive',
platform: ReleasePlatform.ios,
);
final downloadProgress =
logger.progress('Downloading release artifact');
final File releaseArtifactZipFile;
try {
releaseArtifactZipFile = await artifactManager.downloadFile(
Uri.parse(releaseArtifact.url),
);
if (!releaseArtifactZipFile.existsSync()) {
throw Exception('Failed to download release artifact');
}
} catch (error) {
downloadProgress.fail('$error');
return ExitCode.software.code;
}
downloadProgress.complete();
final DiffStatus diffStatus;
try {
diffStatus =
await patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary(
localArtifactDirectory: Directory(archivePath),
releaseArtifact: releaseArtifactZipFile,
archiveDiffer: _archiveDiffer,
allowAssetChanges: allowAssetDiffs,
allowNativeChanges: allowNativeDiffs,
);
} on UserCancelledException {
return ExitCode.success.code;
} on UnpatchableChangeException {
logger.info('Exiting.');
return ExitCode.software.code;
}
final unzipProgress = logger.progress('Extracting release artifact');
final tempDir = Directory.systemTemp.createTempSync();
await artifactManager.extractZip(
zipFile: releaseArtifactZipFile,
outputDirectory: tempDir,
);
final releaseXcarchivePath = tempDir.path;
unzipProgress.complete();
final appDirectory = getAppDirectory(
xcarchiveDirectory: Directory(releaseXcarchivePath),
);
if (appDirectory == null) {
logger.err('Unable to find release artifact .app directory');
return ExitCode.software.code;
}
final releaseArtifactFile = File(
p.join(
appDirectory.path,
'Frameworks',
'App.framework',
'App',
),
);
double? percentLinked;
final useLinker = AotTools.usesLinker(release.flutterRevision);
if (useLinker) {
final (:exitCode, :linkPercentage) = await _runLinker(
releaseArtifact: releaseArtifactFile,
);
if (exitCode != ExitCode.success.code) return exitCode;
if (linkPercentage != null && linkPercentage < minLinkPercentage) {
logger.warn(lowLinkPercentageWarning(linkPercentage));
}
percentLinked = linkPercentage;
}
if (dryRun) {
logger
..info('No issues detected.')
..info('The server may enforce additional checks.');
return ExitCode.success.code;
}
final patchBuildFile =
File(useLinker ? _vmcodeOutputPath : _aotOutputPath);
final File patchFile;
if (useLinker && await aotTools.isGeneratePatchDiffBaseSupported()) {
final patchBaseProgress =
logger.progress('Generating patch diff base');
final analyzeSnapshotPath = shorebirdArtifacts.getArtifactPath(
artifact: ShorebirdArtifact.analyzeSnapshot,
);
final File patchBaseFile;
try {
// If the aot_tools executable supports the dump_blobs command, we
// can generate a stable diff base and use that to create a patch.
patchBaseFile = await aotTools.generatePatchDiffBase(
analyzeSnapshotPath: analyzeSnapshotPath,
releaseSnapshot: releaseArtifactFile,
);
patchBaseProgress.complete();
} catch (error) {
patchBaseProgress.fail('$error');
return ExitCode.software.code;
}
patchFile = File(
await artifactManager.createDiff(
releaseArtifactPath: patchBaseFile.path,
patchArtifactPath: patchBuildFile.path,
),
);
} else {
patchFile = patchBuildFile;
}
final patchFileSize = patchFile.statSync().size;
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)} ${lightCyan.wrap('[$arch (${formatBytes(patchFileSize)})]')}''',
if (isStaging)
'🟠 Track: ${lightCyan.wrap('Staging')}'
else
'🟢 Track: ${lightCyan.wrap('Production')}',
if (percentLinked != null)
'''🔗 Running ${lightCyan.wrap('${percentLinked.toStringAsFixed(1)}%')} on CPU''',
if (results['debug-linker'] == true &&
(percentLinked != null && percentLinked < minLinkPercentage))
'''🔍 Debug Info: ${lightCyan.wrap(_debugInfoOutputPath)}''',
];
logger.info(
'''
${styleBold.wrap(lightGreen.wrap('🚀 Ready to publish a new patch!'))}
${summary.join('\n')}
''',
);
if (shorebirdEnv.canAcceptUserInput) {
final confirm = logger.confirm('Would you like to continue?');
if (!confirm) {
logger.info('Aborting.');
return ExitCode.success.code;
}
}
await codePushClientWrapper.publishPatch(
appId: appId,
releaseId: release.id,
platform: releasePlatform,
track:
isStaging ? DeploymentTrack.staging : DeploymentTrack.production,
patchArtifactBundles: {
Arch.arm64: PatchArtifactBundle(
arch: arch,
path: patchFile.path,
hash: _hashFn(patchBuildFile.readAsBytesSync()),
size: patchFileSize,
),
},
metadata: CreatePatchMetadata(
releasePlatform: releasePlatform,
usedIgnoreAssetChangesFlag: allowAssetDiffs,
hasAssetChanges: diffStatus.hasAssetChanges,
usedIgnoreNativeChangesFlag: allowNativeDiffs,
hasNativeChanges: diffStatus.hasNativeChanges,
linkPercentage: percentLinked,
environment: BuildEnvironmentMetadata(
operatingSystem: platform.operatingSystem,
operatingSystemVersion: platform.operatingSystemVersion,
shorebirdVersion: packageVersion,
xcodeVersion: await xcodeBuild.version(),
),
),
);
return ExitCode.success.code;
},
values: {
shorebirdEnvRef.overrideWith(() => releaseFlutterShorebirdEnv),
},
);
}
String get _buildDirectory => p.join(
shorebirdEnv.getShorebirdProjectRoot()!.path,
'build',
);
String get _aotOutputPath => p.join(
_buildDirectory,
'out.aot',
);
String get _vmcodeOutputPath => p.join(
_buildDirectory,
'out.vmcode',
);
static const _linkDebugInfoFileName = 'linker_diagnostic.zip';
String get _debugInfoOutputPath => p.join(
_buildDirectory,
_linkDebugInfoFileName,
);
String _readVersionFromPlist() {
final archivePath = getXcarchiveDirectory()?.path;
if (archivePath == null) {
throw _ReadVersionException('Unable to find .xcarchive directory');
}
final plistFile = File(p.join(archivePath, 'Info.plist'));
if (!plistFile.existsSync()) {
throw _ReadVersionException(
'No Info.plist file found at ${plistFile.path}.',
);
}
final plist = Plist(file: plistFile);
try {
return plist.versionNumber;
} catch (error) {
throw _ReadVersionException(
'Failed to determine release version from ${plistFile.path}: $error',
);
}
}
Future<void> _buildPatch({
required File exportOptionsPlist,
required String? flavor,
required String? target,
}) async {
final shouldCodesign = results['codesign'] == true;
final flutterVersionString = await shorebirdFlutter.getVersionAndRevision();
final buildProgress = logger.progress(
'Building patch with Flutter $flutterVersionString',
);
try {
// If buildIpa is called with a different codesign value than the release
// was, we will erroneously report native diffs.
await buildIpa(
codesign: shouldCodesign,
exportOptionsPlist: exportOptionsPlist,
flavor: flavor,
target: target,
);
} on ProcessException catch (error) {
buildProgress.fail('Failed to build: ${error.message}');
rethrow;
} on BuildException catch (error) {
buildProgress.fail('Failed to build IPA');
logger.err(error.message);
rethrow;
}
try {
final newestDillFile = newestAppDill();
await buildElfAotSnapshot(
appDillPath: newestDillFile.path,
outFilePath: _aotOutputPath,
);
} catch (error) {
buildProgress.fail('$error');
rethrow;
}
buildProgress.complete();
}
Future<_LinkResult> _runLinker({
required File releaseArtifact,
}) async {
final patch = File(_aotOutputPath);
final dumpDebugInfo = results['debug-linker'] == true &&
(await aotTools.isLinkDebugInfoSupported());
if (!patch.existsSync()) {
logger.err('Unable to find patch AOT file at ${patch.path}');
return (exitCode: ExitCode.software.code, linkPercentage: null);
}
final analyzeSnapshot = File(
shorebirdArtifacts.getArtifactPath(
artifact: ShorebirdArtifact.analyzeSnapshot,
),
);
if (!analyzeSnapshot.existsSync()) {
logger.err('Unable to find analyze_snapshot at ${analyzeSnapshot.path}');
return (exitCode: ExitCode.software.code, linkPercentage: null);
}
final genSnapshot = shorebirdArtifacts.getArtifactPath(
artifact: ShorebirdArtifact.genSnapshot,
);
final linkProgress = logger.progress('Linking AOT files');
double? linkPercentage;
try {
final dumpDebugInfoDir =
dumpDebugInfo ? Directory.systemTemp.createTempSync() : null;
linkPercentage = await aotTools.link(
base: releaseArtifact.path,
patch: patch.path,
analyzeSnapshot: analyzeSnapshot.path,
genSnapshot: genSnapshot,
outputPath: _vmcodeOutputPath,
workingDirectory: _buildDirectory,
kernel: newestAppDill().path,
dumpDebugInfoPath: dumpDebugInfoDir?.path,
);
if (dumpDebugInfo && dumpDebugInfoDir != null) {
final debugInfoZip = await dumpDebugInfoDir.zipToTempFile();
debugInfoZip.copySync(
p.join(
'build',
_debugInfoOutputPath,
),
);
}
} catch (error) {
linkProgress.fail('Failed to link AOT files: $error');
return (exitCode: ExitCode.software.code, linkPercentage: null);
}
linkProgress.complete();
return (exitCode: ExitCode.success.code, linkPercentage: linkPercentage);
}
}
typedef _LinkResult = ({int exitCode, double? linkPercentage});
/// {@template _ReadVersionException}
/// Exception thrown when the release version cannot be determined.
/// {@endtemplate}
class _ReadVersionException implements Exception {
/// {@macro _ReadVersionException}
_ReadVersionException(this.message);
final String message;
}
@@ -1,409 +0,0 @@
import 'dart:io' hide Platform;
import 'package:collection/collection.dart';
import 'package:crypto/crypto.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:path/path.dart' as p;
import 'package:platform/platform.dart';
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart';
import 'package:shorebird_cli/src/artifact_manager.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/config/shorebird_yaml.dart';
import 'package:shorebird_cli/src/deployment_track.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/executables/executables.dart';
import 'package:shorebird_cli/src/formatters/file_size_formatter.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/patch_diff_checker.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_artifacts.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 PatchIosFrameworkCommand extends ShorebirdCommand
with ShorebirdBuildMixin, ShorebirdArtifactMixin {
PatchIosFrameworkCommand({
HashFunction? hashFn,
IosArchiveDiffer? archiveDiffer,
}) : _hashFn = hashFn ?? ((m) => sha256.convert(m).toString()),
_archiveDiffer = archiveDiffer ?? IosArchiveDiffer() {
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.''',
)
..addFlag(
'allow-native-diffs',
help: PatchCommand.allowNativeDiffsHelpText,
negatable: false,
)
..addFlag(
'allow-asset-diffs',
help: PatchCommand.allowAssetDiffsHelpText,
negatable: false,
)
..addFlag(
'dry-run',
abbr: 'n',
negatable: false,
help: 'Validate but do not upload the patch.',
);
}
final HashFunction _hashFn;
final IosArchiveDiffer _archiveDiffer;
String get _buildDirectory => p.join(
shorebirdEnv.getShorebirdProjectRoot()!.path,
'build',
);
String get _vmcodeOutputPath => p.join(
_buildDirectory,
'out.vmcode',
);
@override
String get name => 'ios-framework';
@override
List<String> get aliases => ['ios-framework-alpha'];
@override
String get description =>
'Publish new patches for a specific iOS framework release to Shorebird.';
@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;
}
final allowAssetDiffs = results['allow-asset-diffs'] == true;
final allowNativeDiffs = results['allow-native-diffs'] == true;
final dryRun = results['dry-run'] == true;
const arch = 'aarch64';
const releasePlatform = ReleasePlatform.ios;
final shorebirdYaml = shorebirdEnv.getShorebirdYaml()!;
final appId = shorebirdYaml.getAppId();
final app = await codePushClientWrapper.getApp(appId: appId);
final releases = await codePushClientWrapper.getReleases(appId: appId);
if (releases.isEmpty) {
logger.info('No releases found');
return ExitCode.success.code;
}
final releaseVersion = results['release-version'] as String? ??
await _promptForReleaseVersion(releases);
final release = releases.firstWhereOrNull(
(r) => r.version == releaseVersion,
);
if (releaseVersion == null || release == null) {
logger.info('''
No release found for version $releaseVersion
Available release versions:
${releases.map((r) => r.version).join('\n')}''');
return ExitCode.success.code;
}
if (release.platformStatuses[ReleasePlatform.ios] == ReleaseStatus.draft) {
logger.err('''
Release $releaseVersion is in an incomplete state. It's possible that the original release was terminated or failed to complete.
Please re-run the release command for this version or create a new release.''');
return ExitCode.software.code;
}
try {
await shorebirdFlutter.installRevision(revision: release.flutterRevision);
} catch (_) {
return ExitCode.software.code;
}
final releaseFlutterShorebirdEnv = shorebirdEnv.copyWith(
flutterRevisionOverride: release.flutterRevision,
);
return await runScoped(
() async {
final flutterVersionString =
await shorebirdFlutter.getVersionAndRevision();
final buildProgress = logger.progress(
'Building patch with Flutter $flutterVersionString',
);
try {
await buildIosFramework();
buildProgress.complete();
} on ProcessException catch (error) {
buildProgress.fail('Failed to build: ${error.message}');
return ExitCode.software.code;
}
final File aotSnapshotFile;
try {
final newestDillFile = newestAppDill();
aotSnapshotFile = await buildElfAotSnapshot(
appDillPath: newestDillFile.path,
outFilePath: p.join(
shorebirdEnv.getShorebirdProjectRoot()!.path,
'build',
'out.aot',
),
);
} catch (error) {
buildProgress.fail('$error');
return ExitCode.software.code;
}
buildProgress.complete();
final releaseArtifact = await codePushClientWrapper.getReleaseArtifact(
appId: appId,
releaseId: release.id,
arch: 'xcframework',
platform: ReleasePlatform.ios,
);
final downloadProgress =
logger.progress('Downloading release artifact');
final File releaseArtifactZipFile;
try {
releaseArtifactZipFile = await artifactManager.downloadFile(
Uri.parse(releaseArtifact.url),
);
if (!releaseArtifactZipFile.existsSync()) {
throw Exception('Failed to download release artifact');
}
} catch (error) {
downloadProgress.fail('$error');
return ExitCode.software.code;
}
downloadProgress.complete();
final DiffStatus diffStatus;
try {
diffStatus =
await patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary(
localArtifactDirectory: Directory(getAppXcframeworkPath()),
releaseArtifact: releaseArtifactZipFile,
archiveDiffer: _archiveDiffer,
allowAssetChanges: allowAssetDiffs,
allowNativeChanges: allowNativeDiffs,
);
} on UserCancelledException {
return ExitCode.success.code;
} on UnpatchableChangeException {
logger.info('Exiting.');
return ExitCode.software.code;
}
final unzipProgress = logger.progress('Extracting release artifact');
final tempDir = Directory.systemTemp.createTempSync();
await artifactManager.extractZip(
zipFile: releaseArtifactZipFile,
outputDirectory: tempDir,
);
final releaseXcframeworkPath = tempDir.path;
unzipProgress
.complete('Extracted release artifact to $releaseXcframeworkPath');
final releaseArtifactFile = File(
p.join(
releaseXcframeworkPath,
'ios-arm64',
'App.framework',
'App',
),
);
final useLinker = AotTools.usesLinker(release.flutterRevision);
if (useLinker) {
final exitCode = await _runLinker(
aotSnapshot: aotSnapshotFile,
releaseArtifact: releaseArtifactFile,
);
if (exitCode != ExitCode.success.code) {
return exitCode;
}
}
final patchBuildFile =
useLinker ? File(_vmcodeOutputPath) : aotSnapshotFile;
final File patchFile;
if (await aotTools.isGeneratePatchDiffBaseSupported()) {
final patchBaseProgress =
logger.progress('Generating patch diff base');
final analyzeSnapshotPath = shorebirdArtifacts.getArtifactPath(
artifact: ShorebirdArtifact.analyzeSnapshot,
);
final File patchBaseFile;
try {
// If the aot_tools executable supports the dump_blobs command, we
// can generate a stable diff base and use that to create a patch.
patchBaseFile = await aotTools.generatePatchDiffBase(
analyzeSnapshotPath: analyzeSnapshotPath,
releaseSnapshot: releaseArtifactFile,
);
patchBaseProgress.complete();
} catch (error) {
patchBaseProgress.fail('$error');
return ExitCode.software.code;
}
patchFile = File(
await artifactManager.createDiff(
releaseArtifactPath: patchBaseFile.path,
patchArtifactPath: patchBuildFile.path,
),
);
} else {
patchFile = patchBuildFile;
}
if (dryRun) {
logger
..info('No issues detected.')
..info('The server may enforce additional checks.');
return ExitCode.success.code;
}
final patchFileSize = patchFile.statSync().size;
final summary = [
'''📱 App: ${lightCyan.wrap(app.displayName)} ${lightCyan.wrap('($appId)')}''',
'📦 Release Version: ${lightCyan.wrap(releaseVersion)}',
'''🕹️ Platform: ${lightCyan.wrap(releasePlatform.name)} ${lightCyan.wrap('[$arch (${formatBytes(patchFileSize)})]')}''',
'🟢 Track: ${lightCyan.wrap('Production')}',
];
logger.info(
'''
${styleBold.wrap(lightGreen.wrap('🚀 Ready to publish a new patch!'))}
${summary.join('\n')}
''',
);
if (shorebirdEnv.canAcceptUserInput) {
final confirm = logger.confirm('Would you like to continue?');
if (!confirm) {
logger.info('Aborting.');
return ExitCode.success.code;
}
}
await codePushClientWrapper.publishPatch(
appId: appId,
releaseId: release.id,
platform: releasePlatform,
track: DeploymentTrack.production,
patchArtifactBundles: {
Arch.arm64: PatchArtifactBundle(
arch: arch,
path: patchFile.path,
hash: _hashFn(patchBuildFile.readAsBytesSync()),
size: patchFileSize,
),
},
metadata: CreatePatchMetadata(
releasePlatform: releasePlatform,
usedIgnoreAssetChangesFlag: allowAssetDiffs,
hasAssetChanges: diffStatus.hasAssetChanges,
usedIgnoreNativeChangesFlag: allowNativeDiffs,
hasNativeChanges: diffStatus.hasNativeChanges,
linkPercentage: null,
environment: BuildEnvironmentMetadata(
operatingSystem: platform.operatingSystem,
operatingSystemVersion: platform.operatingSystemVersion,
shorebirdVersion: packageVersion,
xcodeVersion: await xcodeBuild.version(),
),
),
);
return ExitCode.success.code;
},
values: {
shorebirdEnvRef.overrideWith(() => releaseFlutterShorebirdEnv),
},
);
}
Future<String?> _promptForReleaseVersion(List<Release> releases) async {
if (releases.isEmpty) return null;
final release = logger.chooseOne(
'Which release would you like to patch?',
choices: releases,
display: (release) => release.version,
);
return release.version;
}
Future<int> _runLinker({
required File aotSnapshot,
required File releaseArtifact,
}) async {
if (!aotSnapshot.existsSync()) {
logger.err('Unable to find patch AOT file at ${aotSnapshot.path}');
return ExitCode.software.code;
}
final analyzeSnapshot = File(
shorebirdArtifacts.getArtifactPath(
artifact: ShorebirdArtifact.analyzeSnapshot,
),
);
if (!analyzeSnapshot.existsSync()) {
logger.err('Unable to find analyze_snapshot at ${analyzeSnapshot.path}');
return ExitCode.software.code;
}
final genSnapshot = shorebirdArtifacts.getArtifactPath(
artifact: ShorebirdArtifact.genSnapshot,
);
final linkProgress = logger.progress('Linking AOT files');
try {
await aotTools.link(
base: releaseArtifact.path,
patch: aotSnapshot.path,
analyzeSnapshot: analyzeSnapshot.path,
genSnapshot: genSnapshot,
kernel: newestAppDill().path,
outputPath: _vmcodeOutputPath,
workingDirectory: _buildDirectory,
);
} catch (error) {
linkProgress.fail('Failed to link AOT files: $error');
return ExitCode.software.code;
}
linkProgress.complete();
return ExitCode.success.code;
}
}
@@ -1,6 +0,0 @@
export 'aar_patcher.dart';
export 'android_patcher.dart';
export 'ios_framework_patcher.dart';
export 'ios_patcher.dart';
export 'patch_new_command.dart';
export 'patcher.dart';
@@ -1,354 +0,0 @@
import 'package:mason_logger/mason_logger.dart';
import 'package:meta/meta.dart';
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/archive_analysis/archive_differ.dart';
import 'package:shorebird_cli/src/artifact_manager.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/patch_new/patch_new.dart';
import 'package:shorebird_cli/src/config/config.dart';
import 'package:shorebird_cli/src/deployment_track.dart';
import 'package:shorebird_cli/src/extensions/arg_results.dart';
import 'package:shorebird_cli/src/formatters/formatters.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/patch_diff_checker.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/third_party/flutter_tools/lib/flutter_tools.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
typedef ResolvePatcher = Patcher Function(ReleaseType releaseType);
class PatchNewCommand extends ShorebirdCommand {
PatchNewCommand({
ResolvePatcher? resolvePatcher,
}) {
_resolvePatcher = resolvePatcher ?? getPatcher;
argParser
..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(
'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',
)
..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(
'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.''',
)
..addFlag(
'allow-native-diffs',
help: allowNativeDiffsHelpText,
negatable: false,
)
..addFlag(
'allow-asset-diffs',
help: allowAssetDiffsHelpText,
negatable: false,
)
..addFlag(
'staging',
negatable: false,
help: 'Whether to publish the patch to the staging environment.',
)
..addFlag(
'dry-run',
abbr: 'n',
negatable: false,
help: 'Validate but do not upload the patch.',
);
}
static final allowNativeDiffsHelpText = '''
Patch even if native code diffs are detected.
NOTE: this is ${styleBold.wrap('not')} recommended. Native code changes cannot be included in a patch and attempting to do so can cause your app to crash or behave unexpectedly.''';
static final allowAssetDiffsHelpText = '''
Patch even if asset diffs are detected.
NOTE: this is ${styleBold.wrap('not')} recommended. Asset changes cannot be included in a patch can cause your app to behave unexpectedly.''';
late final ResolvePatcher _resolvePatcher;
@override
bool get hidden => true;
@override
String get description =>
'Creates a shorebird patch for the provided target platforms';
@override
String get name => 'patch-new';
/// 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);
bool get isStaging => results['staging'] == true;
@override
Future<int> run() async {
final patcherFutures =
results.releaseTypes.map(_resolvePatcher).map(createPatch);
for (final patcherFuture in patcherFutures) {
await patcherFuture;
}
return ExitCode.success.code;
}
@visibleForTesting
Patcher getPatcher(ReleaseType releaseType) {
switch (releaseType) {
case ReleaseType.android:
return AndroidPatcher(
argResults: results,
flavor: flavor,
target: target,
);
case ReleaseType.ios:
return IosPatcher(
argResults: results,
flavor: flavor,
target: target,
);
case ReleaseType.iosFramework:
return IosFrameworkPatcher(
argResults: results,
flavor: flavor,
target: target,
);
case ReleaseType.aar:
return AarPatcher(
argResults: results,
flavor: flavor,
target: target,
);
}
}
bool get allowAssetDiffs => results['allow-asset-diffs'] == true;
bool get allowNativeDiffs => results['allow-native-diffs'] == true;
String? lastBuiltFlutterRevision;
@visibleForTesting
Future<void> createPatch(Patcher patcher) async {
await patcher.assertPreconditions();
await patcher.assertArgsAreValid();
await cache.updateAll();
final app = await codePushClientWrapper.getApp(appId: appId);
File? patchArtifact;
final String releaseVersion;
if (results.wasParsed('release-version')) {
releaseVersion = results['release-version'] as String;
} else {
patchArtifact = await patcher.buildPatchArtifact();
lastBuiltFlutterRevision = shorebirdEnv.flutterRevision;
releaseVersion = await patcher.extractReleaseVersionFromArtifact(
patchArtifact,
);
}
final release = await getRelease(
releaseVersion: releaseVersion,
patcher: patcher,
);
final releaseArtifact = await downloadPrimaryReleaseArtifact(
release: release,
patcher: patcher,
);
final releaseFlutterShorebirdEnv = shorebirdEnv.copyWith(
flutterRevisionOverride: release.flutterRevision,
);
return await runScoped(
() async {
await cache.updateAll();
// Don't built the patch artifact twice with the same Flutter revision.
if (lastBuiltFlutterRevision != release.flutterRevision) {
patchArtifact = await patcher.buildPatchArtifact();
}
final diffStatus = await assertUnpatchableDiffs(
releaseArtifact: releaseArtifact,
patchArtifact: patchArtifact!,
archiveDiffer: patcher.archiveDiffer,
);
final patchArtifactBundles = await patcher.createPatchArtifacts(
appId: appId,
releaseId: release.id,
);
final dryRun = results['dry-run'] == true;
if (dryRun) {
logger
..info('No issues detected.')
..info('The server may enforce additional checks.');
exit(ExitCode.success.code);
}
await confirmCreatePatch(
app: app,
releaseVersion: releaseVersion,
patcher: patcher,
patchArtifactBundles: patchArtifactBundles,
);
await codePushClientWrapper.publishPatch(
appId: appId,
releaseId: release.id,
metadata: await patcher.createPatchMetadata(diffStatus),
platform: patcher.releaseType.releasePlatform,
track:
isStaging ? DeploymentTrack.staging : DeploymentTrack.production,
patchArtifactBundles: patchArtifactBundles,
);
},
values: {
shorebirdEnvRef.overrideWith(() => releaseFlutterShorebirdEnv),
},
);
}
Future<DiffStatus> assertUnpatchableDiffs({
required File releaseArtifact,
required File patchArtifact,
required ArchiveDiffer archiveDiffer,
}) async {
try {
return patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
localArtifact: patchArtifact,
releaseArtifact: releaseArtifact,
archiveDiffer: archiveDiffer,
allowAssetChanges: allowAssetDiffs,
allowNativeChanges: allowNativeDiffs,
);
} on UserCancelledException {
exit(ExitCode.success.code);
} on UnpatchableChangeException {
logger.info('Exiting.');
exit(ExitCode.software.code);
}
}
Future<void> confirmCreatePatch({
required AppMetadata app,
required String releaseVersion,
required Patcher patcher,
required Map<Arch, PatchArtifactBundle> patchArtifactBundles,
}) async {
final archMetadata = patchArtifactBundles.keys.map((arch) {
final size = formatBytes(patchArtifactBundles[arch]!.size);
return '${arch.name} ($size)';
});
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(patcher.releaseType.releasePlatform.name)} ${lightCyan.wrap('[${archMetadata.join(', ')}]')}''',
if (isStaging)
'🟠 Track: ${lightCyan.wrap('Staging')}'
else
'🟢 Track: ${lightCyan.wrap('Production')}',
];
logger.info(
'''
${styleBold.wrap(lightGreen.wrap('🚀 Ready to publish a new patch!'))}
${summary.join('\n')}
''',
);
if (shorebirdEnv.canAcceptUserInput) {
final confirm = logger.confirm('Would you like to continue?');
if (!confirm) {
logger.info('Aborting.');
exit(ExitCode.success.code);
}
}
}
Future<Release> getRelease({
required String releaseVersion,
required Patcher patcher,
}) async {
final release = await codePushClientWrapper.getRelease(
appId: appId,
releaseVersion: releaseVersion,
);
final releaseStatus =
release.platformStatuses[patcher.releaseType.releasePlatform];
if (releaseStatus != ReleaseStatus.active) {
logger.err('''
Release ${release.version} is in an incomplete state. It's possible that the original release was terminated or failed to complete.
Please re-run the release command for this version or create a new release.''');
exit(ExitCode.software.code);
}
return release;
}
Future<File> downloadPrimaryReleaseArtifact({
required Release release,
required Patcher patcher,
}) async {
final artifact = await codePushClientWrapper.getReleaseArtifact(
appId: appId,
releaseId: release.id,
arch: patcher.primaryReleaseArtifactArch,
platform: patcher.releaseType.releasePlatform,
);
final downloadProgress =
logger.progress('Downloading ${patcher.primaryReleaseArtifactArch}');
final File artifactFile;
try {
artifactFile =
await artifactManager.downloadFile(Uri.parse(artifact.url));
} catch (e) {
downloadProgress.fail(e.toString());
exit(ExitCode.software.code);
}
downloadProgress.complete();
return artifactFile;
}
}
@@ -49,7 +49,7 @@ On Xcode builds it is used as "CFBundleVersion".''',
)
..addFlag(
'codesign',
help: 'Codesign the application bundle.',
help: 'Codesign the application bundle (iOS only).',
defaultsTo: true,
)
..addFlag(
@@ -61,7 +61,7 @@ On Xcode builds it is used as "CFBundleVersion".''',
..addOption(
exportOptionsPlistArgName,
help:
'''Export an IPA with these options. See "xcodebuild -h" for available exportOptionsPlist keys.''',
'''Export an IPA with these options. See "xcodebuild -h" for available exportOptionsPlist keys (iOS only).''',
)
..addOption(
'flutter-version',
@@ -102,9 +102,6 @@ of the iOS app that is using this module.''',
late final ResolveReleaser _resolveReleaser;
@override
bool get hidden => true;
@override
String get description =>
'Creates a shorebird release for the provided target platforms';
@@ -1,162 +0,0 @@
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
mixin ShorebirdArtifactMixin on ShorebirdCommand {
String get aarLibraryPath {
final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!;
return p.joinAll([
projectRoot.path,
'build',
'host',
'outputs',
'repo',
]);
}
String aarArtifactDirectory({
required String packageName,
required String buildNumber,
}) =>
p.joinAll([
aarLibraryPath,
...packageName.split('.'),
'flutter_release',
buildNumber,
]);
String aarArtifactPath({
required String packageName,
required String buildNumber,
}) =>
p.join(
aarArtifactDirectory(
packageName: packageName,
buildNumber: buildNumber,
),
'flutter_release-$buildNumber.aar',
);
Future<String> extractAar({
required String packageName,
required String buildNumber,
required UnzipFn unzipFn,
}) async {
final aarDirectory = aarArtifactDirectory(
packageName: packageName,
buildNumber: buildNumber,
);
final aarPath = aarArtifactPath(
packageName: packageName,
buildNumber: buildNumber,
);
final zipDir = Directory.systemTemp.createTempSync();
final zipPath = p.join(zipDir.path, 'flutter_release-$buildNumber.zip');
logger.detail('Extracting $aarPath to $zipPath');
// Copy the .aar file to a .zip file so package:archive knows how to read it
File(aarPath).copySync(zipPath);
final extractedZipDir = p.join(
aarDirectory,
'flutter_release-$buildNumber',
);
// Unzip the .zip file to a directory so we can read the .so files
await unzipFn(zipPath, extractedZipDir);
return extractedZipDir;
}
/// Returns the .xcarchive directory generated by `flutter build ipa`. This
/// was traditionally named `Runner.xcarchive`, but can now be renamed.
Directory? getXcarchiveDirectory() {
final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!;
final archiveDirectory = Directory(
p.join(
projectRoot.path,
'build',
'ios',
'archive',
),
);
if (!archiveDirectory.existsSync()) return null;
return archiveDirectory
.listSync()
.whereType<Directory>()
.firstWhereOrNull((directory) => directory.path.endsWith('.xcarchive'));
}
/// Returns the .app directory generated by `flutter build ipa`. This was
/// traditionally named `Runner.app`, but can now be renamed.
Directory? getAppDirectory({required Directory xcarchiveDirectory}) {
final applicationsDirectory = Directory(
p.join(
xcarchiveDirectory.path,
'Products',
'Applications',
),
);
if (!applicationsDirectory.existsSync()) {
return null;
}
return applicationsDirectory
.listSync()
.whereType<Directory>()
.firstWhereOrNull((directory) => directory.path.endsWith('.app'));
}
static const String appXcframeworkName = 'App.xcframework';
/// Returns the path to the App.xcframework generated by
/// `shorebird release ios-framework` or
/// `shorebird patch ios-framework`.
String getAppXcframeworkPath() {
return p.join(getAppXcframeworkDirectory().path, appXcframeworkName);
}
/// Returns the [Directory] containing the App.xcframework generated by
/// `shorebird release ios-framework` or
/// `shorebird patch ios-framework`.
Directory getAppXcframeworkDirectory() {
final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!;
return Directory(
p.join(
projectRoot.path,
'build',
'ios',
'framework',
'Release',
),
);
}
/// Finds the most recently-edited app.dill file in the .dart_tool directory.
// TODO(bryanoltman): This is an enormous hack we don't know that this is
// the correct file.
File newestAppDill() {
final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!;
final dartToolBuildDir = Directory(
p.join(
projectRoot.path,
'.dart_tool',
'flutter_build',
),
);
return dartToolBuildDir
.listSync(recursive: true)
.whereType<File>()
.where((f) => p.basename(f.path) == 'app.dill')
.reduce(
(a, b) =>
a.statSync().modified.isAfter(b.statSync().modified) ? a : b,
);
}
}
@@ -1,348 +0,0 @@
import 'dart:io';
import 'package:mason_logger/mason_logger.dart';
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/os/operating_system_interface.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/shorebird_android_artifacts.dart';
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();
/// {@template build_exception}
/// Thrown when a build fails.
/// {@endtemplate}
class BuildException implements Exception {
/// {@macro build_exception}
BuildException(this.message);
/// Information about the build failure.
final String message;
}
mixin ShorebirdBuildMixin on ShorebirdCommand {
Future<File> buildAppBundle({
String? flavor,
String? target,
Iterable<Arch>? targetPlatforms,
}) async {
await _runShorebirdBuildCommand(() async {
const executable = 'flutter';
final targetPlatformArgs = targetPlatforms?.targetPlatformArg;
final arguments = [
'build',
'appbundle',
'--release',
if (flavor != null) '--flavor=$flavor',
if (target != null) '--target=$target',
if (targetPlatformArgs != null) '--target-platform=$targetPlatformArgs',
...results.rest,
];
final result = await process.run(
executable,
arguments,
runInShell: true,
);
if (result.exitCode != ExitCode.success.code) {
throw BuildException(
'Failed to build: ${result.stderr}',
);
}
});
final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!;
try {
return shorebirdAndroidArtifacts.findAab(
project: projectRoot,
flavor: flavor,
);
} on MultipleArtifactsFoundException catch (error) {
throw BuildException(
'Build succeeded, but it generated multiple AABs in the '
'build directory. ${error.foundArtifacts.map((e) => e.path)}',
);
} on ArtifactNotFoundException catch (error) {
throw BuildException(
'Build succeeded, but could not find the AAB in the build directory. '
'Expected to find ${error.artifactName}',
);
}
}
Future<void> buildAar({
required String buildNumber,
Iterable<Arch>? targetPlatforms,
}) async {
return _runShorebirdBuildCommand(() async {
const executable = 'flutter';
final targetPlatformArgs = targetPlatforms?.targetPlatformArg;
final arguments = [
'build',
'aar',
'--no-debug',
'--no-profile',
'--build-number=$buildNumber',
if (targetPlatformArgs != null) '--target-platform=$targetPlatformArgs',
...results.rest,
];
final result = await process.run(
executable,
arguments,
runInShell: true,
);
if (result.exitCode != ExitCode.success.code) {
throw ProcessException(
'flutter',
arguments,
result.stderr.toString(),
result.exitCode,
);
}
});
}
Future<File> buildApk({
String? flavor,
String? target,
Iterable<Arch>? targetPlatforms,
bool splitPerAbi = false,
}) async {
await _runShorebirdBuildCommand(() async {
const executable = 'flutter';
final targetPlatformArgs = targetPlatforms?.targetPlatformArg;
final arguments = [
'build',
'apk',
'--release',
if (flavor != null) '--flavor=$flavor',
if (target != null) '--target=$target',
if (targetPlatformArgs != null) '--target-platform=$targetPlatformArgs',
// TODO(bryanoltman): reintroduce coverage when we can support this.
// See https://github.com/shorebirdtech/shorebird/issues/1141.
if (splitPerAbi) '--split-per-abi',
...results.rest,
];
final result = await process.run(
executable,
arguments,
runInShell: true,
);
if (result.exitCode != ExitCode.success.code) {
throw BuildException(
'Failed to build: ${result.stderr}',
);
}
});
final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!;
try {
return shorebirdAndroidArtifacts.findApk(
project: projectRoot,
flavor: flavor,
);
} on MultipleArtifactsFoundException catch (error) {
throw BuildException(
'Build succeeded, but it generated multiple APKs in the '
'build directory. ${error.foundArtifacts.map((e) => e.path)}',
);
} on ArtifactNotFoundException catch (error) {
throw BuildException(
'Build succeeded, but could not find the APK in the build directory. '
'Expected to find ${error.artifactName}',
);
}
}
/// Calls `flutter build ipa`. If [codesign] is false, this will only build
/// an .xcarchive and _not_ an .ipa.
Future<void> buildIpa({
required bool codesign,
File? exportOptionsPlist,
String? flavor,
String? target,
}) async {
return _runShorebirdBuildCommand(() async {
const executable = 'flutter';
final exportOptionsPlistPath =
(exportOptionsPlist ?? ios.createExportOptionsPlist()).path;
final arguments = [
'build',
'ipa',
'--release',
if (flavor != null) '--flavor=$flavor',
if (target != null) '--target=$target',
if (!codesign) '--no-codesign',
if (codesign) '''--export-options-plist=$exportOptionsPlistPath''',
...results.rest,
];
final result = await process.run(
executable,
arguments,
runInShell: true,
);
if (result.exitCode != ExitCode.success.code) {
throw ProcessException(
'flutter',
arguments,
result.stderr.toString(),
result.exitCode,
);
}
if (result.stderr
.toString()
.contains('Encountered error while creating the IPA')) {
final errorMessage = _failedToCreateIpaErrorMessage(
stderr: result.stderr.toString(),
);
throw BuildException(errorMessage);
}
});
}
/// Builds a release iOS framework (.xcframework) for the current project.
Future<void> buildIosFramework() async {
return _runShorebirdBuildCommand(() async {
const executable = 'flutter';
final arguments = [
'build',
'ios-framework',
'--no-debug',
'--no-profile',
...results.rest,
];
final result = await process.run(
executable,
arguments,
runInShell: true,
);
if (result.exitCode != ExitCode.success.code) {
throw ProcessException(
'flutter',
arguments,
result.stderr.toString(),
result.exitCode,
);
}
});
}
/// A wrapper around [command] (which runs a `flutter build` command with
/// Shorebird's fork of Flutter) with a try/finally that runs
/// `flutter pub get` with the system installation of Flutter to reset
/// `.dart_tool/package_config.json` to the system Flutter.
Future<void> _runShorebirdBuildCommand(ShorebirdBuildCommand command) async {
try {
await command();
} finally {
await _systemFlutterPubGet();
}
}
/// This is a hack to reset `.dart_tool/package_config.json` to point to the
/// Flutter SDK on the user's PATH. This is necessary because Flutter commands
/// run by shorebird update the package_config.json file to point to
/// shorebird's version of Flutter, which confuses VS Code. See
/// https://github.com/shorebirdtech/shorebird/issues/1101 for more info.
Future<void> _systemFlutterPubGet() async {
const executable = 'flutter';
if (osInterface.which(executable) == null) {
// If the user doesn't have Flutter on their PATH, then we can't run
// `flutter pub get` with the system Flutter.
return;
}
final arguments = ['--no-version-check', 'pub', 'get', '--offline'];
final result = await process.run(
executable,
arguments,
runInShell: true,
useVendedFlutter: false,
);
if (result.exitCode != ExitCode.success.code) {
logger.warn(
'''
Build was successful, but `flutter pub get` failed to run after the build completed. You may see unexpected behavior in VS Code.
Either run `flutter pub get` manually, or follow the steps in ${link(uri: Uri.parse('https://docs.shorebird.dev/troubleshooting#i-installed-shorebird-and-now-i-cant-run-my-app-in-vs-code'))}.
''',
);
}
}
String _failedToCreateIpaErrorMessage({required String stderr}) {
// The full error text consists of many repeated lines of the format:
// (newlines added for line length)
//
// [ +1 ms] Encountered error while creating the IPA:
// [ ] error: exportArchive: Team "Team" does not have permission to
// create "iOS In House" provisioning profiles.
// error: exportArchive: No profiles for 'com.example.dev' were found
// error: exportArchive: No signing certificate "iOS Distribution" found
// error: exportArchive: Communication with Apple failed
// error: exportArchive: No signing certificate "iOS Distribution" found
// error: exportArchive: Team "My Team" does not have permission to
// create "iOS App Store" provisioning profiles.
// error: exportArchive: No profiles for 'com.example.demo' were found
// error: exportArchive: Communication with Apple failed
// error: exportArchive: No signing certificate "iOS Distribution" found
// error: exportArchive: Communication with Apple failed
final exportArchiveRegex = RegExp(r'error: exportArchive: (.+)$');
return stderr
.split('\n')
.map((l) => l.trim())
.toSet()
.map(exportArchiveRegex.firstMatch)
.whereType<Match>()
.map((m) => ' ${m.group(1)!}')
.join('\n');
}
/// Creates an AOT snapshot of the given [appDillPath] at [outFilePath] and
/// returns the resulting file.
Future<File> buildElfAotSnapshot({
required String appDillPath,
required String outFilePath,
}) async {
final arguments = [
'--deterministic',
'--snapshot-kind=app-aot-elf',
'--elf=$outFilePath',
appDillPath,
];
final result = await process.run(
shorebirdArtifacts.getArtifactPath(
artifact: ShorebirdArtifact.genSnapshot,
),
arguments,
);
if (result.exitCode != ExitCode.success.code) {
throw Exception('Failed to create snapshot: ${result.stderr}');
}
return File(outFilePath);
}
}
// coverage:ignore-end
@@ -1,23 +0,0 @@
import 'package:shorebird_cli/src/cache.dart';
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/executables/executables.dart';
/// Mixin on [ShorebirdCommand] which exposes methods
/// to extract the release version from an app bundle.
mixin ShorebirdReleaseVersionMixin on ShorebirdCommand {
/// Extract the release version from an appbundle.
Future<String> extractReleaseVersionFromAppBundle(
String appBundlePath,
) async {
await cache.updateAll();
final results = await Future.wait([
bundletool.getVersionName(appBundlePath),
bundletool.getVersionCode(appBundlePath),
]);
final versionName = results[0];
final versionCode = results[1];
return '$versionName+$versionCode';
}
}
@@ -5,13 +5,13 @@ import 'package:mason_logger/mason_logger.dart';
import 'package:mocktail/mocktail.dart';
import 'package:path/path.dart' as p;
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/artifact_builder.dart';
import 'package:shorebird_cli/src/commands/build/build.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/os/operating_system_interface.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/shorebird_env.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:test/test.dart';
@@ -22,27 +22,25 @@ import '../../mocks.dart';
void main() {
group(BuildIpaCommand, () {
late ArgResults argResults;
late ArtifactBuilder artifactBuilder;
late Doctor doctor;
late Ios ios;
late ShorebirdLogger logger;
late OperatingSystemInterface operatingSystemInterface;
late ShorebirdProcessResult buildProcessResult;
late ShorebirdProcessResult flutterPubGetProcessResult;
late BuildIpaCommand command;
late ShorebirdEnv shorebirdEnv;
late ShorebirdFlutterValidator flutterValidator;
late ShorebirdProcess shorebirdProcess;
late ShorebirdValidator shorebirdValidator;
R runWithOverrides<R>(R Function() body) {
return runScoped(
body,
values: {
artifactBuilderRef.overrideWith(() => artifactBuilder),
doctorRef.overrideWith(() => doctor),
iosRef.overrideWith(() => ios),
loggerRef.overrideWith(() => logger),
osInterfaceRef.overrideWith(() => operatingSystemInterface),
processRef.overrideWith(() => shorebirdProcess),
shorebirdEnvRef.overrideWith(() => shorebirdEnv),
shorebirdValidatorRef.overrideWith(() => shorebirdValidator),
},
@@ -54,37 +52,25 @@ void main() {
});
setUp(() {
artifactBuilder = MockArtifactBuilder();
argResults = MockArgResults();
doctor = MockDoctor();
ios = MockIos();
logger = MockShorebirdLogger();
operatingSystemInterface = MockOperatingSystemInterface();
shorebirdProcess = MockShorebirdProcess();
buildProcessResult = MockProcessResult();
flutterPubGetProcessResult = MockProcessResult();
flutterValidator = MockShorebirdFlutterValidator();
shorebirdEnv = MockShorebirdEnv();
shorebirdValidator = MockShorebirdValidator();
when(
() => shorebirdProcess.run(
'flutter',
['--no-version-check', 'pub', 'get', '--offline'],
runInShell: any(named: 'runInShell'),
useVendedFlutter: false,
),
).thenAnswer((_) async => flutterPubGetProcessResult);
when(() => flutterPubGetProcessResult.exitCode)
.thenReturn(ExitCode.success.code);
when(
() => shorebirdProcess.run(
any(),
any(),
runInShell: any(named: 'runInShell'),
),
).thenAnswer((_) async => buildProcessResult);
when(() => argResults['codesign']).thenReturn(true);
when(() => argResults.rest).thenReturn([]);
when(
() => artifactBuilder.buildIpa(
flavor: any(named: 'flavor'),
target: any(named: 'target'),
codesign: any(named: 'codesign'),
),
).thenAnswer((_) async => File(''));
when(() => ios.createExportOptionsPlist()).thenReturn(File('.'));
when(() => logger.progress(any())).thenReturn(MockProgress());
when(() => logger.info(any())).thenReturn(null);
@@ -131,42 +117,26 @@ void main() {
});
test('exits with code 70 when building ipa fails', () async {
when(() => buildProcessResult.exitCode).thenReturn(1);
when(() => buildProcessResult.stderr).thenReturn('oops');
when(
() => artifactBuilder.buildIpa(
flavor: any(named: 'flavor'),
target: any(named: 'target'),
codesign: any(named: 'codesign'),
),
).thenThrow(ArtifactBuildException('oops'));
final exitCode = await runWithOverrides(command.run);
expect(exitCode, equals(ExitCode.software.code));
verify(
() => shorebirdProcess.run(
'flutter',
any(
that: containsAll(
['build', 'ipa', '--release'],
),
),
runInShell: any(named: 'runInShell'),
),
).called(1);
verify(() => artifactBuilder.buildIpa()).called(1);
});
test('exits with code 0 when building ipa succeeds', () async {
when(() => buildProcessResult.exitCode).thenReturn(ExitCode.success.code);
final exitCode = await runWithOverrides(command.run);
expect(exitCode, equals(ExitCode.success.code));
verify(
() => shorebirdProcess.run(
'flutter',
any(
that: containsAll(
['build', 'ipa', '--release'],
),
),
runInShell: true,
),
).called(1);
verify(() => artifactBuilder.buildIpa()).called(1);
verifyInOrder([
() => logger.info(
@@ -182,22 +152,6 @@ ${lightCyan.wrap(p.join('build', 'ios', 'ipa', 'Runner.ipa'))}''',
]);
});
test('runs flutter pub get with system flutter after successful build',
() async {
when(() => buildProcessResult.exitCode).thenReturn(ExitCode.success.code);
await runWithOverrides(command.run);
verify(
() => shorebirdProcess.run(
'flutter',
['--no-version-check', 'pub', 'get', '--offline'],
runInShell: any(named: 'runInShell'),
useVendedFlutter: false,
),
).called(1);
});
test(
'exits with code 0 when building ipa succeeds '
'with flavor and target', () async {
@@ -205,27 +159,12 @@ ${lightCyan.wrap(p.join('build', 'ios', 'ipa', 'Runner.ipa'))}''',
final target = p.join('lib', 'main_development.dart');
when(() => argResults['flavor']).thenReturn(flavor);
when(() => argResults['target']).thenReturn(target);
when(() => buildProcessResult.exitCode).thenReturn(ExitCode.success.code);
final exitCode = await runWithOverrides(command.run);
expect(exitCode, equals(ExitCode.success.code));
verify(
() => shorebirdProcess.run(
'flutter',
any(
that: containsAll(
[
'build',
'ipa',
'--release',
'--flavor=$flavor',
'--target=$target',
],
),
),
runInShell: true,
),
() => artifactBuilder.buildIpa(flavor: flavor, target: target),
).called(1);
verifyInOrder([
@@ -246,19 +185,12 @@ ${lightCyan.wrap(p.join('build', 'ios', 'ipa', 'Runner.ipa'))}''',
'exits with code 0 when building ipa succeeds '
'with --no-codesign', () async {
when(() => argResults['codesign']).thenReturn(false);
when(() => buildProcessResult.exitCode).thenReturn(ExitCode.success.code);
final exitCode = await runWithOverrides(command.run);
expect(exitCode, equals(ExitCode.success.code));
verify(
() => shorebirdProcess.run(
'flutter',
any(
that: containsAll(['build', 'ipa', '--release', '--no-codesign']),
),
runInShell: true,
),
() => artifactBuilder.buildIpa(codesign: false),
).called(1);
verify(
@@ -8,7 +8,7 @@ import 'package:shorebird_cli/src/archive_analysis/archive_analysis.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/patch_new/patch_new.dart';
import 'package:shorebird_cli/src/commands/patch/patch.dart';
import 'package:shorebird_cli/src/engine_config.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/patch_diff_checker.dart';
@@ -8,7 +8,7 @@ import 'package:shorebird_cli/src/archive_analysis/archive_analysis.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/patch_new/patch_new.dart';
import 'package:shorebird_cli/src/commands/patch/patch.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/engine_config.dart';
import 'package:shorebird_cli/src/logger.dart';
@@ -8,7 +8,7 @@ import 'package:shorebird_cli/src/archive_analysis/archive_analysis.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/patch_new/patch_new.dart';
import 'package:shorebird_cli/src/commands/patch/patch.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/engine_config.dart';
import 'package:shorebird_cli/src/executables/aot_tools.dart';
@@ -8,7 +8,7 @@ import 'package:shorebird_cli/src/archive_analysis/archive_analysis.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/patch_new/patch_new.dart';
import 'package:shorebird_cli/src/commands/patch/patch.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/engine_config.dart';
import 'package:shorebird_cli/src/executables/aot_tools.dart';
@@ -1,930 +0,0 @@
import 'dart:io' hide Platform;
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/archive_analysis/archive_analysis.dart';
import 'package:shorebird_cli/src/artifact_manager.dart';
import 'package:shorebird_cli/src/auth/auth.dart';
import 'package:shorebird_cli/src/cache.dart' show Cache, cacheRef;
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/deployment_track.dart';
import 'package:shorebird_cli/src/engine_config.dart';
import 'package:shorebird_cli/src/http_client/http_client.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/os/operating_system_interface.dart';
import 'package:shorebird_cli/src/patch_diff_checker.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(PatchAarCommand, () {
const flutterRevision = '83305b5088e6fe327fb3334a73ff190828d85713';
const flutterVersionAndRevision = '3.10.6 (83305b5088)';
const appId = 'test-app-id';
const shorebirdYaml = ShorebirdYaml(appId: appId);
const buildNumber = '1.0';
const versionName = '1.2.3';
const versionCode = '1';
const version = '$versionName+$versionCode';
const operatingSystem = 'macOS';
const operatingSystemVersion = '11.0.0';
const arch = 'aarch64';
const releasePlatform = ReleasePlatform.android;
const track = DeploymentTrack.production;
const appDisplayName = 'Test App';
final appMetadata = AppMetadata(
appId: appId,
displayName: appDisplayName,
createdAt: DateTime(2023),
updatedAt: DateTime(2023),
);
const androidPackageName = 'com.example.my_flutter_module';
const releaseArtifact = ReleaseArtifact(
id: 0,
releaseId: 0,
arch: arch,
platform: releasePlatform,
hash: '#',
size: 42,
url: 'https://example.com/release.so',
);
const aarArtifact = ReleaseArtifact(
id: 0,
releaseId: 0,
arch: arch,
platform: releasePlatform,
hash: '#',
size: 42,
url: 'https://example.com/release.aar',
);
final release = Release(
id: 0,
appId: appId,
version: version,
flutterRevision: flutterRevision,
displayName: '1.2.3+1',
platformStatuses: {},
createdAt: DateTime(2023),
updatedAt: DateTime(2023),
);
final releaseArtifactFile = File('release.artifact');
late AndroidArchiveDiffer archiveDiffer;
late ArgResults argResults;
late ArtifactManager artifactManager;
late Auth auth;
late CodePushClientWrapper codePushClientWrapper;
late Directory shorebirdRoot;
late Directory projectRoot;
late Directory flutterDirectory;
late OperatingSystemInterface operatingSystemInterface;
late PatchDiffChecker patchDiffChecker;
late Platform platform;
late Progress progress;
late ShorebirdLogger logger;
late ShorebirdProcessResult flutterBuildProcessResult;
late ShorebirdProcessResult flutterPubGetProcessResult;
late http.Client httpClient;
late Cache cache;
late ShorebirdEnv shorebirdEnv;
late ShorebirdFlutter shorebirdFlutter;
late ShorebirdProcess shorebirdProcess;
late ShorebirdValidator shorebirdValidator;
late PatchAarCommand command;
R runWithOverrides<R>(R Function() body) {
return runScoped(
body,
values: {
artifactManagerRef.overrideWith(() => artifactManager),
authRef.overrideWith(() => auth),
cacheRef.overrideWith(() => cache),
codePushClientWrapperRef.overrideWith(() => codePushClientWrapper),
engineConfigRef.overrideWith(() => const EngineConfig.empty()),
httpClientRef.overrideWith(() => httpClient),
loggerRef.overrideWith(() => logger),
osInterfaceRef.overrideWith(() => operatingSystemInterface),
patchDiffCheckerRef.overrideWith(() => patchDiffChecker),
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(CreatePatchMetadata.forTest());
registerFallbackValue(Directory(''));
registerFallbackValue(File(''));
registerFallbackValue(FileSetDiff.empty());
registerFallbackValue(MockHttpClient());
registerFallbackValue(Uri.parse('https://example.com'));
registerFallbackValue(FakeBaseRequest());
registerFallbackValue(FakeShorebirdProcess());
registerFallbackValue(ReleasePlatform.android);
registerFallbackValue(DeploymentTrack.production);
});
setUp(() {
archiveDiffer = MockAndroidArchiveDiffer();
argResults = MockArgResults();
artifactManager = MockArtifactManager();
auth = MockAuth();
codePushClientWrapper = MockCodePushClientWrapper();
shorebirdRoot = Directory.systemTemp.createTempSync();
projectRoot = Directory.systemTemp.createTempSync();
flutterDirectory = Directory(
p.join(shorebirdRoot.path, 'bin', 'cache', 'flutter'),
);
operatingSystemInterface = MockOperatingSystemInterface();
patchDiffChecker = MockPatchDiffChecker();
platform = MockPlatform();
progress = MockProgress();
logger = MockShorebirdLogger();
flutterBuildProcessResult = MockProcessResult();
flutterPubGetProcessResult = MockProcessResult();
httpClient = MockHttpClient();
cache = MockCache();
shorebirdEnv = MockShorebirdEnv();
shorebirdFlutter = MockShorebirdFlutter();
shorebirdProcess = MockShorebirdProcess();
shorebirdValidator = MockShorebirdValidator();
when(() => operatingSystemInterface.which('flutter'))
.thenReturn('/path/to/flutter');
when(() => platform.environment).thenReturn({});
when(() => platform.operatingSystem).thenReturn(operatingSystem);
when(() => platform.operatingSystemVersion)
.thenReturn(operatingSystemVersion);
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.flutterDirectory).thenReturn(flutterDirectory);
when(
() => shorebirdEnv.androidPackageName,
).thenReturn(androidPackageName);
when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(shorebirdYaml);
when(() => shorebirdEnv.flutterRevision).thenReturn(flutterRevision);
when(() => shorebirdEnv.canAcceptUserInput).thenReturn(true);
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(
() => artifactManager.createDiff(
patchArtifactPath: any(named: 'patchArtifactPath'),
releaseArtifactPath: any(named: 'releaseArtifactPath'),
),
).thenAnswer((_) async {
final tempDir = await Directory.systemTemp.createTemp();
final diffPath = p.join(tempDir.path, 'diff.patch');
File(diffPath)
..createSync(recursive: true)
..writeAsStringSync('diff');
return diffPath;
});
when(() => artifactManager.downloadFile(any()))
.thenAnswer((_) async => releaseArtifactFile);
when(
() => archiveDiffer.changedFiles(any(), any()),
).thenAnswer((_) async => FileSetDiff.empty());
when(
() => archiveDiffer.containsPotentiallyBreakingAssetDiffs(any()),
).thenReturn(false);
when(() => argResults.rest).thenReturn([]);
when(() => argResults['dry-run']).thenReturn(false);
when(() => argResults['build-number']).thenReturn(buildNumber);
when(() => argResults['release-version']).thenReturn(version);
when(() => auth.isAuthenticated).thenReturn(true);
when(() => auth.client).thenReturn(httpClient);
when(() => logger.level).thenReturn(Level.info);
when(() => logger.progress(any())).thenReturn(progress);
when(
() => logger.prompt(any(), defaultValue: any(named: 'defaultValue')),
).thenReturn(version);
when(() => logger.confirm(any())).thenReturn(true);
when(
() => flutterBuildProcessResult.exitCode,
).thenReturn(ExitCode.success.code);
when(
() => flutterPubGetProcessResult.exitCode,
).thenReturn(ExitCode.success.code);
when(() => httpClient.send(any())).thenAnswer(
(_) async => http.StreamedResponse(const Stream.empty(), HttpStatus.ok),
);
when(
() => codePushClientWrapper.getApp(appId: any(named: 'appId')),
).thenAnswer((_) async => appMetadata);
when(
() => codePushClientWrapper.getReleases(appId: any(named: 'appId')),
).thenAnswer((_) async => [release]);
when(
() => codePushClientWrapper.getReleaseArtifacts(
appId: any(named: 'appId'),
releaseId: any(named: 'releaseId'),
architectures: any(named: 'architectures'),
platform: any(named: 'platform'),
),
).thenAnswer(
(_) async => {
Arch.arm32: releaseArtifact,
Arch.arm64: releaseArtifact,
Arch.x86_64: releaseArtifact,
},
);
when(
() => codePushClientWrapper.getReleaseArtifact(
appId: any(named: 'appId'),
releaseId: any(named: 'releaseId'),
arch: 'aar',
platform: ReleasePlatform.android,
),
).thenAnswer((_) async => aarArtifact);
when(
() => codePushClientWrapper.publishPatch(
appId: any(named: 'appId'),
releaseId: any(named: 'releaseId'),
platform: any(named: 'platform'),
track: any(named: 'track'),
patchArtifactBundles: any(named: 'patchArtifactBundles'),
metadata: any(named: 'metadata'),
),
).thenAnswer((_) async {});
when(() => cache.updateAll()).thenAnswer((_) async => {});
when(
() => cache.getArtifactDirectory(any()),
).thenReturn(Directory.systemTemp.createTempSync());
when(
() => shorebirdFlutter.getVersionAndRevision(),
).thenAnswer((_) async => flutterVersionAndRevision);
when(
() => shorebirdFlutter.installRevision(
revision: any(named: 'revision'),
),
).thenAnswer((_) async {});
when(
() => shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'),
checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'),
),
).thenAnswer((_) async {});
when(
() => patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
localArtifact: any(named: 'localArtifact'),
releaseArtifact: any(named: 'releaseArtifact'),
archiveDiffer: archiveDiffer,
allowAssetChanges: any(named: 'allowAssetChanges'),
allowNativeChanges: any(named: 'allowNativeChanges'),
),
).thenAnswer(
(_) async => DiffStatus(
hasAssetChanges: false,
hasNativeChanges: false,
),
);
command = runWithOverrides(
() => PatchAarCommand(
archiveDiffer: archiveDiffer,
unzipFn: (_, __) async {},
),
)..testArgResults = argResults;
});
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'),
),
).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 exitCode = await runWithOverrides(command.run);
expect(exitCode, ExitCode.config.code);
});
test('prompts for release when release-version is not specified', () async {
when(() => argResults['release-version']).thenReturn(null);
when(
() => logger.chooseOne<Release>(
any(),
choices: any(named: 'choices'),
display: any(named: 'display'),
),
).thenReturn(release);
try {
await runWithOverrides(command.run);
} catch (_) {}
await untilCalled(
() => logger.chooseOne<Release>(
any(),
choices: any(named: 'choices'),
display: any(named: 'display'),
),
);
final display = verify(
() => logger.chooseOne<Release>(
any(),
choices: any(named: 'choices'),
display: captureAny(named: 'display'),
),
).captured.single as String Function(Release);
expect(display(release), equals(release.version));
});
test('exits early when no releases are found', () async {
when(() => argResults['release-version']).thenReturn(null);
when(
() => codePushClientWrapper.getReleases(appId: any(named: 'appId')),
).thenAnswer((_) async => []);
try {
await runWithOverrides(command.run);
} catch (_) {}
verifyNever(
() => logger.chooseOne<Release>(
any(),
choices: any(named: 'choices'),
display: captureAny(named: 'display'),
),
);
verify(() => codePushClientWrapper.getReleases(appId: appId)).called(1);
verify(() => logger.info('No releases found')).called(1);
});
test('exits early when specified release does not exist.', () async {
when(() => argResults['release-version']).thenReturn('0.0.0');
try {
await runWithOverrides(command.run);
} catch (_) {}
verifyNever(
() => logger.chooseOne<Release>(
any(),
choices: any(named: 'choices'),
display: captureAny(named: 'display'),
),
);
verify(() => codePushClientWrapper.getReleases(appId: appId)).called(1);
verify(
() => logger.info('''
No release found for version 0.0.0
Available release versions:
${release.version}'''),
).called(1);
});
test(
'''exits with code 70 if release is in draft state for the android platform''',
() async {
when(
() => codePushClientWrapper.getReleases(appId: any(named: 'appId')),
).thenAnswer(
(_) async => [
Release(
id: 0,
appId: appId,
version: version,
flutterRevision: flutterRevision,
displayName: '1.2.3+1',
platformStatuses: {ReleasePlatform.android: ReleaseStatus.draft},
createdAt: DateTime(2023),
updatedAt: DateTime(2023),
),
],
);
setUpProjectRootArtifacts();
final exitCode = await runWithOverrides(command.run);
expect(exitCode, ExitCode.software.code);
verify(
() => logger.err('''
Release 1.2.3+1 is in an incomplete state. It's possible that the original release was terminated or failed to complete.
Please re-run the release command for this version or create a new release.'''),
).called(1);
});
test('proceeds if release is in draft state for non-android platform',
() async {
when(
() => codePushClientWrapper.getReleases(appId: any(named: 'appId')),
).thenAnswer(
(_) async => [
Release(
id: 0,
appId: appId,
version: version,
flutterRevision: flutterRevision,
displayName: '1.2.3+1',
platformStatuses: {ReleasePlatform.ios: ReleaseStatus.draft},
createdAt: DateTime(2023),
updatedAt: DateTime(2023),
),
],
);
setUpProjectRootArtifacts();
final exitCode = await runWithOverrides(command.run);
expect(exitCode, ExitCode.success.code);
});
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: release.flutterRevision,
),
).called(1);
});
});
test('exits with code 70 when downloading release artifact fails',
() async {
final exception = Exception('oops');
when(
() => artifactManager.downloadFile(
any(),
outputPath: any(named: 'outputPath'),
),
).thenThrow(exception);
setUpProjectRootArtifacts();
final exitCode = await runWithOverrides(command.run);
verify(() => progress.fail('$exception')).called(1);
expect(exitCode, ExitCode.software.code);
});
test(
'builds using correct flutter revision '
'when release flutter revision differs', () async {
when(
() => platform.script,
).thenReturn(
Uri.file(p.join('bin', 'cache', 'shorebird.snapshot')),
);
const otherRevision = 'other-revision';
when(() => shorebirdEnv.flutterRevision).thenReturn(otherRevision);
final processWrapper = MockProcessWrapper();
when(
() => processWrapper.run(
any(),
any(),
runInShell: any(named: 'runInShell'),
workingDirectory: any(named: 'workingDirectory'),
environment: any(named: 'environment'),
),
).thenAnswer((_) async => flutterBuildProcessResult);
final flutterFile = File(
p.join(
'.',
'bin',
'cache',
'flutter',
release.flutterRevision,
'bin',
'flutter',
),
);
when(() => shorebirdEnv.flutterBinaryFile).thenReturn(flutterFile);
when(
() => shorebirdProcess.run(
'flutter',
any(),
runInShell: any(named: 'runInShell'),
),
).thenAnswer((_) async {
// Ensure we're building with the correct flutter revision.
expect(shorebirdEnv.flutterRevision, equals(release.flutterRevision));
return flutterBuildProcessResult;
});
setUpProjectRootArtifacts();
await runWithOverrides(
() => runScoped(
() => command.run(),
values: {
shorebirdEnvRef.overrideWith(() => shorebirdEnv),
processRef.overrideWith(
() => ShorebirdProcess(processWrapper: processWrapper),
),
},
),
);
verify(
() => shorebirdFlutter.installRevision(
revision: release.flutterRevision,
),
).called(1);
verify(
() => processWrapper.run(
flutterFile.path,
any(),
runInShell: true,
workingDirectory: any(named: 'workingDirectory'),
environment: any(named: 'environment'),
),
).called(1);
});
test('exits with code 70 when building fails', () async {
when(() => flutterBuildProcessResult.exitCode).thenReturn(1);
when(() => flutterBuildProcessResult.stderr).thenReturn('oops');
final exitCode = await runWithOverrides(command.run);
expect(exitCode, equals(ExitCode.software.code));
});
test(
'''exits with code 0 if confirmUnpatchableDiffsIfNecessary throws UserCancelledException''',
() async {
when(
() => patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
localArtifact: any(named: 'localArtifact'),
releaseArtifact: any(named: 'releaseArtifact'),
archiveDiffer: archiveDiffer,
allowAssetChanges: any(named: 'allowAssetChanges'),
allowNativeChanges: any(named: 'allowNativeChanges'),
),
).thenThrow(UserCancelledException());
setUpProjectRootArtifacts();
final exitCode = await runWithOverrides(command.run);
expect(exitCode, equals(ExitCode.success.code));
verify(
() => patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
localArtifact: any(named: 'localArtifact'),
releaseArtifact: releaseArtifactFile,
archiveDiffer: archiveDiffer,
allowAssetChanges: false,
allowNativeChanges: false,
),
).called(1);
verifyNever(
() => codePushClientWrapper.publishPatch(
appId: any(named: 'appId'),
releaseId: any(named: 'releaseId'),
platform: any(named: 'platform'),
track: any(named: 'track'),
patchArtifactBundles: any(named: 'patchArtifactBundles'),
metadata: any(named: 'metadata'),
),
);
});
test(
'''exits with code 70 if confirmUnpatchableDiffsIfNecessary throws UnpatchableChangeException''',
() async {
when(
() => patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
localArtifact: any(named: 'localArtifact'),
releaseArtifact: any(named: 'releaseArtifact'),
archiveDiffer: archiveDiffer,
allowAssetChanges: any(named: 'allowAssetChanges'),
allowNativeChanges: any(named: 'allowNativeChanges'),
),
).thenThrow(UnpatchableChangeException());
setUpProjectRootArtifacts();
final exitCode = await runWithOverrides(command.run);
expect(exitCode, equals(ExitCode.software.code));
verify(
() => patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
localArtifact: any(named: 'localArtifact'),
releaseArtifact: releaseArtifactFile,
archiveDiffer: archiveDiffer,
allowAssetChanges: false,
allowNativeChanges: false,
),
).called(1);
verifyNever(
() => codePushClientWrapper.publishPatch(
appId: any(named: 'appId'),
releaseId: any(named: 'releaseId'),
platform: any(named: 'platform'),
track: any(named: 'track'),
patchArtifactBundles: any(named: 'patchArtifactBundles'),
metadata: any(named: 'metadata'),
),
);
});
test('exits with code 70 and prints error when creating diff fails',
() async {
final error = Exception('oops something went wrong');
when(
() => artifactManager.createDiff(
releaseArtifactPath: any(named: 'releaseArtifactPath'),
patchArtifactPath: any(named: 'patchArtifactPath'),
),
).thenThrow(error);
setUpProjectRootArtifacts();
final exitCode = await runWithOverrides(command.run);
verify(() => progress.fail('$error')).called(1);
expect(exitCode, ExitCode.software.code);
});
test('does not create patch on --dry-run', () async {
when(() => argResults['dry-run']).thenReturn(true);
setUpProjectRootArtifacts();
final exitCode = await runWithOverrides(command.run);
expect(exitCode, equals(ExitCode.success.code));
verifyNever(
() => codePushClientWrapper.publishPatch(
appId: any(named: 'appId'),
releaseId: any(named: 'releaseId'),
platform: any(named: 'platform'),
track: any(named: 'track'),
patchArtifactBundles: any(named: 'patchArtifactBundles'),
metadata: any(named: 'metadata'),
),
);
verify(() => logger.info('No issues detected.')).called(1);
});
test('aborts when user opts out', () async {
when(() => logger.confirm(any())).thenReturn(false);
setUpProjectRootArtifacts();
final exitCode = await runWithOverrides(command.run);
expect(exitCode, ExitCode.success.code);
verify(() => logger.info('Aborting.')).called(1);
});
test(
'''forwards allow-asset-diffs and allow-native-diffs to patch diff checker''',
() async {
setUpProjectRootArtifacts();
when(() => argResults['allow-asset-diffs']).thenReturn(true);
when(() => argResults['allow-native-diffs']).thenReturn(true);
await runWithOverrides(command.run);
verify(
() => patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
localArtifact: any(named: 'localArtifact'),
releaseArtifact: any(named: 'releaseArtifact'),
archiveDiffer: archiveDiffer,
allowAssetChanges: true,
allowNativeChanges: true,
),
).called(1);
when(() => argResults['allow-asset-diffs']).thenReturn(false);
when(() => argResults['allow-native-diffs']).thenReturn(false);
await runWithOverrides(command.run);
verify(
() => patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
localArtifact: any(named: 'localArtifact'),
releaseArtifact: any(named: 'releaseArtifact'),
archiveDiffer: archiveDiffer,
allowAssetChanges: false,
allowNativeChanges: false,
),
).called(1);
});
test('reports when patch has asset and native changes', () async {
when(() => archiveDiffer.containsPotentiallyBreakingAssetDiffs(any()))
.thenReturn(true);
when(() => archiveDiffer.containsPotentiallyBreakingNativeDiffs(any()))
.thenReturn(true);
when(() => archiveDiffer.changedFiles(any(), any()))
.thenAnswer((_) async => FileSetDiff.empty());
when(
() => patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
localArtifact: any(named: 'localArtifact'),
releaseArtifact: any(named: 'releaseArtifact'),
archiveDiffer: archiveDiffer,
allowAssetChanges: any(named: 'allowAssetChanges'),
allowNativeChanges: any(named: 'allowNativeChanges'),
),
).thenAnswer(
(_) async => DiffStatus(
hasAssetChanges: true,
hasNativeChanges: true,
),
);
setUpProjectRootArtifacts();
final exitCode = await runWithOverrides(command.run);
expect(exitCode, equals(ExitCode.success.code));
verify(
() => codePushClientWrapper.publishPatch(
appId: appId,
releaseId: release.id,
platform: releasePlatform,
track: track,
patchArtifactBundles: any(named: 'patchArtifactBundles'),
metadata: any(
named: 'metadata',
that: isA<CreatePatchMetadata>()
.having(
(m) => m.releasePlatform,
'releasePlatform',
ReleasePlatform.android,
)
.having(
(m) => m.hasAssetChanges,
'hasAssetChanges',
true,
)
.having(
(m) => m.hasNativeChanges,
'hasNativeChanges',
true,
),
),
),
).called(1);
});
test('succeeds when patch is successful', () async {
setUpProjectRootArtifacts();
final exitCode = await runWithOverrides(command.run);
expect(exitCode, ExitCode.success.code);
verify(
() => logger.info(
any(
that: contains(
'''
🕹️ Platform: ${lightCyan.wrap(releasePlatform.name)} ${lightCyan.wrap('[arm32 (4 B), arm64 (4 B), x86_64 (4 B)]')}
🟢 Track: ${lightCyan.wrap('Production')}''',
),
),
),
).called(1);
verify(() => codePushClientWrapper.getApp(appId: appId)).called(1);
verify(() => codePushClientWrapper.getReleases(appId: appId)).called(1);
verify(
() => codePushClientWrapper.getReleaseArtifacts(
appId: appId,
releaseId: release.id,
architectures: Arch.values,
platform: releasePlatform,
),
).called(1);
verify(
() => codePushClientWrapper.getReleaseArtifact(
appId: appId,
releaseId: release.id,
arch: 'aar',
platform: releasePlatform,
),
).called(1);
verify(
() => codePushClientWrapper.publishPatch(
appId: appId,
releaseId: release.id,
platform: releasePlatform,
track: track,
patchArtifactBundles: any(named: 'patchArtifactBundles'),
metadata: const CreatePatchMetadata(
releasePlatform: releasePlatform,
usedIgnoreAssetChangesFlag: false,
hasAssetChanges: false,
usedIgnoreNativeChangesFlag: false,
hasNativeChanges: false,
linkPercentage: null,
environment: BuildEnvironmentMetadata(
shorebirdVersion: packageVersion,
operatingSystem: operatingSystem,
operatingSystemVersion: operatingSystemVersion,
xcodeVersion: null,
),
),
),
).called(1);
});
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 prompt if unable to accept user input', () async {
when(() => shorebirdEnv.canAcceptUserInput).thenReturn(false);
setUpProjectRootArtifacts();
final exitCode = await runWithOverrides(command.run);
expect(exitCode, equals(ExitCode.success.code));
verifyNever(() => logger.confirm(any()));
});
});
}
File diff suppressed because it is too large Load Diff
@@ -8,7 +8,7 @@ import 'package:shorebird_cli/src/artifact_builder.dart';
import 'package:shorebird_cli/src/artifact_manager.dart';
import 'package:shorebird_cli/src/cache.dart';
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/patch/patch.dart';
import 'package:shorebird_cli/src/config/config.dart';
import 'package:shorebird_cli/src/deployment_track.dart';
import 'package:shorebird_cli/src/logger.dart';
@@ -85,7 +85,7 @@ void main() {
late ShorebirdEnv shorebirdEnv;
late ShorebirdFlutter shorebirdFlutter;
late PatchNewCommand command;
late PatchCommand command;
R runWithOverrides<R>(R Function() body) {
return runScoped(
@@ -255,7 +255,7 @@ void main() {
),
).thenAnswer((_) async => {});
command = PatchNewCommand(resolvePatcher: (_) => patcher)
command = PatchCommand(resolvePatcher: (_) => patcher)
..testArgResults = argResults;
});
@@ -263,12 +263,6 @@ void main() {
expect(command.description, isNotEmpty);
});
group('hidden', () {
test('is true', () {
expect(command.hidden, true);
});
});
group('getPatcher', () {
test('maps the correct platform to the patcher', () async {
expect(
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -16,7 +16,7 @@ import 'package:shorebird_cli/src/artifact_manager.dart';
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/patch/patch.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';