Implementation
@override
Future<void> publish() async {
_logger.info('Install dependencies...');
final isProduction = flutterPublish.stage == PublishStage.production;
await ensureInstalled('fastlane');
// Create tmp keychain to be able to run non interactively,
// see https://github.com/fastlane/fastlane/blob/df12128496a9a0ad349f8cf8efe6f9288612f2cb/fastlane/lib/fastlane/actions/setup_ci.rb#L37
final fastlaneKeychainName = 'fastlane_tmp_keychain';
final resultStr = await runFastlaneProcess(
[
'run',
'is_ci',
],
workingDirectory: _iosDirectory,
);
final isCi = bool.parse(resultStr ?? 'false');
if (isCi) {
await runProcess(
'fastlane',
[
'run',
'setup_ci',
],
workingDirectory: _iosDirectory,
);
} else {
_logger.warning(
'Not on CI: Create keychain "$fastlaneKeychainName" manually.');
await runProcess(
'fastlane',
[
'run',
'create_keychain',
'name:$fastlaneKeychainName',
'default_keychain:true',
'unlock:true',
'timeout:3600',
'lock_when_sleeps:true',
'password:',
],
workingDirectory: _iosDirectory,
);
}
// Determine app bundle id
await installFastlanePlugin('get_product_bundle_id',
workingDirectory: _iosDirectory);
String buildConfiguration = _isDevelopment ? 'Debug' : 'Release';
if (xcodeScheme != 'Runner') {
buildConfiguration += '-$xcodeScheme';
}
final bundleId = await runFastlaneProcess(
[
'run',
'get_product_bundle_id',
'project_filepath:Runner.xcodeproj',
'target:Runner',
'scheme:$xcodeScheme',
'build_configuration:$buildConfiguration',
],
printCall: true,
workingDirectory: _iosDirectory,
);
if (bundleId == null) throw Exception('Bundle Id not found');
_logger.info('Use app bundle id: $bundleId');
final fastlaneAppfile = '''
app_identifier("$bundleId")
apple_id("$appleUsername")
itc_team_id("$contentProviderId")
team_id("$teamId")
''';
await Directory(_fastlaneDirectory).create(recursive: true);
await File('$_fastlaneDirectory/Appfile').writeAsString(fastlaneAppfile);
final apiKeyJsonPath = await generateApiKeyJson(
apiPrivateKeyBase64: apiPrivateKeyBase64,
apiKeyId: apiKeyId,
apiIssuerId: apiIssuerId,
isTeamEnterprise: isTeamEnterprise,
workingDirectory: _iosDirectory,
);
Future<void> installCertificates({bool isDevelopment = false}) async {
final signingIdentity = isDevelopment ? 'Development' : 'Distribution';
final codeSigningIdentity =
'iPhone ${isDevelopment ? 'Developer' : 'Distribution'}';
// Disable automatic code signing
await runProcess(
'fastlane',
[
'run',
'update_code_signing_settings',
'use_automatic_signing:false',
'path:Runner.xcodeproj',
'code_sign_identity:$codeSigningIdentity',
'sdk:iphoneos*',
],
workingDirectory: _iosDirectory,
);
final p12PrivateKeyBytes =
base64Decode(isDevelopment ? '' : distributionPrivateKeyBase64);
final distributionPrivateKeyFile =
File('$_iosDirectory/$signingIdentity.p12');
await distributionPrivateKeyFile.writeAsBytes(p12PrivateKeyBytes);
// Import private key
await runProcess(
'fastlane',
[
'run',
'import_certificate',
'certificate_path:$signingIdentity.p12',
'keychain_name:$fastlaneKeychainName',
],
workingDirectory: _iosDirectory,
);
final certBytes =
base64Decode(isDevelopment ? '' : distributionCertificateBase64);
final certFile = File('$_iosDirectory/$signingIdentity.cer');
await certFile.writeAsBytes(certBytes);
// Import certificate
await runProcess(
'fastlane',
[
'run',
'import_certificate',
'certificate_path:$signingIdentity.cer',
'keychain_name:$fastlaneKeychainName',
],
workingDirectory: _iosDirectory,
);
// Download provisioning profile
await runProcess(
'fastlane',
[
'sigh',
'download_all',
if (isDevelopment) '--development',
// get_provisioning_profile
//'filename:$signingIdentity.mobileprovision', // only works for newly created profiles
'--api_key_path',
apiKeyJsonPath,
],
workingDirectory: _iosDirectory,
);
final iosDir = Directory(_iosDirectory);
final entities = await iosDir.list().toList();
Iterable<FileSystemEntity> provisioningProfilePaths =
entities.where((file) {
final fileName = file.uri.pathSegments.last;
return fileName.endsWith('.mobileprovision');
});
for (var provisioningProfilePath in provisioningProfilePaths) {
final filePath = provisioningProfilePath.uri.pathSegments.last;
final fileName = filePath.replaceAll('.mobileprovision', '');
final provisionParams = fileName.split('_');
final provisionIsDevelopment = provisionParams[0] != 'AppStore';
if (provisionIsDevelopment != isDevelopment) continue;
final provisionBundleId =
provisionParams[provisionParams.length > 2 ? 2 : 1];
// Install provisioning profile
await runProcess(
'fastlane',
[
'run',
'install_provisioning_profile',
'path:$filePath',
],
workingDirectory: _iosDirectory,
);
if (!updateProvisioning) continue;
// Update provisioning profile
// Need to get the target (product) name of the bundle ids in order to update the provisioning profiles.
// As there's no easy way to do this in fastlane, a script handles this.
final getBundleIdFromProductRubyUri = Uri.parse(
'package:flutter_release/fastlane/get_bundle_id_product.rb');
final getBundleIdFromProductRubyFile =
await Isolate.resolvePackageUri(getBundleIdFromProductRubyUri);
var result = await runProcess(
'ruby',
[
getBundleIdFromProductRubyFile!.path,
'Runner.xcodeproj',
provisionBundleId,
buildConfiguration,
],
workingDirectory: _iosDirectory,
);
final target = result.stdout.trim();
_logger.info('Target "$target" has bundle id "$provisionBundleId"');
result = await runProcess(
'fastlane',
[
'run',
'update_project_provisioning',
'xcodeproj:Runner.xcodeproj',
// 'build_configuration:${isDevelopment ? '/Debug|Profile/gm' : 'Release'}',
// 'build_configuration:${isDevelopment ? 'Debug' : 'Release'}',
'target_filter:${target.replaceAll('.', '\\.')}',
'profile:$filePath',
'code_signing_identity:$codeSigningIdentity',
],
workingDirectory: _iosDirectory,
);
_logger.info(
'Updating provisioning profile $filePath ($provisionBundleId)');
_logger.info(result.stdout);
}
}
// await installCertificates(isDevelopment: true);
await installCertificates(isDevelopment: _isDevelopment);
await runProcess(
'fastlane',
[
'run',
'update_project_team',
'path:Runner.xcodeproj',
'teamid:$teamId',
],
workingDirectory: _iosDirectory,
);
if (!isProduction) {
final buildVersion = platformBuild.flutterBuild.buildVersion;
// Remove semver preRelease suffix
// See: https://github.com/flutter/flutter/issues/27589
if (buildVersion.isPreRelease) {
platformBuild.flutterBuild.buildVersion =
platformBuild.flutterBuild.buildVersion.copyWith(pre: null);
_logger.info(
'Build version was truncated from $buildVersion to '
'${platformBuild.flutterBuild.buildVersion} as required by app store',
);
}
}
if (platformBuild.flutterBuild.buildVersion.build.isEmpty) {
var versionCode = await _getLastVersionCodeFromAppStoreConnect(
isProduction: isProduction,
apiKeyJsonPath: apiKeyJsonPath,
);
if (versionCode != null) {
// Increase versionCode by 1, if available:
versionCode++;
_logger.info(
'Use "$versionCode" as next version code (fetched from App Store Connect).',
);
platformBuild.flutterBuild.buildVersion =
platformBuild.flutterBuild.buildVersion.copyWith(
build: versionCode.toString(),
);
}
}
_logger.info('Build application...');
// Build xcarchive only
final outputPath = await platformBuild.build();
File? outputFile;
if (outputPath.isEmpty) {
_logger.warning('Build via flutter command finished failed silently. '
'This can happen using manual signing with provisioning profiles.\n'
'Therefore the app is now build again with fastlane. '
'See: https://docs.flutter.dev/deployment/cd, '
'and https://github.com/flutter/flutter/issues/106612');
// Build signed ipa
// https://docs.flutter.dev/deployment/cd
// https://github.com/flutter/flutter/issues/106612
_logger.info('Using XCode scheme "$xcodeScheme" to build the project.');
await runAsyncProcess(
printCall: true,
'fastlane',
[
'run',
'build_app',
'scheme:$xcodeScheme',
'skip_build_archive:true',
'archive_path:../build/ios/archive/Runner.xcarchive',
],
environment: {'FASTLANE_XCODEBUILD_SETTINGS_RETRIES': '15'},
workingDirectory: _iosDirectory,
);
} else {
_logger.info('Build artifact path: $outputPath');
outputFile = File(outputPath);
}
if (flutterPublish.isDryRun) {
_logger.info('Did NOT publish: Remove `--dry-run` flag for publishing.');
} else {
_logger.info('Publish...');
if (!isProduction) {
await runProcess(
'fastlane',
// upload_to_testflight
[
'pilot',
'upload',
'--skip_waiting_for_build_processing',
'--api_key_path',
apiKeyJsonPath,
if (outputFile != null) ...[
'--ipa',
outputFile.absolute.path,
],
],
workingDirectory: _iosDirectory,
printCall: true,
);
} else {
await runProcess(
'fastlane',
['upload_to_app_store', '--api_key_path', apiKeyJsonPath],
workingDirectory: _iosDirectory,
printCall: true,
);
}
}
// Clean up
await runProcess(
'fastlane',
[
'run',
'delete_keychain',
'name:$fastlaneKeychainName',
],
workingDirectory: _iosDirectory,
);
}