refactor(shorebird_cli): delete old patch command and mixins (#2052)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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';
|
||||
+1
-1
@@ -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';
|
||||
+1
-1
@@ -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';
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+1
-1
@@ -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';
|
||||
+1
-1
@@ -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';
|
||||
+1
-1
@@ -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
-1
@@ -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
+3
-9
@@ -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
-1216
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user