feat(shorebird_cli): add ability to release with --no-codesign (#1267)

Co-authored-by: Felix Angelov <felix@shorebird.dev>
This commit is contained in:
Bryan Oltman
2023-09-15 14:13:00 -04:00
committed by GitHub
parent dfa6c33062
commit 2d047a42bd
32 changed files with 638 additions and 452 deletions
@@ -0,0 +1 @@
export 'directory_archive.dart';
@@ -0,0 +1,19 @@
import 'dart:isolate';
import 'package:archive/archive_io.dart';
import 'package:io/io.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart';
extension DirectoryArchive on Directory {
/// Copies this directory to a temporary directory and zips it.
Future<File> zipToTempFile() async {
final tempDir = await Directory.systemTemp.createTemp();
final outFile = File(p.join(tempDir.path, '${p.basename(path)}.zip'));
await Isolate.run(() {
copyPathSync(path, tempDir.path);
ZipFileEncoder().zipDirectory(tempDir, filename: outFile.path);
});
return outFile;
}
}
@@ -1,4 +1,4 @@
export 'android_archive_differ.dart';
export 'file_set_diff.dart';
export 'ios_archive_differ.dart';
export 'ipa.dart';
export 'plist.dart';
@@ -20,11 +20,13 @@ import 'package:shorebird_cli/src/archive_analysis/file_set_diff.dart';
class IosArchiveDiffer extends ArchiveDiffer {
String _hash(List<int> bytes) => sha256.convert(bytes).toString();
static const binaryFiles = {
'App.framework/App',
'Flutter.framework/Flutter',
static final binaryFilePatterns = {
RegExp(r'App.framework/App$'),
RegExp(r'Flutter.framework/Flutter$'),
};
static RegExp appRegex = RegExp(r'^Payload/[\w\-. ]+.app/[\w\- ]+$');
static RegExp appRegex = RegExp(
r'^Products/Applications/[\w\-. ]+.app/[\w\- ]+$',
);
/// Files that have been added, removed, or that have changed between the
/// archives at the two provided paths. This method will also unisgn mach-o
@@ -73,8 +75,8 @@ class IosArchiveDiffer extends ArchiveDiffer {
.where((file) => file.isFile)
.where(
(file) =>
file.name.endsWith('App.framework/App') ||
file.name.endsWith('Flutter.framework/Flutter') ||
binaryFilePatterns
.any((pattern) => pattern.hasMatch(file.name)) ||
appRegex.hasMatch(file.name),
)
.toList();
@@ -84,7 +86,7 @@ class IosArchiveDiffer extends ArchiveDiffer {
return ZipDecoder()
.decodeBuffer(InputFileStream(archivePath))
.files
.where((file) => file.isFile && p.extension(file.name) == '.car')
.where((file) => file.isFile && p.basename(file.name) == 'Assets.car')
.toList();
}
@@ -102,8 +104,7 @@ class IosArchiveDiffer extends ArchiveDiffer {
}
final outFile = File(outPath);
final hash = _hash(outFile.readAsBytesSync());
return hash;
return _hash(outFile.readAsBytesSync());
}
/// Uses assetutil to write a json description of a .car file to disk and
@@ -148,7 +149,7 @@ class IosArchiveDiffer extends ArchiveDiffer {
/// The flutter_assets directory contains the assets listed in the assets
/// section of the pubspec.yaml file.
/// Assets.car is the compiled asset catalog(s) (.xcassets files).
return p.extension(filePath) == '.car' ||
return p.basename(filePath) == 'Assets.car' ||
p.split(filePath).contains('flutter_assets');
}
@@ -1,74 +0,0 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:archive/archive_io.dart';
import 'package:propertylistserialization/propertylistserialization.dart';
/// {@template ipa_reader}
/// Wraps the [Ipa] class to make it easier to test.
/// {@endtemplate}
class IpaReader {
/// {@macro ipa_reader}
Ipa read(String path) => Ipa(path: path);
}
/// {@template ipa}
/// A class that represents an iOS IPA file.
/// {@endtemplate}
class Ipa {
/// {@macro ipa}
Ipa({
required String path,
}) : _ipaFile = File(path);
/// This key is a user-visible string for the version of the bundle. The
/// required format is three period-separated integers, such as 10.14.1. The
/// string can only contain numeric characters (0-9) and periods.
///
/// See https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundleshortversionstring
static const releaseVersionKey = 'CFBundleShortVersionString';
/// The version of the build that identifies an iteration of the bundle.
///
/// See https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundleversion
static const buildNumberKey = 'CFBundleVersion';
final File _ipaFile;
/// The version number of the IPA, as derived from the app's Info.plist.
String get versionNumber {
final plist = _getPlist();
if (plist == null) {
throw Exception('Could not find Info.plist');
}
final releaseVersion = plist[releaseVersionKey] as String?;
final buildNumber = plist[buildNumberKey] as String?;
if (releaseVersion == null) {
throw Exception('Could not determine release version');
}
return buildNumber == null
? releaseVersion
: '$releaseVersion+$buildNumber';
}
Map<String, Object>? _getPlist() {
final plistPathRegex = RegExp(r'Payload/[\w\-. ]+.app/Info.plist');
final plistFile = ZipDecoder()
.decodeBuffer(InputFileStream(_ipaFile.path))
.files
.where((file) {
return file.isFile && plistPathRegex.hasMatch(file.name);
}).firstOrNull;
if (plistFile == null) {
return null;
}
final content = plistFile.content as Uint8List;
return PropertyListSerialization.propertyListWithData(
ByteData.view(content.buffer),
) as Map<String, Object>;
}
}
@@ -0,0 +1,47 @@
import 'dart:io';
import 'package:propertylistserialization/propertylistserialization.dart';
class Plist {
Plist({required File file}) {
properties = PropertyListSerialization.propertyListWithString(
file.readAsStringSync(),
) as Map<String, Object>;
}
/// This key is a user-visible string for the version of the bundle. The
/// required format is three period-separated integers, such as 10.14.1. The
/// string can only contain numeric characters (0-9) and periods.
///
/// See https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundleshortversionstring
static const releaseVersionKey = 'CFBundleShortVersionString';
/// The version of the build that identifies an iteration of the bundle.
///
/// See https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundleversion
static const buildNumberKey = 'CFBundleVersion';
/// The Info.plist contained in .xcarchives has the following structure:
/// {
/// ApplicationProperties: {
/// CFBundleShortVersionString: "1.0.0",
/// CFBundleVersion: "1",
/// },
static const applicationPropertiesKey = 'ApplicationProperties';
late final Map<String, Object> properties;
String get versionNumber {
final applicationProperties =
properties[applicationPropertiesKey]! as Map<String, Object>;
final releaseVersion = applicationProperties[releaseVersionKey] as String?;
final buildNumber = applicationProperties[buildNumberKey] as String?;
if (releaseVersion == null) {
throw Exception('Could not determine release version');
}
return buildNumber == null
? releaseVersion
: '$releaseVersion+$buildNumber';
}
}
@@ -3,10 +3,12 @@ import 'dart:isolate';
import 'package:archive/archive_io.dart';
import 'package:collection/collection.dart';
import 'package:crypto/crypto.dart';
import 'package:io/io.dart' as io;
import 'package:mason_logger/mason_logger.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/archive/directory_archive.dart';
import 'package:shorebird_cli/src/auth/auth.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/shorebird_build_mixin.dart';
@@ -503,35 +505,51 @@ aar artifact already exists, continuing...''',
createArtifactProgress.complete();
}
/// Uploads a release ipa to the Shorebird server.
/// Removes all .dylib files from the given .xcarchive to reduce the size of
/// the uploaded artifact.
Future<Directory> _thinXcarchive({required String xcarchivePath}) async {
final xcarchiveDirectoryName = p.basename(xcarchivePath);
final tempDir = Directory.systemTemp.createTempSync();
final thinnedArchiveDirectory =
Directory(p.join(tempDir.path, xcarchiveDirectoryName));
await io.copyPath(xcarchivePath, thinnedArchiveDirectory.path);
thinnedArchiveDirectory
.listSync(recursive: true)
.whereType<File>()
.where((file) => p.extension(file.path) == '.dylib')
.forEach((file) => file.deleteSync());
return thinnedArchiveDirectory;
}
/// Uploads a release .xcarchive and .app to the Shorebird server.
Future<void> createIosReleaseArtifacts({
required String appId,
required int releaseId,
required String ipaPath,
required String xcarchivePath,
required String runnerPath,
}) async {
final createArtifactProgress = logger.progress('Creating artifacts');
final ipaFile = File(ipaPath);
final thinnedArchiveDirectory =
await _thinXcarchive(xcarchivePath: xcarchivePath);
final zippedArchive = await thinnedArchiveDirectory.zipToTempFile();
try {
await codePushClient.createReleaseArtifact(
appId: appId,
releaseId: releaseId,
artifactPath: ipaPath,
arch: 'ipa',
artifactPath: zippedArchive.path,
arch: 'xcarchive',
platform: ReleasePlatform.ios,
hash: sha256.convert(await ipaFile.readAsBytes()).toString(),
hash: sha256.convert(await zippedArchive.readAsBytes()).toString(),
);
} catch (error) {
_handleErrorAndExit(
error,
progress: createArtifactProgress,
message: 'Error uploading ipa: $error',
message: 'Error uploading xcarchive: $error',
);
}
final runnerDirectory = Directory(runnerPath);
await Isolate.run(() => ZipFileEncoder().zipDirectory(runnerDirectory));
final zippedRunner = File('$runnerPath.zip');
final zippedRunner = await Directory(runnerPath).zipToTempFile();
try {
await codePushClient.createReleaseArtifact(
appId: appId,
@@ -30,10 +30,8 @@ class PatchIosCommand extends ShorebirdCommand
PatchIosCommand({
HashFunction? hashFn,
IosArchiveDiffer? archiveDiffer,
IpaReader? ipaReader,
}) : _hashFn = hashFn ?? ((m) => sha256.convert(m).toString()),
_archiveDiffer = archiveDiffer ?? IosArchiveDiffer(),
_ipaReader = ipaReader ?? IpaReader() {
_archiveDiffer = archiveDiffer ?? IosArchiveDiffer() {
argParser
..addOption(
'target',
@@ -44,6 +42,11 @@ class PatchIosCommand extends ShorebirdCommand
'flavor',
help: 'The product flavor to use when building the app.',
)
..addFlag(
'codesign',
help: 'Codesign the application bundle.',
defaultsTo: true,
)
..addFlag(
'force',
abbr: 'f',
@@ -67,7 +70,6 @@ class PatchIosCommand extends ShorebirdCommand
final HashFunction _hashFn;
final IosArchiveDiffer _archiveDiffer;
final IpaReader _ipaReader;
@override
Future<int> run() async {
@@ -107,30 +109,30 @@ class PatchIosCommand extends ShorebirdCommand
return ExitCode.software.code;
}
final detectReleaseVersionProgress = logger.progress(
'Detecting release version',
final archivePath = p.join(
Directory.current.path,
'build',
'ios',
'archive',
'Runner.xcarchive',
);
final String ipaPath;
final plistFile = File(p.join(archivePath, 'Info.plist'));
if (!plistFile.existsSync()) {
logger.err('No Info.plist file found at ${plistFile.path}.');
return ExitCode.software.code;
}
final plist = Plist(file: plistFile);
final String releaseVersion;
try {
ipaPath = getIpaPath();
releaseVersion = plist.versionNumber;
} catch (error) {
detectReleaseVersionProgress.fail('Could not find ipa file: $error');
return ExitCode.software.code;
}
try {
final ipa = _ipaReader.read(ipaPath);
releaseVersion = ipa.versionNumber;
detectReleaseVersionProgress.complete(
'Detected release version $releaseVersion',
);
} catch (error) {
detectReleaseVersionProgress.fail(
'Failed to determine release version: $error',
);
logger.err('Failed to determine release version: $error');
return ExitCode.software.code;
}
logger.info('Detected release version $releaseVersion');
final release = await codePushClientWrapper.getRelease(
appId: appId,
releaseVersion: releaseVersion,
@@ -169,7 +171,7 @@ Current Flutter Revision: $originalFlutterRevision
return ExitCode.software.code;
} finally {
flutterVersionProgress = logger.progress(
'Switching back to original Flutter revision $originalFlutterRevision',
'''Switching back to original Flutter revision $originalFlutterRevision''',
);
await shorebirdFlutter.useRevision(revision: originalFlutterRevision);
flutterVersionProgress.complete();
@@ -179,13 +181,13 @@ Current Flutter Revision: $originalFlutterRevision
final releaseArtifact = await codePushClientWrapper.getReleaseArtifact(
appId: appId,
releaseId: release.id,
arch: 'ipa',
arch: 'xcarchive',
platform: ReleasePlatform.ios,
);
try {
await patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
localArtifact: File(ipaPath),
await patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary(
localArtifactDirectory: Directory(archivePath),
releaseArtifactUrl: Uri.parse(releaseArtifact.url),
archiveDiffer: _archiveDiffer,
force: force,
@@ -224,8 +226,6 @@ ${summary.join('\n')}
''',
);
// TODO(bryanoltman): check for asset changes
final needsConfirmation = !force && !shorebirdEnv.isRunningOnCI;
if (needsConfirmation) {
final confirm = logger.confirm('Would you like to continue?');
@@ -260,9 +260,12 @@ ${summary.join('\n')}
Future<void> _buildPatch() async {
final target = results['target'] as String?;
final flavor = results['flavor'] as String?;
final shouldCodesign = results['codesign'] == true;
final buildProgress = logger.progress('Building patch');
try {
await buildIpa(flavor: flavor, target: target);
// If buildIpa is called with a different codesign value than the release
// was, we will erroneously report native diffs.
await buildIpa(codesign: shouldCodesign, flavor: flavor, target: target);
} on ProcessException catch (error) {
buildProgress.fail('Failed to build: ${error.message}');
rethrow;
@@ -1,6 +1,5 @@
import 'dart:io' hide Platform;
import 'package:archive/archive_io.dart';
import 'package:collection/collection.dart';
import 'package:crypto/crypto.dart';
import 'package:mason_logger/mason_logger.dart';
@@ -168,18 +167,6 @@ Please re-run the release command for this version or create a new release.''');
buildProgress.complete();
const zippedFrameworkFileName =
'${ShorebirdArtifactMixin.appXcframeworkName}.zip';
final tempDir = Directory.systemTemp.createTempSync();
final zippedFrameworkPath = p.join(
tempDir.path,
zippedFrameworkFileName,
);
ZipFileEncoder().zipDirectory(
Directory(getAppXcframeworkPath()),
filename: zippedFrameworkPath,
);
final releaseArtifact = await codePushClientWrapper.getReleaseArtifact(
appId: appId,
releaseId: release.id,
@@ -188,8 +175,8 @@ Please re-run the release command for this version or create a new release.''');
);
try {
await patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
localArtifact: File(zippedFrameworkPath),
await patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary(
localArtifactDirectory: Directory(getAppXcframeworkPath()),
releaseArtifactUrl: Uri.parse(releaseArtifact.url),
archiveDiffer: _archiveDiffer,
force: force,
@@ -23,9 +23,7 @@ import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
class ReleaseIosCommand extends ShorebirdCommand
with ShorebirdBuildMixin, ShorebirdArtifactMixin {
/// {@macro release_ios_command}
ReleaseIosCommand({
IpaReader? ipaReader,
}) : _ipaReader = ipaReader ?? IpaReader() {
ReleaseIosCommand() {
argParser
..addOption(
'target',
@@ -36,6 +34,11 @@ class ReleaseIosCommand extends ShorebirdCommand
'flavor',
help: 'The product flavor to use when building the app.',
)
..addFlag(
'codesign',
help: 'Codesign the application bundle.',
defaultsTo: true,
)
..addFlag(
'force',
abbr: 'f',
@@ -44,8 +47,6 @@ class ReleaseIosCommand extends ShorebirdCommand
);
}
final IpaReader _ipaReader;
@override
String get description => '''
Builds and submits your iOS app to Shorebird.
@@ -71,6 +72,17 @@ make smaller updates to your app.
showiOSStatusWarning();
final codesign = results['codesign'] == true;
if (!codesign) {
logger
..info(
'''Building for device with codesigning disabled. You will have to manually codesign before deploying to device.''',
)
..warn(
'''shorebird preview will not work for releases created with "--no-codesign". However, you can still preview your app by signing the generated .xcarchive in Xcode.''',
);
}
const releasePlatform = ReleasePlatform.ios;
final flavor = results['flavor'] as String?;
final target = results['target'] as String?;
@@ -80,48 +92,54 @@ make smaller updates to your app.
final buildProgress = logger.progress('Building release');
try {
await buildIpa(flavor: flavor, target: target);
await buildIpa(codesign: codesign, flavor: flavor, target: target);
} on ProcessException catch (error) {
buildProgress.fail('Failed to build IPA: ${error.message}');
buildProgress.fail('Failed to build: ${error.message}');
return ExitCode.software.code;
} on BuildException catch (error) {
buildProgress.fail('Failed to build IPA');
buildProgress.fail('Failed to build');
logger.err(error.message);
return ExitCode.software.code;
}
buildProgress.complete();
final releaseVersionProgress = logger.progress('Getting release version');
final iosBuildDir = p.join(Directory.current.path, 'build', 'ios');
// Ensure the ipa was built
final String ipaPath;
try {
ipaPath = getIpaPath();
} catch (error) {
releaseVersionProgress.fail('Could not find ipa file: $error');
logger.err('Could not find ipa file: $error');
return ExitCode.software.code;
}
final runnerPath = p.join(
final iosBuildDir = p.join(Directory.current.path, 'build', 'ios');
final archivePath = p.join(
iosBuildDir,
'archive',
'Runner.xcarchive',
);
final runnerPath = p.join(
archivePath,
'Products',
'Applications',
'Runner.app',
);
String releaseVersion;
try {
final ipa = _ipaReader.read(ipaPath);
releaseVersion = ipa.versionNumber;
} catch (error) {
releaseVersionProgress.fail(
'Failed to determine release version: $error',
);
final plistFile = File(p.join(archivePath, 'Info.plist'));
if (!plistFile.existsSync()) {
logger.err('No Info.plist file found at ${plistFile.path}.');
return ExitCode.software.code;
}
releaseVersionProgress.complete();
final plist = Plist(file: plistFile);
final String releaseVersion;
try {
releaseVersion = plist.versionNumber;
} catch (error) {
logger.err('Failed to determine release version: $error');
return ExitCode.software.code;
}
final existingRelease = await codePushClientWrapper.maybeGetRelease(
appId: appId,
@@ -177,12 +195,10 @@ ${summary.join('\n')}
);
}
final relativeIpaPath = p.relative(ipaPath);
await codePushClientWrapper.createIosReleaseArtifacts(
appId: app.appId,
releaseId: release.id,
ipaPath: ipaPath,
xcarchivePath: archivePath,
runnerPath: runnerPath,
);
@@ -193,9 +209,11 @@ ${summary.join('\n')}
status: ReleaseStatus.active,
);
logger
..success('\n✅ Published Release!')
..info('''
logger.success('\n✅ Published Release!');
if (codesign) {
final relativeIpaPath = p.relative(ipaPath);
logger.info('''
Your next step is to upload the ipa to App Store Connect.
${lightCyan.wrap(relativeIpaPath)}
@@ -205,6 +223,17 @@ To upload to the App Store either:
2. Run ${lightCyan.wrap('xcrun altool --upload-app --type ios -f $relativeIpaPath --apiKey your_api_key --apiIssuer your_issuer_id')}.
See "man altool" for details about how to authenticate with the App Store Connect API key.
''');
} else {
logger.info('''
Your next step is to submit the archive at ${lightCyan.wrap(archivePath)} to the App Store using Xcode.
You can open the archive in Xcode by running:
${lightCyan.wrap('open $archivePath')}
${styleBold.wrap('Make sure to uncheck "Manage Version and Build Number", or else shorebird will not work.')}
''');
}
return ExitCode.success.code;
}
@@ -4,6 +4,7 @@ import 'package:http/http.dart' as http;
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/archive.dart';
import 'package:shorebird_cli/src/archive_analysis/archive_differ.dart';
import 'package:shorebird_cli/src/http_client/http_client.dart';
import 'package:shorebird_cli/src/logger.dart';
@@ -35,6 +36,26 @@ class PatchDiffChecker {
final http.Client _httpClient;
/// Zips the contents of [localArtifactDirectory] to a temporary file and
/// forwards to [confirmUnpatchableDiffsIfNecessary].
Future<void> zipAndConfirmUnpatchableDiffsIfNecessary({
required Directory localArtifactDirectory,
required Uri releaseArtifactUrl,
required ArchiveDiffer archiveDiffer,
required bool force,
}) async {
final zipProgress = logger.progress('Compressing archive');
final zippedFile = await localArtifactDirectory.zipToTempFile();
zipProgress.complete();
return confirmUnpatchableDiffsIfNecessary(
localArtifact: zippedFile,
releaseArtifactUrl: releaseArtifactUrl,
archiveDiffer: archiveDiffer,
force: force,
);
}
/// Downloads the release artifact at [releaseArtifactUrl] and checks for
/// differences that could cause issues when applying the patch represented by
/// [localArtifact].
@@ -182,10 +182,12 @@ mixin ShorebirdBuildMixin on ShorebirdCommand {
});
}
/// Calls `flutter build ipa`. If [codesign] is false, this will only build
/// an .xcarchive and _not_ an .ipa.
Future<void> buildIpa({
required bool codesign,
String? flavor,
String? target,
bool codesign = true,
}) async {
return _runShorebirdBuildCommand(() async {
const executable = 'flutter';
+1
View File
@@ -20,6 +20,7 @@ dependencies:
googleapis_auth: ^1.4.0
http: ^1.0.0
intl: ^0.18.0
io: ^1.0.4
json_annotation: ^4.8.0
mason_logger: ^0.2.8
meta: ^1.9.0
-12
View File
@@ -1,12 +0,0 @@
The ipa files in this folder were generated by building the stock Flutter counter app with `shorebird build ipa`.
Some of their contents has been removed to reduce the size of the files. All .dylib files have been removed.
Files:
- base.ipa is meant to represent an ipa uploaded as part of a release. App.framework/App and Flutter.framework/Flutter have been removed to save space.
- no_version.ipa an .ipa with the version info removed from the Info.plist, along with several other files to save space.
- no_plist.ipa an .ipa with the Info.plist (and many other parts) removed.
- app_file_space.ipa is an .ipa with a space in the .app file name.
- asset_changes.ipa is built from the same codebase as base.ipa with a change made to one of the assets. App.framework/App and Flutter.framework/Flutter have been removed to save space.
- dart_changes.ipa is built from the same codebase as base.ipa with a change made to the dart code. App.framework/App and Flutter.framework/Flutter have been removed to save space.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,8 @@
The xcarchives in this folder were generated by building the stock Flutter counter app with `shorebird release ios-alpha --no-codesign`. .dylib files have been removed to reduce size.
Files:
- Runner.base.xcarchive is meant to represent an xcarchive uploaded as part of a release.
- Runner.changed_asset.xcarchive was made with same code as Runner.base.xcarchive, delta a change to a json asset file.
- Runner.changed_dart.xcarchive was made with same code as Runner.base.xcarchive, delta a change to lib/main.dart.
- Runner.changed_swift.xcarchive was made with same code as Runner.base.xcarchive, delta a change to ios/Runner/AppDelegate.swift.
@@ -4,11 +4,23 @@ import 'package:shorebird_cli/src/platform.dart';
import 'package:test/test.dart';
void main() {
final ipaFixturesBasePath = p.join('test', 'fixtures', 'ipas');
final baseIpaPath = p.join(ipaFixturesBasePath, 'base.ipa');
final changedAssetIpaPath = p.join(ipaFixturesBasePath, 'asset_changes.ipa');
final changedDartIpaPath = p.join(ipaFixturesBasePath, 'dart_changes.ipa');
final changedSwiftIpaPath = p.join(ipaFixturesBasePath, 'swift_changes.ipa');
final xcarchiveFixturesBasePath = p.join('test', 'fixtures', 'xcarchives');
final baseIpaPath = p.join(
xcarchiveFixturesBasePath,
'base.xcarchive.zip',
);
final changedAssetIpaPath = p.join(
xcarchiveFixturesBasePath,
'changed_asset.xcarchive.zip',
);
final changedDartIpaPath = p.join(
xcarchiveFixturesBasePath,
'changed_dart.xcarchive.zip',
);
final changedSwiftIpaPath = p.join(
xcarchiveFixturesBasePath,
'changed_swift.xcarchive.zip',
);
final xcframeworkFixturesBasePath = p.join(
'test',
@@ -34,29 +46,33 @@ void main() {
group('appRegex', () {
test('identifies Runner.app/Runner as an app file', () {
expect(
IosArchiveDiffer.appRegex.hasMatch('Payload/Runner.app/Runner'),
IosArchiveDiffer.appRegex.hasMatch(
'Products/Applications/Runner.app/Runner',
),
isTrue,
);
});
test('does not identify Runner.app/Assets.car as an app file', () {
expect(
IosArchiveDiffer.appRegex.hasMatch('Payload/Runner.app/Assets.car'),
IosArchiveDiffer.appRegex.hasMatch(
'Products/Applications/Runner.app/Assets.car',
),
isFalse,
);
});
});
group('ipa', () {
group('xcarchive', () {
group('changedPaths', () {
test('finds no differences between the same ipa', () {
test('finds no differences between the same xcarchive', () {
expect(
differ.changedFiles(baseIpaPath, baseIpaPath),
isEmpty,
);
});
test('finds differences between two different ipas', () {
test('finds differences between two different xcarchives', () {
final fileSetDiff = differ.changedFiles(
baseIpaPath,
changedAssetIpaPath,
@@ -65,27 +81,19 @@ void main() {
expect(
fileSetDiff.changedPaths,
{
'Payload/Runner.app/_CodeSignature/CodeResources',
'Payload/Runner.app/Frameworks/App.framework/_CodeSignature/CodeResources',
'Payload/Runner.app/Frameworks/App.framework/flutter_assets/assets/asset.json',
'Symbols/4C4C4411-5555-3144-A13A-E47369D8ACD5.symbols',
'Symbols/BC970605-0A53-3457-8736-D7A870AB6E71.symbols',
'Symbols/0CBBC9EF-0745-3074-81B7-765F5B4515FD.symbols',
'Products/Applications/Runner.app/Frameworks/App.framework/_CodeSignature/CodeResources',
'Products/Applications/Runner.app/Frameworks/App.framework/flutter_assets/assets/asset.json',
'Info.plist',
},
);
} else {
expect(
fileSetDiff.changedPaths,
{
'Payload/Runner.app/_CodeSignature/CodeResources',
'Payload/Runner.app/Runner',
'Payload/Runner.app/Frameworks/Flutter.framework/Flutter',
'Payload/Runner.app/Frameworks/App.framework/_CodeSignature/CodeResources',
'Payload/Runner.app/Frameworks/App.framework/App',
'Payload/Runner.app/Frameworks/App.framework/flutter_assets/assets/asset.json',
'Symbols/4C4C4411-5555-3144-A13A-E47369D8ACD5.symbols',
'Symbols/BC970605-0A53-3457-8736-D7A870AB6E71.symbols',
'Symbols/0CBBC9EF-0745-3074-81B7-765F5B4515FD.symbols',
'Products/Applications/Runner.app/Frameworks/App.framework/_CodeSignature/CodeResources',
'Products/Applications/Runner.app/Frameworks/App.framework/App',
'Products/Applications/Runner.app/Frameworks/App.framework/flutter_assets/assets/asset.json',
'Info.plist',
},
);
}
@@ -101,10 +109,7 @@ void main() {
differ.dartFileSetDiff(fileSetDiff),
platform.isMacOS ? isEmpty : isNotEmpty,
);
expect(
differ.nativeFileSetDiff(fileSetDiff),
platform.isMacOS ? isEmpty : isNotEmpty,
);
expect(differ.nativeFileSetDiff(fileSetDiff), isEmpty);
});
test('detects dart changes', () {
@@ -112,20 +117,14 @@ void main() {
differ.changedFiles(baseIpaPath, changedDartIpaPath);
expect(differ.assetsFileSetDiff(fileSetDiff), isEmpty);
expect(differ.dartFileSetDiff(fileSetDiff), isNotEmpty);
expect(
differ.nativeFileSetDiff(fileSetDiff),
platform.isMacOS ? isEmpty : isNotEmpty,
);
expect(differ.nativeFileSetDiff(fileSetDiff), isEmpty);
});
test('detects swift changes', () {
final fileSetDiff =
differ.changedFiles(baseIpaPath, changedSwiftIpaPath);
expect(differ.assetsFileSetDiff(fileSetDiff), isEmpty);
expect(
differ.dartFileSetDiff(fileSetDiff),
platform.isMacOS ? isEmpty : isNotEmpty,
);
expect(differ.dartFileSetDiff(fileSetDiff), isEmpty);
expect(differ.nativeFileSetDiff(fileSetDiff), isNotEmpty);
});
});
@@ -173,7 +172,7 @@ void main() {
);
expect(
differ.containsPotentiallyBreakingNativeDiffs(fileSetDiff),
platform.isMacOS ? isFalse : isTrue,
isFalse,
);
});
});
@@ -1,41 +0,0 @@
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart';
import 'package:test/test.dart';
void main() {
final ipaFixturesBasePath = p.join('test', 'fixtures', 'ipas');
final baseIpaBath = p.join(ipaFixturesBasePath, 'base.ipa');
final noVersionIpaPath = p.join(ipaFixturesBasePath, 'no_version.ipa');
final noPlistIpaPath = p.join(ipaFixturesBasePath, 'no_plist.ipa');
final spaceInAppFileNameIpaPath =
p.join(ipaFixturesBasePath, 'app_file_space.ipa');
group(IpaReader, () {
test('creates Ipa', () {
final ipa = IpaReader().read(baseIpaBath);
expect(ipa.versionNumber, '1.0.0+1');
});
});
group(Ipa, () {
test('reads app version from ipa', () {
final ipa = Ipa(path: baseIpaBath);
expect(ipa.versionNumber, '1.0.0+1');
});
test('reads app version from ipa with space in app file name', () {
final ipa = Ipa(path: spaceInAppFileNameIpaPath);
expect(ipa.versionNumber, '1.0.0+1');
});
test('throws exception if no Info.plist is found', () {
final ipa = Ipa(path: noPlistIpaPath);
expect(() => ipa.versionNumber, throwsException);
});
test('throws exception if no version is found in Info.plist', () {
final ipa = Ipa(path: noVersionIpaPath);
expect(() => ipa.versionNumber, throwsException);
});
});
}
@@ -1483,12 +1483,14 @@ Please bump your version number and try again.''',
});
group('createIosReleaseArtifacts', () {
final ipaPath = p.join('path', 'to', 'app.ipa');
final xcarchivePath = p.join('path', 'to', 'app.xcarchive');
final runnerPath = p.join('path', 'to', 'runner.app');
Directory setUpTempDir({String? flavor}) {
final tempDir = Directory.systemTemp.createTempSync();
File(p.join(tempDir.path, ipaPath)).createSync(recursive: true);
Directory(p.join(tempDir.path, xcarchivePath))
.createSync(recursive: true);
Directory(p.join(tempDir.path, runnerPath)).createSync(recursive: true);
return tempDir;
}
@@ -1505,12 +1507,14 @@ Please bump your version number and try again.''',
).thenAnswer((_) async => {});
});
test('exits with code 70 when ipa artifact creation fails', () async {
test('exits with code 70 when xcarchive artifact creation fails',
() async {
const error = 'something went wrong';
when(
() => codePushClient.createReleaseArtifact(
appId: any(named: 'appId'),
artifactPath: any(named: 'artifactPath', that: endsWith('.ipa')),
artifactPath:
any(named: 'artifactPath', that: endsWith('.xcarchive.zip')),
releaseId: any(named: 'releaseId'),
arch: any(named: 'arch'),
platform: any(named: 'platform'),
@@ -1525,7 +1529,7 @@ Please bump your version number and try again.''',
() async => codePushClientWrapper.createIosReleaseArtifacts(
appId: app.appId,
releaseId: releaseId,
ipaPath: p.join(tempDir.path, ipaPath),
xcarchivePath: p.join(tempDir.path, xcarchivePath),
runnerPath: p.join(tempDir.path, runnerPath),
),
),
@@ -1537,13 +1541,14 @@ Please bump your version number and try again.''',
verify(() => progress.fail(any(that: contains(error)))).called(1);
});
test('exits with code 70 when uploading ipa that already exists',
test('exits with code 70 when uploading xcarchive that already exists',
() async {
const error = 'something went wrong';
when(
() => codePushClient.createReleaseArtifact(
appId: any(named: 'appId'),
artifactPath: any(named: 'artifactPath', that: endsWith('.ipa')),
artifactPath:
any(named: 'artifactPath', that: endsWith('.xcarchive.zip')),
releaseId: any(named: 'releaseId'),
arch: any(named: 'arch'),
platform: any(named: 'platform'),
@@ -1558,7 +1563,7 @@ Please bump your version number and try again.''',
() async => codePushClientWrapper.createIosReleaseArtifacts(
appId: app.appId,
releaseId: releaseId,
ipaPath: p.join(tempDir.path, ipaPath),
xcarchivePath: p.join(tempDir.path, xcarchivePath),
runnerPath: p.join(tempDir.path, runnerPath),
),
),
@@ -1594,7 +1599,7 @@ Please bump your version number and try again.''',
() async => codePushClientWrapper.createIosReleaseArtifacts(
appId: app.appId,
releaseId: releaseId,
ipaPath: p.join(tempDir.path, ipaPath),
xcarchivePath: p.join(tempDir.path, xcarchivePath),
runnerPath: p.join(tempDir.path, runnerPath),
),
),
@@ -1624,7 +1629,7 @@ Please bump your version number and try again.''',
() async => codePushClientWrapper.createIosReleaseArtifacts(
appId: app.appId,
releaseId: releaseId,
ipaPath: p.join(tempDir.path, ipaPath),
xcarchivePath: p.join(tempDir.path, xcarchivePath),
runnerPath: p.join(tempDir.path, runnerPath),
),
getCurrentDirectory: () => tempDir,
@@ -189,7 +189,8 @@ Please make sure you are running "shorebird init" from the root of your Flutter
final exitCode = await runWithOverrides(command.run);
verify(
() => logger.err(
'A "shorebird.yaml" file already exists and seems up-to-date.'),
'A "shorebird.yaml" file already exists and seems up-to-date.',
),
).called(1);
verify(
() => logger.info(
@@ -37,12 +37,8 @@ class _MockCodePushClientWrapper extends Mock
class _MockDoctor extends Mock implements Doctor {}
class _MockIpa extends Mock implements Ipa {}
class _MockIpaDiffer extends Mock implements IosArchiveDiffer {}
class _MockIpaReader extends Mock implements IpaReader {}
class _MockLogger extends Mock implements Logger {}
class _MockPatchDiffChecker extends Mock implements PatchDiffChecker {}
@@ -85,10 +81,40 @@ void main() {
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>app_bundle_name</string>
<key>ApplicationProperties</key>
<dict>
<key>ApplicationPath</key>
<string>Applications/Runner.app</string>
<key>Architectures</key>
<array>
<string>arm64</string>
</array>
<key>CFBundleIdentifier</key>
<string>com.shorebird.timeShift</string>
<key>CFBundleShortVersionString</key>
<string>1.2.3</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
<key>ArchiveVersion</key>
<integer>2</integer>
<key>Name</key>
<string>Runner</string>
<key>SchemeName</key>
<string>Runner</string>
</dict>
</plist>''';
const emptyPlistContent = '''
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ApplicationProperties</key>
<dict>
</dict>
</dict>
</plist>'
''';
const pubspecYamlContent = '''
name: example
version: $version
@@ -126,9 +152,7 @@ flutter:
late Directory shorebirdRoot;
late File genSnapshotFile;
late Doctor doctor;
late Ipa ipa;
late IosArchiveDiffer archiveDiffer;
late IpaReader ipaReader;
late Progress progress;
late Logger logger;
late PatchDiffChecker patchDiffChecker;
@@ -171,7 +195,14 @@ flutter:
p.join(tempDir.path, 'shorebird.yaml'),
).writeAsStringSync('app_id: $appId');
File(
p.join(tempDir.path, 'ios', 'Runner', 'Info.plist'),
p.join(
tempDir.path,
'build',
'ios',
'archive',
'Runner.xcarchive',
'Info.plist',
),
)
..createSync(recursive: true)
..writeAsStringSync(infoPlistContent);
@@ -199,6 +230,7 @@ flutter:
}
setUpAll(() {
registerFallbackValue(Directory(''));
registerFallbackValue(File(''));
registerFallbackValue(FileSetDiff.empty());
registerFallbackValue(ReleasePlatform.ios);
@@ -227,9 +259,7 @@ flutter:
'gen_snapshot_arm64',
),
);
ipa = _MockIpa();
archiveDiffer = _MockIpaDiffer();
ipaReader = _MockIpaReader();
progress = _MockProgress();
logger = _MockLogger();
platform = _MockPlatform();
@@ -277,8 +307,6 @@ flutter:
patchArtifactBundles: any(named: 'patchArtifactBundles'),
),
).thenAnswer((_) async {});
when(() => ipa.versionNumber).thenReturn(version);
when(() => ipaReader.read(any())).thenReturn(ipa);
when(() => doctor.iosCommandValidators).thenReturn([flutterValidator]);
when(flutterValidator.validate).thenAnswer((_) async => []);
when(() => logger.confirm(any())).thenReturn(true);
@@ -333,8 +361,8 @@ flutter:
),
).thenAnswer((_) async {});
when(
() => patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
localArtifact: any(named: 'localArtifact'),
() => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary(
localArtifactDirectory: any(named: 'localArtifactDirectory'),
releaseArtifactUrl: any(named: 'releaseArtifactUrl'),
archiveDiffer: archiveDiffer,
force: any(named: 'force'),
@@ -342,8 +370,7 @@ flutter:
).thenAnswer((_) async => {});
command = runWithOverrides(
() =>
PatchIosCommand(archiveDiffer: archiveDiffer, ipaReader: ipaReader),
() => PatchIosCommand(archiveDiffer: archiveDiffer),
)..testArgResults = argResults;
});
@@ -451,63 +478,7 @@ error: exportArchive: No signing certificate "iOS Distribution" found
expect(exitCode, equals(ExitCode.software.code));
verify(
() => progress.fail(
any(
that: stringContainsInOrder([
'Could not find ipa file',
'No directory found at ${p.join(tempDir.path, 'build')}',
]),
),
),
).called(1);
});
test('exits with code 70 if ipa file does not exist', () async {
final tempDir = setUpTempDir();
setUpTempArtifacts(tempDir);
File(p.join(tempDir.path, ipaPath)).deleteSync(recursive: true);
final exitCode = await IOOverrides.runZoned(
() => runWithOverrides(command.run),
getCurrentDirectory: () => tempDir,
);
expect(exitCode, equals(ExitCode.software.code));
verify(
() => progress.fail(
any(
that: stringContainsInOrder([
'Could not find ipa file',
'No .ipa files found in',
'build/ios/ipa',
]),
),
),
).called(1);
});
test('exits with code 70 if more than one ipa file is found', () async {
final tempDir = setUpTempDir();
setUpTempArtifacts(tempDir);
File(p.join(tempDir.path, 'build/ios/ipa/Runner2.ipa'))
.createSync(recursive: true);
final exitCode = await IOOverrides.runZoned(
() => runWithOverrides(command.run),
getCurrentDirectory: () => tempDir,
);
expect(exitCode, equals(ExitCode.software.code));
verify(
() => progress.fail(
any(
that: stringContainsInOrder([
'Could not find ipa file',
'More than one .ipa file found in',
'build/ios/ipa',
]),
),
),
() => logger.err(any(that: contains('No Info.plist file found'))),
).called(1);
});
@@ -659,10 +630,21 @@ Please re-run the release command for this version or create a new release.'''),
test('exits with code 70 when release version cannot be determiend',
() async {
when(() => ipa.versionNumber).thenThrow(Exception('oops'));
final tempDir = setUpTempDir();
setUpTempArtifacts(tempDir);
File(
p.join(
tempDir.path,
'build',
'ios',
'archive',
'Runner.xcarchive',
'Info.plist',
),
)
..createSync(recursive: true)
..writeAsStringSync(emptyPlistContent);
final exitCode = await IOOverrides.runZoned(
() => runWithOverrides(command.run),
getCurrentDirectory: () => tempDir,
@@ -670,7 +652,7 @@ Please re-run the release command for this version or create a new release.'''),
expect(exitCode, equals(ExitCode.software.code));
verify(
() => progress.fail(
() => logger.err(
any(that: contains('Failed to determine release version')),
),
).called(1);
@@ -679,14 +661,14 @@ Please re-run the release command for this version or create a new release.'''),
test('prints release version when detected', () async {
final tempDir = setUpTempDir();
setUpTempArtifacts(tempDir);
final exitCode = await IOOverrides.runZoned(
() => runWithOverrides(command.run),
getCurrentDirectory: () => tempDir,
);
expect(exitCode, equals(ExitCode.success.code));
verify(() => progress.complete('Detected release version 1.2.3+1'))
.called(1);
verify(() => logger.info('Detected release version 1.2.3+1')).called(1);
});
test('aborts when user opts out', () async {
@@ -717,62 +699,13 @@ Please re-run the release command for this version or create a new release.'''),
expect(exitCode, ExitCode.software.code);
});
test('exits with code 70 when ipa not found', () async {
final tempDir = setUpTempDir();
setUpTempArtifacts(tempDir);
File(p.join(tempDir.path, ipaPath)).deleteSync();
final exitCode = await IOOverrides.runZoned(
() => runWithOverrides(command.run),
getCurrentDirectory: () => tempDir,
);
expect(exitCode, equals(ExitCode.software.code));
verify(
() => progress.fail(any(that: contains('Could not find ipa file'))),
).called(1);
});
test('exits with code 70 when local release version cannot be determiend',
() async {
when(() => ipa.versionNumber).thenThrow(Exception('oops'));
final tempDir = setUpTempDir();
setUpTempArtifacts(tempDir);
final exitCode = await IOOverrides.runZoned(
() => runWithOverrides(command.run),
getCurrentDirectory: () => tempDir,
);
expect(exitCode, equals(ExitCode.software.code));
verify(
() => progress.fail(
any(that: contains('Failed to determine release version')),
),
).called(1);
});
test('prints local release version when detected', () async {
final tempDir = setUpTempDir();
setUpTempArtifacts(tempDir);
final exitCode = await IOOverrides.runZoned(
() => runWithOverrides(command.run),
getCurrentDirectory: () => tempDir,
);
expect(exitCode, equals(ExitCode.success.code));
verify(
() => progress.complete('Detected release version 1.2.3+1'),
).called(1);
});
test(
'''exits with code 0 if confirmUnpatchableDiffsIfNecessary throws UserCancelledException''',
'''exits with code 0 if zipAndConfirmUnpatchableDiffsIfNecessary throws UserCancelledException''',
() async {
when(() => argResults['force']).thenReturn(false);
when(
() => patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
localArtifact: any(named: 'localArtifact'),
() => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary(
localArtifactDirectory: any(named: 'localArtifactDirectory'),
releaseArtifactUrl: any(named: 'releaseArtifactUrl'),
archiveDiffer: archiveDiffer,
force: any(named: 'force'),
@@ -788,8 +721,8 @@ Please re-run the release command for this version or create a new release.'''),
expect(exitCode, equals(ExitCode.success.code));
verify(
() => patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
localArtifact: any(named: 'localArtifact'),
() => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary(
localArtifactDirectory: any(named: 'localArtifactDirectory'),
releaseArtifactUrl: Uri.parse(ipaArtifact.url),
archiveDiffer: archiveDiffer,
force: false,
@@ -807,12 +740,12 @@ Please re-run the release command for this version or create a new release.'''),
});
test(
'''exits with code 70 if confirmUnpatchableDiffsIfNecessary throws UnpatchableChangeException''',
'''exits with code 70 if zipAndConfirmUnpatchableDiffsIfNecessary throws UnpatchableChangeException''',
() async {
when(() => argResults['force']).thenReturn(false);
when(
() => patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
localArtifact: any(named: 'localArtifact'),
() => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary(
localArtifactDirectory: any(named: 'localArtifactDirectory'),
releaseArtifactUrl: any(named: 'releaseArtifactUrl'),
archiveDiffer: archiveDiffer,
force: any(named: 'force'),
@@ -828,8 +761,8 @@ Please re-run the release command for this version or create a new release.'''),
expect(exitCode, equals(ExitCode.software.code));
verify(
() => patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
localArtifact: any(named: 'localArtifact'),
() => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary(
localArtifactDirectory: any(named: 'localArtifactDirectory'),
releaseArtifactUrl: Uri.parse(ipaArtifact.url),
archiveDiffer: archiveDiffer,
force: false,
@@ -933,6 +866,33 @@ Please re-run the release command for this version or create a new release.'''),
).called(1);
});
test('forwards codesign to flutter build', () async {
when(() => argResults['codesign']).thenReturn(false);
final tempDir = setUpTempDir();
setUpTempArtifacts(tempDir);
await IOOverrides.runZoned(
() => runWithOverrides(command.run),
getCurrentDirectory: () => tempDir,
);
verify(
() => shorebirdProcess.run(
'flutter',
any(
that: containsAllInOrder(
[
'build',
'ipa',
'--release',
'--no-codesign',
],
),
),
runInShell: true,
),
).called(1);
});
test(
'succeeds when patch is successful '
'with flavors and target', () async {
@@ -179,6 +179,7 @@ flutter:
}
setUpAll(() {
registerFallbackValue(Directory(''));
registerFallbackValue(File(''));
registerFallbackValue(ReleasePlatform.ios);
registerFallbackValue(Uri.parse('https://example.com'));
@@ -300,8 +301,8 @@ flutter:
),
).thenAnswer((_) async {});
when(
() => patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
localArtifact: any(named: 'localArtifact'),
() => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary(
localArtifactDirectory: any(named: 'localArtifactDirectory'),
releaseArtifactUrl: any(named: 'releaseArtifactUrl'),
archiveDiffer: archiveDiffer,
force: any(named: 'force'),
@@ -633,12 +634,12 @@ Please re-run the release command for this version or create a new release.'''),
});
test(
'''exits with code 0 if confirmUnpatchableDiffsIfNecessary throws UserCancelledException''',
'''exits with code 0 if zipAndConfirmUnpatchableDiffsIfNecessary throws UserCancelledException''',
() async {
when(() => argResults['force']).thenReturn(false);
when(
() => patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
localArtifact: any(named: 'localArtifact'),
() => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary(
localArtifactDirectory: any(named: 'localArtifactDirectory'),
releaseArtifactUrl: any(named: 'releaseArtifactUrl'),
archiveDiffer: archiveDiffer,
force: any(named: 'force'),
@@ -654,8 +655,8 @@ Please re-run the release command for this version or create a new release.'''),
expect(exitCode, equals(ExitCode.success.code));
verify(
() => patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
localArtifact: any(named: 'localArtifact'),
() => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary(
localArtifactDirectory: any(named: 'localArtifactDirectory'),
releaseArtifactUrl: Uri.parse(xcframeworkArtifact.url),
archiveDiffer: archiveDiffer,
force: false,
@@ -673,12 +674,12 @@ Please re-run the release command for this version or create a new release.'''),
});
test(
'''exits with code 70 if confirmUnpatchableDiffsIfNecessary throws UnpatchableChangeException''',
'''exits with code 70 if zipAndConfirmUnpatchableDiffsIfNecessary throws UnpatchableChangeException''',
() async {
when(() => argResults['force']).thenReturn(false);
when(
() => patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
localArtifact: any(named: 'localArtifact'),
() => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary(
localArtifactDirectory: any(named: 'localArtifactDirectory'),
releaseArtifactUrl: any(named: 'releaseArtifactUrl'),
archiveDiffer: archiveDiffer,
force: any(named: 'force'),
@@ -694,8 +695,8 @@ Please re-run the release command for this version or create a new release.'''),
expect(exitCode, equals(ExitCode.software.code));
verify(
() => patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
localArtifact: any(named: 'localArtifact'),
() => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary(
localArtifactDirectory: any(named: 'localArtifactDirectory'),
releaseArtifactUrl: Uri.parse(xcframeworkArtifact.url),
archiveDiffer: archiveDiffer,
force: false,
@@ -357,12 +357,20 @@ void main() {
test('succeeds when release is successful', () async {
final exitCode = await runWithOverrides(command.run);
verify(() => logger.success('\n✅ Published Release!')).called(1);
final aabPath = p.join(
'build',
'app',
'outputs',
'bundle',
'release',
'app-release.aab',
);
// Verify info message does not include apk instructions.
verify(
() => logger.info('''
Your next step is to upload the app bundle to the Play Store:
${lightCyan.wrap('build/app/outputs/bundle/release/app-release.aab')}
${lightCyan.wrap(aabPath)}
For information on uploading to the Play Store, see:
${link(uri: Uri.parse('https://support.google.com/googleplay/android-developer/answer/9859152?hl=en'))}
@@ -393,14 +401,30 @@ ${link(uri: Uri.parse('https://support.google.com/googleplay/android-developer/a
final exitCode = await runWithOverrides(command.run);
verify(() => logger.success('\n✅ Published Release!')).called(1);
// Verify info message does include apk instructions.
final aabPath = p.join(
'build',
'app',
'outputs',
'bundle',
'release',
'app-release.aab',
);
final apkPath = p.join(
'build',
'app',
'outputs',
'apk',
'release',
'app-release.apk',
);
verify(
() => logger.info('''
Your next step is to upload the app bundle to the Play Store:
${lightCyan.wrap('build/app/outputs/bundle/release/app-release.aab')}
${lightCyan.wrap(aabPath)}
Or distribute the apk:
${lightCyan.wrap('build/app/outputs/apk/release/app-release.apk')}
${lightCyan.wrap(apkPath)}
For information on uploading to the Play Store, see:
${link(uri: Uri.parse('https://support.google.com/googleplay/android-developer/answer/9859152?hl=en'))}
@@ -7,7 +7,6 @@ import 'package:path/path.dart' as p;
import 'package:platform/platform.dart';
import 'package:propertylistserialization/propertylistserialization.dart';
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart';
import 'package:shorebird_cli/src/auth/auth.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/commands/commands.dart';
@@ -31,10 +30,6 @@ class _MockCodePushClientWrapper extends Mock
class _MockDoctor extends Mock implements Doctor {}
class _MockIpa extends Mock implements Ipa {}
class _MockIpaReader extends Mock implements IpaReader {}
class _MockLogger extends Mock implements Logger {}
class _MockPlatform extends Mock implements Platform {}
@@ -82,10 +77,40 @@ void main() {
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>app_bundle_name</string>
<key>ApplicationProperties</key>
<dict>
<key>ApplicationPath</key>
<string>Applications/Runner.app</string>
<key>Architectures</key>
<array>
<string>arm64</string>
</array>
<key>CFBundleIdentifier</key>
<string>com.shorebird.timeShift</string>
<key>CFBundleShortVersionString</key>
<string>1.2.3</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
<key>ArchiveVersion</key>
<integer>2</integer>
<key>Name</key>
<string>Runner</string>
<key>SchemeName</key>
<string>Runner</string>
</dict>
</plist>''';
const emptyPlistContent = '''
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ApplicationProperties</key>
<dict>
</dict>
</dict>
</plist>'
''';
const pubspecYamlContent = '''
name: example
version: $version
@@ -102,8 +127,6 @@ flutter:
late Doctor doctor;
late Platform platform;
late Auth auth;
late IpaReader ipaReader;
late Ipa ipa;
late Progress progress;
late Logger logger;
late ShorebirdProcessResult flutterBuildProcessResult;
@@ -139,7 +162,14 @@ flutter:
p.join(tempDir.path, 'shorebird.yaml'),
).writeAsStringSync('app_id: $appId');
File(
p.join(tempDir.path, 'ios', 'Runner', 'Info.plist'),
p.join(
tempDir.path,
'build',
'ios',
'archive',
'Runner.xcarchive',
'Info.plist',
),
)
..createSync(recursive: true)
..writeAsStringSync(infoPlistContent);
@@ -161,8 +191,6 @@ flutter:
platform = _MockPlatform();
shorebirdRoot = Directory.systemTemp.createTempSync();
auth = _MockAuth();
ipa = _MockIpa();
ipaReader = _MockIpaReader();
progress = _MockProgress();
logger = _MockLogger();
flutterBuildProcessResult = _MockProcessResult();
@@ -193,10 +221,9 @@ flutter:
).thenAnswer((_) async => flutterBuildProcessResult);
when(() => argResults.rest).thenReturn([]);
when(() => argResults['arch']).thenReturn(arch);
when(() => argResults['codesign']).thenReturn(true);
when(() => argResults['platform']).thenReturn(releasePlatform);
when(() => auth.isAuthenticated).thenReturn(true);
when(() => ipaReader.read(any())).thenReturn(ipa);
when(() => ipa.versionNumber).thenReturn(version);
when(() => logger.progress(any())).thenReturn(progress);
when(() => logger.confirm(any())).thenReturn(true);
when(
@@ -235,7 +262,7 @@ flutter:
() => codePushClientWrapper.createIosReleaseArtifacts(
appId: any(named: 'appId'),
releaseId: any(named: 'releaseId'),
ipaPath: any(named: 'ipaPath'),
xcarchivePath: any(named: 'xcarchivePath'),
runnerPath: any(named: 'runnerPath'),
),
).thenAnswer((_) async => release);
@@ -258,7 +285,7 @@ flutter:
),
).thenAnswer((_) async {});
command = runWithOverrides(() => ReleaseIosCommand(ipaReader: ipaReader))
command = runWithOverrides(ReleaseIosCommand.new)
..testArgResults = argResults;
});
@@ -290,6 +317,84 @@ flutter:
).called(1);
});
group('when codesign is disabled', () {
setUp(() {
when(() => argResults['codesign']).thenReturn(false);
});
test('prints instructions to manually codesign', () async {
final tempDir = setUpTempDir();
await IOOverrides.runZoned(
() => runWithOverrides(command.run),
getCurrentDirectory: () => tempDir,
);
verify(
() => logger.info(
'''Building for device with codesigning disabled. You will have to manually codesign before deploying to device.''',
),
).called(1);
});
test('builds without codesigning', () async {
final tempDir = setUpTempDir();
await IOOverrides.runZoned(
() => runWithOverrides(command.run),
getCurrentDirectory: () => tempDir,
);
verify(
() => shorebirdProcess.run(
'flutter',
any(
that: containsAllInOrder(
[
'build',
'ipa',
'--release',
'--no-codesign',
],
),
),
runInShell: true,
),
).called(1);
});
test('prints archive upload instructions on success', () async {
final tempDir = setUpTempDir();
final result = await IOOverrides.runZoned(
() => runWithOverrides(command.run),
getCurrentDirectory: () => tempDir,
);
expect(result, equals(ExitCode.success.code));
final archivePath = p.join(
tempDir.path,
'build',
'ios',
'archive',
'Runner.xcarchive',
);
verify(
() => logger.info(
any(
that: stringContainsInOrder(
[
'Your next step is to submit the archive',
archivePath,
'to the App Store using Xcode.',
'You can open the archive in Xcode by running',
'open $archivePath',
'''Make sure to uncheck "Manage Version and Build Number", or else shorebird will not work.''',
],
),
),
),
).called(1);
});
});
test('exits with code 70 when build fails with non-zero exit code',
() async {
when(() => flutterBuildProcessResult.exitCode).thenReturn(1);
@@ -346,9 +451,19 @@ error: exportArchive: No signing certificate "iOS Distribution" found
test('exits with code 70 when release version cannot be determiend',
() async {
when(() => ipa.versionNumber).thenThrow(Exception('oops'));
final tempDir = setUpTempDir();
File(
p.join(
tempDir.path,
'build',
'ios',
'archive',
'Runner.xcarchive',
'Info.plist',
),
)
..createSync(recursive: true)
..writeAsStringSync(emptyPlistContent);
final exitCode = await IOOverrides.runZoned(
() => runWithOverrides(command.run),
getCurrentDirectory: () => tempDir,
@@ -356,7 +471,7 @@ error: exportArchive: No signing certificate "iOS Distribution" found
expect(exitCode, equals(ExitCode.software.code));
verify(
() => progress.fail(
() => logger.err(
any(that: contains('Failed to determine release version')),
),
).called(1);
@@ -377,12 +492,37 @@ error: exportArchive: No signing certificate "iOS Distribution" found
() => codePushClientWrapper.createIosReleaseArtifacts(
appId: appId,
releaseId: release.id,
ipaPath: any(named: 'ipaPath', that: endsWith('.ipa')),
xcarchivePath:
any(named: 'xcarchivePath', that: endsWith('.xcarchive')),
runnerPath: any(named: 'runnerPath', that: endsWith('Runner.app')),
),
);
});
test('exits with code 70 if Info.plist does not exist', () async {
final tempDir = setUpTempDir();
final infoPlistFile = File(
p.join(
tempDir.path,
'build',
'ios',
'archive',
'Runner.xcarchive',
'Info.plist',
),
)..deleteSync(recursive: true);
final exitCode = await IOOverrides.runZoned(
() => runWithOverrides(command.run),
getCurrentDirectory: () => tempDir,
);
expect(exitCode, equals(ExitCode.software.code));
verify(
() => logger.err('No Info.plist file found at ${infoPlistFile.path}.'),
).called(1);
});
test('exits with code 70 if build directory does not exist', () async {
final tempDir = setUpTempDir();
Directory(p.join(tempDir.path, 'build')).deleteSync(recursive: true);
@@ -394,12 +534,31 @@ error: exportArchive: No signing certificate "iOS Distribution" found
expect(exitCode, equals(ExitCode.software.code));
verify(
() => progress.fail(
() => logger.err(any(that: contains('No directory found'))),
).called(1);
});
test('exits with code 70 if ipa build directory does not exist', () async {
final tempDir = setUpTempDir();
final ipaDirectory =
Directory(p.join(tempDir.path, 'build', 'ios', 'ipa'))
..deleteSync(recursive: true);
final exitCode = await IOOverrides.runZoned(
() => runWithOverrides(command.run),
getCurrentDirectory: () => tempDir,
);
expect(exitCode, equals(ExitCode.software.code));
verify(
() => logger.err(
any(
that: stringContainsInOrder([
'Could not find ipa file',
'No directory found at ${p.join(tempDir.path, 'build')}',
]),
that: stringContainsInOrder(
[
'Could not find ipa file',
'No directory found at ${ipaDirectory.path}',
],
),
),
),
).called(1);
@@ -416,7 +575,7 @@ error: exportArchive: No signing certificate "iOS Distribution" found
expect(exitCode, equals(ExitCode.software.code));
verify(
() => progress.fail(
() => logger.err(
any(
that: stringContainsInOrder([
'Could not find ipa file',
@@ -440,7 +599,7 @@ error: exportArchive: No signing certificate "iOS Distribution" found
expect(exitCode, equals(ExitCode.software.code));
verify(
() => progress.fail(
() => logger.err(
any(
that: stringContainsInOrder([
'Could not find ipa file',
@@ -504,7 +663,8 @@ error: exportArchive: No signing certificate "iOS Distribution" found
() => codePushClientWrapper.createIosReleaseArtifacts(
appId: appId,
releaseId: release.id,
ipaPath: any(named: 'ipaPath', that: endsWith('.ipa')),
xcarchivePath:
any(named: 'xcarchivePath', that: endsWith('.xcarchive')),
runnerPath: any(named: 'runnerPath', that: endsWith('Runner.app')),
),
).called(1);
@@ -575,7 +735,8 @@ flavors:
() => codePushClientWrapper.createIosReleaseArtifacts(
appId: appId,
releaseId: release.id,
ipaPath: any(named: 'ipaPath', that: endsWith('.ipa')),
xcarchivePath:
any(named: 'xcarchivePath', that: endsWith('.xcarchive')),
runnerPath: any(named: 'runnerPath', that: endsWith('Runner.app')),
),
).called(1);
@@ -610,7 +771,8 @@ flavors:
() => codePushClientWrapper.createIosReleaseArtifacts(
appId: appId,
releaseId: release.id,
ipaPath: any(named: 'ipaPath', that: endsWith('.ipa')),
xcarchivePath:
any(named: 'xcarchivePath', that: endsWith('.xcarchive')),
runnerPath: any(named: 'runnerPath', that: endsWith('Runner.app')),
),
).called(1);
@@ -123,6 +123,7 @@ flutter:
}
setUpAll(() {
registerFallbackValue(Directory(''));
registerFallbackValue(ReleasePlatform.ios);
registerFallbackValue(ReleaseStatus.draft);
registerFallbackValue(_FakeRelease());
@@ -309,7 +310,8 @@ flutter:
() => codePushClientWrapper.createIosReleaseArtifacts(
appId: appId,
releaseId: release.id,
ipaPath: any(named: 'ipaPath', that: endsWith('.ipa')),
xcarchivePath:
any(named: 'xcarchivePath', that: endsWith('.xcarchive')),
runnerPath: any(named: 'runnerPath', that: endsWith('Runner.app')),
),
);
@@ -3,6 +3,7 @@ import 'dart:io';
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:scoped/scoped.dart';
import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart';
import 'package:shorebird_cli/src/archive_analysis/archive_differ.dart';
@@ -92,6 +93,27 @@ void main() {
.thenReturn(nativeDiffPrettyString);
});
group('zipAndConfirmUnpatchableDiffsIfNecessary', () {
test('zips directory and forwards to confirmUnpatchableDiffsIfNecessary',
() async {
final tempDir = Directory.systemTemp.createTempSync();
final localArtifactDirectory = Directory(
p.join(tempDir.path, 'artifact'),
)..createSync();
await runWithOverrides(
() => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary(
localArtifactDirectory: localArtifactDirectory,
releaseArtifactUrl: releaseArtifactUrl,
archiveDiffer: archiveDiffer,
force: false,
),
);
verify(() => archiveDiffer.changedFiles(any(), any())).called(1);
});
});
group('confirmUnpatchableDiffsIfNecessary', () {
test('throws Exception when release artifact fails to download',
() async {