Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implementing sz deploy ios for Automating iOS App Deployment #695

Merged
merged 18 commits into from
Jul 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
283 changes: 283 additions & 0 deletions tools/sz_repo_cli/lib/src/commands/src/deploy_ios_command.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
// Copyright (c) 2022 Sharezone UG (haftungsbeschränkt)
// Licensed under the EUPL-1.2-or-later.
//
// You may obtain a copy of the Licence at:
// https://joinup.ec.europa.eu/software/page/eupl
//
// SPDX-License-Identifier: EUPL-1.2

import 'dart:io';

import 'package:args/command_runner.dart';
import 'package:sz_repo_cli/src/common/common.dart';
import 'package:sz_repo_cli/src/common/src/apple_track.dart';

/// A map that maps the stage to the corresponding [AppleTrack].
final _iosStageToTracks = {
'stable': const AppStoreTrack(),
'alpha': const TestFlightTrack('alpha'),
};

/// The different flavors of the iOS app that support deployment.
final _iosFlavors = [
'prod',
];

/// [DeployIosCommand] provides functionality for deploying the Sharezone iOS
/// app to the App Store or TestFlight.
///
/// This command automatically increments the build number and builds the app.
/// The Codemagic CLI tools are required for this process. Note that only the
/// "prod" flavor of the app is currently supported for iOS deployment.
///
/// You can customize deployment using command line arguments. Some of these
/// include:
/// - `private-key`: The App Store Connect API private key used for JWT
/// authentication.
/// - `key-id`: The App Store Connect API Key ID used to authenticate.
/// - `issuer-id`: The App Store Connect API Key Issuer ID used to
/// authenticate.
/// - `stage`: The stage to deploy to. Supports "stable" for App Store releases
/// and "alpha" for TestFlight releases.
/// - `flavor`: The flavor to build for. Currently only "prod" flavor is
/// supported.
/// - `whats-new`: Release notes either for TestFlight or App Store review
/// submission.
///
/// These options can either be provided via the command line or set as
/// environment variables (only applies for some of them). If any required
/// argument is missing, the deployment will fail.
class DeployIosCommand extends Command {
final SharezoneRepo _repo;

DeployIosCommand(this._repo) {
argParser
..addOption(
releaseStageOptionName,
abbr: 's',
allowed: _iosStages,
help:
'The deployment stage to deploy to. The "stable" stage is used for App Store releases, the "alpha" stage is used for TestFlight releases. The value will be forwarded to the "sz build" command.',
defaultsTo: 'stable',
)
..addOption(
keyIdOptionName,
help:
'The App Store Connect API Key ID used to authenticate. This can be found in the App Store Connect Developer Portal. Learn more at https://developer.apple.com/documentation/appstoreconnectapi/creating_api_keys_for_app_store_connect_api. If the parameter is not provided, the value of the APP_STORE_CONNECT_KEY_IDENTIFIER environment variable will be used. If no value is set, the deployment will fail. Example value: 1234567890',
)
..addOption(
issuerIdOptionName,
help:
'The App Store Connect API Key Issuer ID used to authenticate. This can be found in the App Store Connect Developer Portal. Learn more at https://developer.apple.com/documentation/appstoreconnectapi/creating_api_keys_for_app_store_connect_api. If the parameter is not provided, the value of the APP_STORE_CONNECT_ISSUER_ID environment variable will be used. If no value is set, the deployment will fail. Example value: 00000000-0000-0000-0000-000000000000',
)
..addOption(
privateKeyOptionName,
help:
'The App Store Connect API private key used for JWT authentication to communicate with Apple services. This can be found in the App Store Connect Developer Portal. Learn more at https://developer.apple.com/documentation/appstoreconnectapi/creating_api_keys_for_app_store_connect_api. If not provided, the value will be checked from the environment variable APP_STORE_CONNECT_PRIVATE_KEY. If not given, the key will be searched from the following directories in sequence for a private key file with the name AuthKey_<key_identifier>.p8: private_keys, ~/private_keys, ~/.private_keys, ~/.appstoreconnect/private_keys, where <key_identifier> is the value of --key-id. If no value is set, the deployment will fail.',
)
..addOption(
whatsNewOptionName,
help:
"Release notes either for TestFlight or App Store review submission. Describe what's new in this version of your app, such as new features, improvements, and bug fixes. The string should not exceed 4000 characters. Example usage: --whats-new 'Bug fixes and performance improvements.'",
)
..addOption(
flavorOptionName,
allowed: _iosFlavors,
help: 'The flavor to build for. Only the "prod" flavor is supported.',
defaultsTo: 'prod',
);
}

static const privateKeyOptionName = 'private-key';
static const keyIdOptionName = 'key-id';
static const issuerIdOptionName = 'issuer-id';
static const releaseStageOptionName = 'stage';
static const flavorOptionName = 'flavor';
static const whatsNewOptionName = 'whats-new';

List<String> get _iosStages => _iosStageToTracks.keys.toList();

@override
String get description =>
'Deploys the Sharezone iOS app to the App Store or TestFlight. Automatically bumps the build number and builds the app. Codemagic CLI tools are required.';

@override
String get name => 'ios';

@override
Future<void> run() async {
_throwIfFlavorIsNotSupportForDeployment();
await _throwIfCodemagiCliToolsAreNotInstalled();

// Is used so that runProcess commands print the command that was run. Right
// now this can't be done via an argument.
//
// This workaround should be addressed in the future.
isVerbose = true;

final buildNumber = await _getNextBuildNumber();
await _buildApp(buildNumber: buildNumber);
await _publish();

nilsreichardt marked this conversation as resolved.
Show resolved Hide resolved
print('Deployment finished 🎉 ');
}

void _throwIfFlavorIsNotSupportForDeployment() {
final flavor = argResults![flavorOptionName] as String;
if (flavor != 'prod') {
throw Exception(
'Only the "prod" flavor is supported for iOS deployment.',
);
}
}

Future<void> _throwIfCodemagiCliToolsAreNotInstalled() async {
// Check if "which -s app-store-connect" returns 0.
// If not, throw an exception.
final result = await runProcess(
'which',
['-s', 'app-store-connect'],
);
if (result.exitCode != 0) {
throw Exception(
'Codemagic CLI tools are not installed. Docs to install them: https://github.com/codemagic-ci-cd/cli-tools#installing',
);
}
}

Future<int> _getNextBuildNumber() async {
final latestBuildNumber = await _getLatestBuildNumberFromAppStoreConnect();
final nextBuildNumber = latestBuildNumber + 1;
print('Next build number: $nextBuildNumber');
return nextBuildNumber;
}

/// Returns the latest build number from App Store and TestFligth all tracks.
Future<int> _getLatestBuildNumberFromAppStoreConnect() async {
try {
// From https://appstoreconnect.apple.com/apps/1434868489/
const appId = 1434868489;

final issuerId = argResults![issuerIdOptionName] as String? ??
Platform.environment['APP_STORE_CONNECT_ISSUER_ID'];
final keyIdentifier = argResults![keyIdOptionName] as String? ??
Platform.environment['APP_STORE_CONNECT_KEY_IDENTIFIER'];
final privateKey = argResults![privateKeyOptionName] as String? ??
Platform.environment['APP_STORE_CONNECT_PRIVATE_KEY'];

final result = await runProcessSucessfullyOrThrow(
'app-store-connect',
[
'get-latest-build-number',
'$appId',
'--platform',
'IOS',
if (issuerId != null) ...[
'--issuer-id',
issuerId,
],
if (keyIdentifier != null) ...[
'--key-id',
keyIdentifier,
],
if (privateKey != null) ...[
'--private-key',
privateKey,
],
],
// Using the app location as working direcorty because the default
// location for the App Store Connect private key is
// app/private_keys/AuthKey_{keyIdentifier}.p8.
workingDirectory: _repo.sharezoneFlutterApp.location.path,
);
return int.parse(result.stdout);
} catch (e) {
throw Exception(
'Failed to get latest build number from App Store Connect: $e');
}
}

Future<void> _buildApp({required int buildNumber}) async {
try {
final flavor = argResults![flavorOptionName] as String;
final stage = argResults![releaseStageOptionName] as String;
await runProcessSucessfullyOrThrow(
'fvm',
[
'dart',
'run',
'sz_repo_cli',
'build',
'ios',
'--flavor',
flavor,
'--stage',
stage,
'--build-number',
'$buildNumber',
],
workingDirectory: _repo.sharezoneCiCdTool.path,
);
} catch (e) {
throw Exception('Failed to build iOS app: $e');
}
}

Future<void> _publish() async {
final whatsNew = argResults![whatsNewOptionName] as String?;
final issuerId = argResults![issuerIdOptionName] as String? ??
Platform.environment['APP_STORE_CONNECT_ISSUER_ID'];
final keyIdentifier = argResults![keyIdOptionName] as String? ??
Platform.environment['APP_STORE_CONNECT_KEY_IDENTIFIER'];
final privateKey = argResults![privateKeyOptionName] as String? ??
Platform.environment['APP_STORE_CONNECT_PRIVATE_KEY'];

final track = _getAppleTrack();
await runProcessSucessfullyOrThrow(
'app-store-connect',
[
'publish',
'--path',
'build/ios/ipa/*.ipa',
'--release-type',
// The app version will be automatically released right after it has
// been approved by App Review.
'AFTER_APPROVAL',
if (whatsNew != null) ...[
'--whats-new',
whatsNew,
],
if (track is AppStoreTrack) ...[
'--app-store',
],
if (track is TestFlightTrack) ...[
'--beta-group',
track.groupName,
'--testflight',
],
if (issuerId != null) ...[
'--issuer-id',
issuerId,
],
if (keyIdentifier != null) ...[
'--key-id',
keyIdentifier,
],
if (privateKey != null) ...[
'--private-key',
privateKey,
],
],
workingDirectory: _repo.sharezoneFlutterApp.location.path,
);
}

AppleTrack _getAppleTrack() {
final stage = argResults![releaseStageOptionName] as String;
final track = _iosStageToTracks[stage];
if (track == null) {
throw Exception('Unknown track for stage: $stage');
}
return track;
}
}
29 changes: 29 additions & 0 deletions tools/sz_repo_cli/lib/src/common/src/apple_track.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright (c) 2022 Sharezone UG (haftungsbeschränkt)
// Licensed under the EUPL-1.2-or-later.
//
// You may obtain a copy of the Licence at:
// https://joinup.ec.europa.eu/software/page/eupl
//
// SPDX-License-Identifier: EUPL-1.2

/// A track to publish the app (iOS or macOS) for Apple.
abstract class AppleTrack {
const AppleTrack();
}

/// The track for the App Store.
///
/// https://appstoreconnect.apple.com/apps/1434868489/appstore
class AppStoreTrack extends AppleTrack {
const AppStoreTrack();
}

/// The track for TestFlight.
///
/// https://appstoreconnect.apple.com/apps/1434868489/testflight
class TestFlightTrack extends AppleTrack {
/// The name of the TestFlight group.
final String groupName;

const TestFlightTrack(this.groupName);
}
4 changes: 3 additions & 1 deletion tools/sz_repo_cli/lib/src/common/src/run_process.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import '../common.dart';

/// Helper method that automatically throws if [Process.exitCode] is non-zero
/// (unsucessfull).
Future<void> runProcessSucessfullyOrThrow(
Future<ProcessResult> runProcessSucessfullyOrThrow(
String executable,
List<String> arguments, {
String? workingDirectory,
Expand All @@ -34,6 +34,8 @@ Future<void> runProcessSucessfullyOrThrow(
throw Exception(
'Process ended with non-zero exit code: $displayableCommand (exit code ${result.exitCode}): ${result.stderr}\n\n stdout:${result.stdout}');
}

return ProcessResult(result.pid, exitCode, result.stdout, result.stderr);
}

/// Helper method with automatic (verbose) logging and workarounds for some
Expand Down
5 changes: 4 additions & 1 deletion tools/sz_repo_cli/lib/src/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import 'package:sz_repo_cli/src/commands/src/build_macos_command.dart';
import 'package:sz_repo_cli/src/commands/src/build_web_command.dart';
import 'package:sz_repo_cli/src/commands/src/build_ios_command.dart';
import 'package:sz_repo_cli/src/commands/src/check_license_headers_command.dart';
import 'package:sz_repo_cli/src/commands/src/deploy_ios_command.dart';
import 'package:sz_repo_cli/src/commands/src/format_command.dart';
import 'package:sz_repo_cli/src/commands/src/license_headers_command.dart';

Expand Down Expand Up @@ -47,7 +48,9 @@ Future<void> main(List<String> args) async {
..addCommand(LicenseHeadersCommand()
..addSubcommand(CheckLicenseHeadersCommand(repo))
..addSubcommand(AddLicenseHeadersCommand(repo)))
..addCommand(DeployCommand()..addSubcommand(DeployWebAppCommand(repo)))
..addCommand(DeployCommand()
..addSubcommand(DeployWebAppCommand(repo))
..addSubcommand(DeployIosCommand(repo)))
..addCommand(BuildCommand()
..addSubcommand(BuildAndroidCommand(repo))
..addSubcommand(BuildMacOsCommand(repo))
Expand Down
Loading