From c3b6f4acee388eece13552cc0f72f6eb7cff128d Mon Sep 17 00:00:00 2001 From: Erick Date: Thu, 23 May 2024 18:17:08 -0300 Subject: [PATCH] feat: ios release with public key bundling (#2143) Co-authored-by: Bryan Oltman --- .../lib/src/artifact_builder.dart | 21 +++-- .../commands/release/android_releaser.dart | 10 +-- .../src/commands/release/ios_releaser.dart | 4 + .../lib/src/extensions/arg_results.dart | 21 +++++ .../test/src/artifact_builder_test.dart | 46 ++++++++++ .../release/android_releaser_test.dart | 8 +- .../commands/release/ios_releaser_test.dart | 87 +++++++++++++++++++ 7 files changed, 179 insertions(+), 18 deletions(-) diff --git a/packages/shorebird_cli/lib/src/artifact_builder.dart b/packages/shorebird_cli/lib/src/artifact_builder.dart index 377cd0ce..8d5af53e 100644 --- a/packages/shorebird_cli/lib/src/artifact_builder.dart +++ b/packages/shorebird_cli/lib/src/artifact_builder.dart @@ -34,6 +34,17 @@ final artifactBuilderRef = create(ArtifactBuilder.new); /// The [ArtifactBuilder] instance available in the current zone. ArtifactBuilder get artifactBuilder => read(artifactBuilderRef); +extension on String { + /// Converts this base64-encoded public key into the Map: + /// {'SHOREBIRD_PUBLIC_KEY': this} + /// + /// SHOREBIRD_PUBLIC_KEY is the name expected by the Shorebird's Flutter tool + /// + /// This allow us to just call var?.toPublicKeyEnv() instead of doing + /// a ternary operation to check if the value is null. + Map toPublicKeyEnv() => {'SHOREBIRD_PUBLIC_KEY': this}; +} + /// @{template artifact_builder} /// Builds aabs, ipas, and other artifacts produced by `flutter build`. /// @{endtemplate} @@ -65,9 +76,7 @@ class ArtifactBuilder { executable, arguments, runInShell: true, - environment: base64PublicKey != null - ? {'SHOREBIRD_PUBLIC_KEY': base64PublicKey} - : null, + environment: base64PublicKey?.toPublicKeyEnv(), ); if (result.exitCode != ExitCode.success.code) { @@ -126,9 +135,7 @@ class ArtifactBuilder { executable, arguments, runInShell: true, - environment: base64PublicKey != null - ? {'SHOREBIRD_PUBLIC_KEY': base64PublicKey} - : null, + environment: base64PublicKey?.toPublicKeyEnv(), ); if (result.exitCode != ExitCode.success.code) { @@ -194,6 +201,7 @@ class ArtifactBuilder { String? flavor, String? target, List args = const [], + String? base64PublicKey, }) async { return _runShorebirdBuildCommand(() async { const executable = 'flutter'; @@ -214,6 +222,7 @@ class ArtifactBuilder { executable, arguments, runInShell: true, + environment: base64PublicKey?.toPublicKeyEnv(), ); if (result.exitCode != ExitCode.success.code) { diff --git a/packages/shorebird_cli/lib/src/commands/release/android_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/android_releaser.dart index 23fbcdf2..aac982cf 100644 --- a/packages/shorebird_cli/lib/src/commands/release/android_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/android_releaser.dart @@ -1,5 +1,3 @@ -import 'dart:convert'; - import 'package:mason_logger/mason_logger.dart'; import 'package:shorebird_cli/src/artifact_builder.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; @@ -7,7 +5,6 @@ import 'package:shorebird_cli/src/commands/release/release.dart'; import 'package:shorebird_cli/src/commands/release/releaser.dart'; import 'package:shorebird_cli/src/doctor.dart'; import 'package:shorebird_cli/src/extensions/arg_results.dart'; -import 'package:shorebird_cli/src/extensions/file.dart'; import 'package:shorebird_cli/src/logger.dart'; import 'package:shorebird_cli/src/platform.dart'; import 'package:shorebird_cli/src/platform/platform.dart'; @@ -63,7 +60,7 @@ class AndroidReleaser extends Releaser { @override Future assertArgsAreValid() async { - argResults.file('public-key-path')?.assertExists(); + argResults.assertAbsentOrValidPublicKey(); if (generateApk && splitApk) { logger ..err( @@ -96,10 +93,7 @@ Please comment and upvote ${link(uri: Uri.parse('https://github.com/shorebirdtec final File aab; - final publicKeyFile = argResults.file('public-key-path'); - final base64PublicKey = publicKeyFile != null - ? base64Encode(publicKeyFile.readAsBytesSync()) - : null; + final base64PublicKey = argResults.encodedPublicKey; try { aab = await artifactBuilder.buildAppBundle( diff --git a/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart index 2d4c77ef..4f9fc5b0 100644 --- a/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart @@ -9,6 +9,7 @@ import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/commands/release/releaser.dart'; import 'package:shorebird_cli/src/doctor.dart'; import 'package:shorebird_cli/src/executables/xcodebuild.dart'; +import 'package:shorebird_cli/src/extensions/arg_results.dart'; import 'package:shorebird_cli/src/logger.dart'; import 'package:shorebird_cli/src/platform.dart'; import 'package:shorebird_cli/src/platform/ios.dart'; @@ -38,6 +39,7 @@ class IosReleaser extends Releaser { @override Future assertArgsAreValid() async { + argResults.assertAbsentOrValidPublicKey(); if (argResults.rest.contains('--obfuscate')) { // Obfuscated releases break patching, so we don't support them. // See https://github.com/shorebirdtech/shorebird/issues/1619 @@ -98,6 +100,7 @@ class IosReleaser extends Releaser { final flutterVersionString = await shorebirdFlutter.getVersionAndRevision(); final buildProgress = logger.progress('Building ipa with Flutter $flutterVersionString'); + try { await artifactBuilder.buildIpa( codesign: codesign, @@ -105,6 +108,7 @@ class IosReleaser extends Releaser { flavor: flavor, target: target, args: argResults.forwardedArgs, + base64PublicKey: argResults.encodedPublicKey, ); buildProgress.complete(); } on ArtifactBuildException catch (error) { diff --git a/packages/shorebird_cli/lib/src/extensions/arg_results.dart b/packages/shorebird_cli/lib/src/extensions/arg_results.dart index 28e0d636..2a689f31 100644 --- a/packages/shorebird_cli/lib/src/extensions/arg_results.dart +++ b/packages/shorebird_cli/lib/src/extensions/arg_results.dart @@ -1,7 +1,9 @@ +import 'dart:convert'; import 'dart:io'; import 'package:args/args.dart'; import 'package:collection/collection.dart'; +import 'package:shorebird_cli/src/extensions/file.dart'; extension OptionFinder on ArgResults { /// // Detects flags even when passed to underlying commands via a `--` @@ -42,6 +44,25 @@ extension OptionFinder on ArgResults { } } +const _publicKeyArgName = 'public-key-path'; + +extension CodeSign on ArgResults { + /// Asserts that either there is no public key argument + /// or that the path received exists. + void assertAbsentOrValidPublicKey() { + file(_publicKeyArgName)?.assertExists(); + } + + /// Read the public key file and encode it to base64 if any. + String? get encodedPublicKey { + final publicKeyFile = file(_publicKeyArgName); + + return publicKeyFile != null + ? base64Encode(publicKeyFile.readAsBytesSync()) + : null; + } +} + /// Extension on [ArgResults] to provide file related extensions. extension FileArgs on ArgResults { /// Returns a [File] from the argument [name] or null if the argument was not diff --git a/packages/shorebird_cli/test/src/artifact_builder_test.dart b/packages/shorebird_cli/test/src/artifact_builder_test.dart index fd56815a..3f5de996 100644 --- a/packages/shorebird_cli/test/src/artifact_builder_test.dart +++ b/packages/shorebird_cli/test/src/artifact_builder_test.dart @@ -654,6 +654,52 @@ Either run `flutter pub get` manually, or follow the steps in ${link(uri: Uri.pa }); }); + group('when base64PublicKey is not null', () { + const base64PublicKey = 'base64PublicKey'; + + setUp(() { + when( + () => shorebirdProcess.run( + 'flutter', + [ + 'build', + 'ipa', + '--release', + '--export-options-plist=${exportOptionsPlist.path}', + ], + runInShell: any(named: 'runInShell'), + environment: { + 'SHOREBIRD_PUBLIC_KEY': base64PublicKey, + }, + ), + ).thenAnswer((_) async => buildProcessResult); + }); + + test('adds the SHOREBIRD_PUBLIC_KEY to the environment', () async { + await runWithOverrides( + () => builder.buildIpa( + base64PublicKey: base64PublicKey, + ), + ); + + verify( + () => shorebirdProcess.run( + 'flutter', + [ + 'build', + 'ipa', + '--release', + '--export-options-plist=${exportOptionsPlist.path}', + ], + runInShell: any(named: 'runInShell'), + environment: { + 'SHOREBIRD_PUBLIC_KEY': base64PublicKey, + }, + ), + ).called(1); + }); + }); + group('when export options plist is provided', () { test('forwards to flutter build', () async { await runWithOverrides( diff --git a/packages/shorebird_cli/test/src/commands/release/android_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/android_releaser_test.dart index 0140f919..b39149f7 100644 --- a/packages/shorebird_cli/test/src/commands/release/android_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/android_releaser_test.dart @@ -225,7 +225,7 @@ void main() { final publicKeyFile = File( p.join( Directory.systemTemp.createTempSync().path, - 'public-key.pem', + 'public-key.der', ), )..writeAsStringSync('public key'); when(() => argResults['public-key-path']) @@ -244,7 +244,7 @@ void main() { setUp(() { when(() => argResults['artifact']).thenReturn('apk'); when(() => argResults['public-key-path']) - .thenReturn('non-existing-key.pem'); + .thenReturn('non-existing-key.der'); }); test('logs and exits with usage err', () async { @@ -253,7 +253,7 @@ void main() { exitsWithCode(ExitCode.usage), ); - verify(() => logger.err('No file found at non-existing-key.pem')) + verify(() => logger.err('No file found at non-existing-key.der')) .called(1); }); }); @@ -432,7 +432,7 @@ void main() { patchSigningPublicKeyFile = File( p.join( Directory.systemTemp.createTempSync().path, - 'patch-signing-public-key.pem', + 'patch-signing-public-key.der', ), )..writeAsStringSync('public key'); when(() => argResults['public-key-path']) diff --git a/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart index e40078ee..05037fa9 100644 --- a/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:args/args.dart'; import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; @@ -243,6 +245,43 @@ void main() { ); }); }); + + group('when a public key is provided and it exists', () { + setUp(() { + final publicKeyFile = File( + p.join( + Directory.systemTemp.createTempSync().path, + 'public-key.der', + ), + )..writeAsStringSync('public key'); + when(() => argResults['public-key-path']) + .thenReturn(publicKeyFile.path); + }); + + test('returns normally', () async { + expect( + () => runWithOverrides(iosReleaser.assertArgsAreValid), + returnsNormally, + ); + }); + }); + + group('when the provided public key is a nonexistent file', () { + setUp(() { + when(() => argResults['public-key-path']) + .thenReturn('non-existing-key.der'); + }); + + test('logs and exits with usage err', () async { + await expectLater( + () => runWithOverrides(iosReleaser.assertArgsAreValid), + exitsWithCode(ExitCode.usage), + ); + + verify(() => logger.err('No file found at non-existing-key.der')) + .called(1); + }); + }); }); group('buildReleaseArtifacts', () { @@ -282,6 +321,54 @@ void main() { ).thenAnswer((_) async => flutterVersionAndRevision); }); + group('when a patch signing key path is provided', () { + late File patchSigningPublicKeyFile; + + setUp(() { + patchSigningPublicKeyFile = File( + p.join( + Directory.systemTemp.createTempSync().path, + 'patch-signing-public-key.der', + ), + )..writeAsStringSync('public key'); + when(() => argResults['public-key-path']) + .thenReturn(patchSigningPublicKeyFile.path); + + when( + () => artifactBuilder.buildIpa( + codesign: any(named: 'codesign'), + exportOptionsPlist: any(named: 'exportOptionsPlist'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + args: any(named: 'args'), + base64PublicKey: any(named: 'base64PublicKey'), + ), + ).thenAnswer((_) async => File('')); + }); + + test( + 'encodes the patch signing public key and forward it to buildIpa', + () async { + await runWithOverrides( + () => iosReleaser.buildReleaseArtifacts(), + ); + + verify( + () => artifactBuilder.buildIpa( + codesign: any(named: 'codesign'), + exportOptionsPlist: any(named: 'exportOptionsPlist'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + args: any(named: 'args'), + base64PublicKey: base64Encode( + patchSigningPublicKeyFile.readAsBytesSync(), + ), + ), + ).called(1); + }, + ); + }); + group('when not codesigning', () { setUp(() { when(() => argResults['codesign']).thenReturn(false);