Skip to content

Commit

Permalink
feat(shorebird_cli): add custom keystore support to `shorebird previe…
Browse files Browse the repository at this point in the history
…w` (#2740)
felangel authored Jan 3, 2025

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
1 parent 4cc3f48 commit ce2b9eb
Showing 4 changed files with 301 additions and 1 deletion.
69 changes: 68 additions & 1 deletion packages/shorebird_cli/lib/src/commands/preview_command.dart
Original file line number Diff line number Diff line change
@@ -54,6 +54,36 @@ class PreviewCommand extends ShorebirdCommand {
},
help: 'The platform of the release.',
)
..addOption(
'ks',
help: '''
Specifies the path to the deployment keystore used to sign the APKs.
If you don't include this flag, bundletool attempts to sign your APKs with a debug signing key.
This is only applicable when previewing Android releases.''',
)
..addOption(
'ks-pass',
help: '''
Specifies your keystore password.
If you specify a password in plain text, qualify it with pass:.
If you pass the path to a file that contains the password, qualify it with file:.
If you specify a keystore using the --ks flag you must also specify a password.
This is only applicable when previewing Android releases.''',
)
..addOption(
'ks-key-pass',
help: '''
Specifies the password for the signing key. If you specify a password in plain text, qualify it with pass:.
If you pass the path to a file that contains the password, qualify it with file:.
If this password is identical to the one for the keystore itself, you can omit this flag.
This is only applicable when previewing Android releases.''',
)
..addOption(
'ks-key-alias',
help: '''
Specifies the alias of the signing key you want to use.
This is only applicable when previewing Android releases.''',
)
..addFlag(
'staging',
negatable: false,
@@ -361,6 +391,36 @@ class PreviewCommand extends ShorebirdCommand {
}) async {
const platform = ReleasePlatform.android;

final keystore = results['ks'] as String?;
final keystorePassword = results['ks-pass'] as String?;
final keyPassword = results['ks-key-pass'] as String?;
final keyAlias = results['ks-key-alias'] as String?;

// Ensure keystore options are valid.
if (keystore != null) {
if (keystorePassword == null) {
logger.err('You must provide a keystore password.');
return ExitCode.usage.code;
}
if (keyAlias == null) {
logger.err('You must provide a key alias.');
return ExitCode.usage.code;
}

if (!keystorePassword.startsWith('pass:') &&
!keystorePassword.startsWith('file:')) {
logger.err('Keystore password must start with "pass:" or "file:".');
return ExitCode.usage.code;
}

if (keyPassword != null &&
!keyPassword.startsWith('pass:') &&
!keyPassword.startsWith('file:')) {
logger.err('Key password must start with "pass:" or "file:".');
return ExitCode.usage.code;
}
}

final downloadArtifactProgress = logger.progress('Downloading release');
late File aabFile;
late ReleaseArtifact releaseAabArtifact;
@@ -435,7 +495,14 @@ class PreviewCommand extends ShorebirdCommand {

final buildApksProgress = logger.progress('Building apks');
try {
await bundletool.buildApks(bundle: aabFile.path, output: apksPath);
await bundletool.buildApks(
bundle: aabFile.path,
output: apksPath,
keystore: keystore,
keystorePassword: keystorePassword,
keyPassword: keyPassword,
keyAlias: keyAlias,
);
final apksLink = link(uri: Uri.parse(apksPath));
buildApksProgress.complete('Built apks: ${cyan.wrap(apksLink)}');
} on Exception catch (error) {
8 changes: 8 additions & 0 deletions packages/shorebird_cli/lib/src/executables/bundletool.dart
Original file line number Diff line number Diff line change
@@ -45,6 +45,10 @@ class Bundletool {
required String bundle,
required String output,
bool universal = true,
String? keystore,
String? keystorePassword,
String? keyPassword,
String? keyAlias,
}) async {
final result = await _exec(
[
@@ -53,6 +57,10 @@ class Bundletool {
'--bundle=$bundle',
'--output=$output',
if (universal) '--mode=universal',
if (keystore != null) '--ks=$keystore',
if (keystorePassword != null) '--ks-pass=$keystorePassword',
if (keyPassword != null) '--key-pass=$keyPassword',
if (keyAlias != null) '--ks-key-alias=$keyAlias',
],
);
if (result.exitCode != 0) {
163 changes: 163 additions & 0 deletions packages/shorebird_cli/test/src/commands/preview_command_test.dart
Original file line number Diff line number Diff line change
@@ -403,6 +403,10 @@ void main() {
() => bundletool.buildApks(
bundle: any(named: 'bundle'),
output: any(named: 'output'),
keystore: any(named: 'keystore'),
keystorePassword: any(named: 'keystorePassword'),
keyPassword: any(named: 'keyPassword'),
keyAlias: any(named: 'keyAlias'),
),
).thenAnswer((_) async {});
when(
@@ -483,6 +487,165 @@ void main() {
),
);
});

group('when valid keystore is specified', () {
const keystore = 'keystore.jks';
const keystorePassword = 'pass:keystorePassword';
const keyPassword = 'pass:keyPassword';
const keyAlias = 'keyAlias';

setUp(() {
when(() => argResults['ks']).thenReturn(keystore);
when(() => argResults['ks-pass']).thenReturn(keystorePassword);
when(() => argResults['ks-key-pass']).thenReturn(keyPassword);
when(() => argResults['ks-key-alias']).thenReturn(keyAlias);
});

test('builds apks with keystore', () async {
await runWithOverrides(command.run);

verify(
() => bundletool.buildApks(
bundle: aabPath(),
output: apksPath(),
keystore: keystore,
keystorePassword: keystorePassword,
keyPassword: keyPassword,
keyAlias: keyAlias,
),
).called(1);
});
});

group('when keystorePassword is missing', () {
const keystore = 'keystore.jks';

setUp(() {
when(() => argResults['ks']).thenReturn(keystore);
});

test('exits with usage error', () async {
final result = await runWithOverrides(command.run);
expect(result, equals(ExitCode.usage.code));

verify(
() => logger.err('You must provide a keystore password.'),
).called(1);

verifyNever(
() => bundletool.buildApks(
bundle: aabPath(),
output: apksPath(),
keystore: any(named: 'keystore'),
keystorePassword: any(named: 'keystorePassword'),
keyPassword: any(named: 'keyPassword'),
keyAlias: any(named: 'keyAlias'),
),
);
});
});

group('when keyAlias is missing', () {
const keystore = 'keystore.jks';
const keystorePassword = 'keystorePassword';

setUp(() {
when(() => argResults['ks']).thenReturn(keystore);
when(() => argResults['ks-pass']).thenReturn(keystorePassword);
});

test('exits with usage error', () async {
final result = await runWithOverrides(command.run);
expect(result, equals(ExitCode.usage.code));

verify(
() => logger.err('You must provide a key alias.'),
).called(1);

verifyNever(
() => bundletool.buildApks(
bundle: aabPath(),
output: apksPath(),
keystore: any(named: 'keystore'),
keystorePassword: any(named: 'keystorePassword'),
keyPassword: any(named: 'keyPassword'),
keyAlias: any(named: 'keyAlias'),
),
);
});
});

group('when keystorePassword is invalid', () {
const keystore = 'keystore.jks';
const keystorePassword = 'keystorePassword';
const keyPassword = 'pass:keyPassword';
const keyAlias = 'keyAlias';

setUp(() {
when(() => argResults['ks']).thenReturn(keystore);
when(() => argResults['ks-pass']).thenReturn(keystorePassword);
when(() => argResults['ks-key-pass']).thenReturn(keyPassword);
when(() => argResults['ks-key-alias']).thenReturn(keyAlias);
});

test('exits with usage error', () async {
final result = await runWithOverrides(command.run);
expect(result, equals(ExitCode.usage.code));

verify(
() => logger.err(
'Keystore password must start with "pass:" or "file:".',
),
).called(1);

verifyNever(
() => bundletool.buildApks(
bundle: aabPath(),
output: apksPath(),
keystore: any(named: 'keystore'),
keystorePassword: any(named: 'keystorePassword'),
keyPassword: any(named: 'keyPassword'),
keyAlias: any(named: 'keyAlias'),
),
);
});
});

group('when keyPassword is invalid', () {
const keystore = 'keystore.jks';
const keystorePassword = 'file:keystorePasswordFile';
const keyPassword = 'keyPassword';
const keyAlias = 'keyAlias';

setUp(() {
when(() => argResults['ks']).thenReturn(keystore);
when(() => argResults['ks-pass']).thenReturn(keystorePassword);
when(() => argResults['ks-key-pass']).thenReturn(keyPassword);
when(() => argResults['ks-key-alias']).thenReturn(keyAlias);
});

test('exits with usage error', () async {
final result = await runWithOverrides(command.run);
expect(result, equals(ExitCode.usage.code));

verify(
() => logger.err(
'Key password must start with "pass:" or "file:".',
),
).called(1);

verifyNever(
() => bundletool.buildApks(
bundle: aabPath(),
output: apksPath(),
keystore: any(named: 'keystore'),
keystorePassword: any(named: 'keystorePassword'),
keyPassword: any(named: 'keyPassword'),
keyAlias: any(named: 'keyAlias'),
),
);
});
});
});

group('setChannelOnAab', () {
62 changes: 62 additions & 0 deletions packages/shorebird_cli/test/src/executables/bundletool_test.dart
Original file line number Diff line number Diff line change
@@ -129,6 +129,68 @@ void main() {
).called(1);
});

group('when keystore configuration is passed', () {
const keystore = 'keystore.jks';
const keystorePassword = 'pass:keystorePassword';
const keyPassword = 'pass:keyPassword';
const keyAlias = 'keyAlias';

setUp(() {
when(
() => process.run(
any(),
any(),
environment: any(named: 'environment'),
),
).thenAnswer(
(_) async => const ShorebirdProcessResult(
exitCode: 0,
stdout: '',
stderr: '',
),
);
});

test('sets correct flags', () async {
await expectLater(
runWithOverrides(
() => bundletool.buildApks(
bundle: appBundlePath,
output: output,
keystore: keystore,
keystorePassword: keystorePassword,
keyPassword: keyPassword,
keyAlias: keyAlias,
),
),
completes,
);

verify(
() => process.run(
'java',
[
'-jar',
p.join(workingDirectory.path, 'bundletool.jar'),
'build-apks',
'--overwrite',
'--bundle=$appBundlePath',
'--output=$output',
'--mode=universal',
'--ks=$keystore',
'--ks-pass=$keystorePassword',
'--key-pass=$keyPassword',
'--ks-key-alias=$keyAlias',
],
environment: {
'ANDROID_HOME': androidSdkPath,
'JAVA_HOME': javaHome,
},
),
).called(1);
});
});

group('when universal is set to false', () {
setUp(() {
when(

0 comments on commit ce2b9eb

Please sign in to comment.