Introduction
In this post you will learn how to use Cake script to deploy Xamarin app to App Center, Google Play Internal and Test Flight.
Below, you can find previous posts from the series:
- Creating Cake script for building and deploying Xamarin app: Part 1 running unit tests
- Creating Cake script for building and deploying Xamarin app: Part 2 building Xamarin app
Table of contents
- Deploying to App Center
- Deploying to Google Play Internal
- Deploying to Test Flight
- Full script
- Summary
Deploying to App Center
Prerequisites
To communicate with App Center, we need an API token. To generate one, go to Account Settings:
Next go to User API tokens
:
Click on New API token
:
Provide name for your token and select level of Access and click on Add new API token
:
New API token will created:
Now you need to create new environment variable with value of API token. Our environment variable will be called TASTYFORMSAPP_APP_CENTER_TOKEN
and to get it’s value from Cake script we will use following code:
readonly string APP_CENTER_TOKEN = EnvironmentVariable("TASTYFORMSAPP_APP_CENTER_TOKEN");
You should NEVER keep tokens/secrets/passwords in your script for security reasons. These values should be kept in environments variables.
Deploying Android to App Center
To deploy APK to App Center, we will use the following task:
Task("DeployAPKToAppCenter") .IsDependentOn("PublishAPK") .Does<BuildInfo>(buildInfo => { if (buildInfo.Environment == PROD_ENV) { throw new InvalidOperationException("Master branch being deployed to App Center."); } AppCenterDistributeRelease(new AppCenterDistributeReleaseSettings { File = $"{APP_PACKAGE_FOLDER_NAME}/{buildInfo.ApkFileName}", Group = "Collaborators", App = buildInfo.AndroidAppCenterAppName, Token = APP_CENTER_TOKEN }); });
As first step, we are checking if we are not deploying PROD package to App Center. Production packages will be deployed to Google Play Internal. To check for which environment we are deploying package, use instance of buildInfo
class that we have added in previous post from the series.
Next, we are using method AppCenterDistributeRelease
and passing instance of AppCenterDistributeReleaseSettings
class with following values:
- File – path to APK
- Group – group to which we will distribute package
- App – app name for which we are deploying package
- Token – API token to communicate with App Center, which we have generated before
Providing proper value for App
can be tricky. The convention is organization_name/app_name
. The best way to get proper values is to check the url:
In this example, proper value for App
will be: TastyFormsApp/TastyFormsApp-Android-staging
Deploying iOS to App Center
To deploy IPA to App Center we will use the following task:
Task("DeployIPAToAppCenter") .IsDependentOn("PublishIPA") .Does<BuildInfo>(buildInfo => { if (buildInfo.Environment == PROD_ENV) { throw new InvalidOperationException("Master branch being deployed to App Center."); } AppCenterDistributeRelease(new AppCenterDistributeReleaseSettings { File = $"{APP_PACKAGE_FOLDER_NAME}/{buildInfo.IpaFileName}", Group = "Collaborators", App = buildInfo.IosAppCenterAppName, Token = APP_CENTER_TOKEN }); });
The task looks identical like DeployAPKToAppCenter
task with – the only difference is that we are providing values specific to iOS.
Google Play Internal
Prerequisites
Json Key
To communicate with Google Play Console, we need Json Key. To generate one navigate to Google Play Console to API access
page:
Next, click on Create new service account
and following pop-up will show:
Click on Google Cloud Platform
and you will be navigated to Service accounts
page:
Next click on create service account
and fill the form:
After clicking Done
, you will be navigated back to Services accounts
page. Now you need to use three dots for service you have just created and select Manage keys
option:
In new page you need to click on Create new key
:
Next, select that you want to generate JSON key and click on Create
:
After that JSON Key will be downloaded to your computer.
Now go back to Google Play Console and click on Grant access
for service account you have just created:
Next, select Release permissions and click on Invite user
:
Now we can use Json Key to communicate with Google Play Console. You need to create new environment variable with path to Json Key. Our environment variable will be called GOOGLE_PLAY_CONSOLE_JSON_KEY_FILE_PATH and to get it’s value from Cake script we will use following code:
readonly string GOOGLE_PLAY_CONSOLE_JSON_KEY_FILE_PATH = EnvironmentVariable("GOOGLE_PLAY_CONSOLE_JSON_KEY_FILE_PATH");
Fastlane
Fastlane is an open source platform aimed at simplifying Android and iOS deployment. We’ll use fastlane to deploy APK to Google Play Internal and IPA to Test Flight.
In order to install fastlane, you can use homebrew with following command brew install fastlane
.
Before using fastlane, you have to manually deploy APK to any track in Google Play. If you don’t do that you will receive error as described in this issue.
Deploying APK to Google Play Internal
In order to deploy APK to Google Play Internal track, you can use following task:
Task("DeployAPKToGooglePlayInternalTrack") .IsDependentOn("PublishAPK") .Does<BuildInfo>(buildInfo => { var configuration = new FastlaneSupplyConfiguration { ApkFilePath = $"{APP_PACKAGE_FOLDER_NAME}/{buildInfo.ApkFileName}", JsonKeyFilePath = GOOGLE_PLAY_CONSOLE_JSON_KEY_FILE_PATH, MetadataPath = "AndroidMetadata", SkipUploadMetadata = true, SkipUploadImages = true, SkipUploadScreenShots = true, Track = "internal", PackageName = buildInfo.PackageName }; Fastlane.Supply(configuration); });
We need to use Cake.Fastlane
addin in order to invoke Fastlane.Supply
method. Fastlane docs have more detailed information about supply
action.
We are invoking Fastlane.Supply
with instance of FastlaneSupplyConfiguration
class with following values:
- ApkFilePath – file path to APK
- JsonKeyFilePath – file path to Json Key
- MetadataPath – name of folder with metadata (changelog, screenshots, etc)
- SkipUploadMetadata – whether to skip uploading metadata, changelogs not included
- SkipUploadImages – whether to skip uploading images
- SkipUploadScreenShots – whether to skip uploading screenshots
- Track – track for which to deploy APK
- PackageName – package name
Fastlane.Supply
will also send changelogs to Google Play. To make it happen you have to create proper folder hierarchy like this:
metadata ├── en-US │ └── changelogs │ └── default.txt └── pl-PL └── changelogs └── default.txt
You see this folder hierarchy on my GitHub repo.
Test Flight
Prerequisites
To communicate with App Store Connect we need API Key. To generate API Key go to App Store Connect => Users and Access => Keys:
Click on plus button and pop-up will show:
Provide a name for key and select access level (“Developer” should be enough). Next you will be able to download your key:
You can download individual key only once.
Now you need to create new environment variable with path to API key. Our environment variable will be called APP_STORE_CONNECT_API_JSON_KEY_FILE_PATH and to get its value from Cake script we will use following code:
readonly string APP_STORE_CONNECT_API_JSON_KEY_FILE_PATH = EnvironmentVariable("APP_STORE_CONNECT_API_JSON_KEY_FILE_PATH");
Deploying IPA to Test Flight
In order to deploy IPA to Test Flight you can use following task:
Task("DeployIPAToTestFlight") .IsDependentOn("PublishIPA") .Does<BuildInfo>(buildInfo => { Information("Preparing to upload new build number: " + buildInfo.BuildNumber); var ipaPath = MakeAbsolute(Directory($"{APP_PACKAGE_FOLDER_NAME}/{buildInfo.IpaFileName}")); StartProcess("fastlane", new ProcessSettings { Arguments = "pilot upload --ipa " + ipaPath + " --skip_waiting_for_build_processing true --api_key_path " + APP_STORE_CONNECT_API_JSON_KEY_FILE_PATH}); });
We are using StartProcess
method to invoke fastlane action pilot
with following arguments:
- ipa – path to IPA file
- skip_waiting_for_build_processing – if set to
true
then script won’t wait for App Store Connect to finish processing package - api_key_path – path to API key
Full script
Here is a full script including Tasks from previous posts in the series:
#addin "Cake.Xamarin" #addin "Cake.AppCenter" #addin "Cake.Plist" #addin "Cake.AndroidAppManifest" #addin "Cake.Fastlane" #tool "nuget:?package=GitVersion.CommandLine&version=5.0.1" // Reference older version because newest doesn't work on macOS. var target = Argument("target", (string)null); var environmentArg = Argument("env", (string)null); //==================================================================== // Consts // Environment const string DEV_ENV = "dev"; const string STAGING_ENV = "staging"; const string PROD_ENV = "prod"; // General const string APP_NAME="TastyFormsApp"; const string PATH_TO_SOLUTION = "TastyFormsApp.sln"; const string PATH_TO_UNIT_TESTS_PROJECT = "TastyFormsApp.Tests/TastyFormsApp.Tests.csproj"; const string APP_PACKAGE_FOLDER_NAME = "AppPackages"; readonly string APP_CENTER_TOKEN = EnvironmentVariable("TASTYFORMSAPP_APP_CENTER_TOKEN"); // Android const string PATH_TO_ANDROID_PROJECT = "TastyFormsApp.Android/TastyFormsApp.Android.csproj"; const string PATH_TO_ANDROID_MANIFEST_FILE = "TastyFormsApp.Android/Properties/AndroidManifest.xml"; readonly string PATH_TO_ANDROID_KEYSTORE_FILE = EnvironmentVariable("TASTYFORMSAPP_KEYSTORE_PATH"); readonly string ANDROID_KEYSTORE_ALIAS = EnvironmentVariable("TASTYFORMSAPP_KEYSTORE_ALIAS"); readonly string ANDROID_KEYSTORE_PASSWORD = EnvironmentVariable("TASTYFORMSAPP_KEYSTORE_PASSWORD"); readonly string GOOGLE_PLAY_CONSOLE_JSON_KEY_FILE_PATH = EnvironmentVariable("GOOGLE_PLAY_CONSOLE_JSON_KEY_FILE_PATH"); // iOS const string PATH_TO_IOS_PROJECT = "TastyFormsApp.iOS/TastyFormsApp.iOS.csproj"; const string PATH_TO_INFO_PLIST_FILE = "TastyFormsApp.iOS/Info.plist"; readonly string APP_STORE_CONNECT_API_JSON_KEY_FILE_PATH = EnvironmentVariable("APP_STORE_CONNECT_API_JSON_KEY_FILE_PATH"); //==================================================================== // Moves app package to app packages folder public string MoveAppPackageToPackagesFolder(FilePath appPackageFilePath) { var packageFileName = appPackageFilePath.GetFilename(); var targetAppPackageFilePath = new FilePath($"{APP_PACKAGE_FOLDER_NAME}/" + packageFileName); if (FileExists(targetAppPackageFilePath)) { DeleteFile(targetAppPackageFilePath); } EnsureDirectoryExists($"{APP_PACKAGE_FOLDER_NAME}"); MoveFile(appPackageFilePath, targetAppPackageFilePath); return targetAppPackageFilePath.ToString(); } //==================================================================== // Class that hold information for current build. public class BuildInfo { public string ApiUrl { get; } public int BuildNumber { get; } public string AppVersion { get; } public string AppName { get; } public string PackageName { get; } public string AndroidAppCenterAppName { get; } public string ApkFileName { get; } public string IosAppCenterAppName { get; } public string IpaFileName { get; } public string Environment { get; } public BuildInfo( string apiUrl, int buildNumber, string appVersion, string appName, string packageName, string androidAppCenterAppName, string apkFileName, string iosAppCenterAppName, string ipaFileName, string environment) { ApiUrl = apiUrl; BuildNumber = buildNumber; AppVersion = appVersion; AppName = appName; PackageName = packageName; AndroidAppCenterAppName = androidAppCenterAppName; ApkFileName = apkFileName; IosAppCenterAppName = iosAppCenterAppName; IpaFileName = ipaFileName; Environment = environment; } } //==================================================================== // Setups script. Setup<BuildInfo>(setupContext => { var gitVersion = GitVersion(); var apiUrl = "https://dev.tastyformsapp.com"; var appName = $"{APP_NAME}.dev"; var packageName = "com.tastyformsapp.dev"; var androidAppCenterAppName = "TastyFormsApp/TastyFormsApp-Android-DEV"; var iosAppCenterAppName = "TastyFormsApp/TastyFormsApp-iOS-DEV"; var ipaFileName = $"{APP_NAME}.iOS.ipa"; var currentEnvironment = GetEnvironment(); if (currentEnvironment == STAGING_ENV) { apiUrl = "https://staging.tastyformsapp.com"; appName = $"{APP_NAME}.staging"; packageName = "com.tastyformsapp.staging"; androidAppCenterAppName = "TastyFormsApp/TastyFormsApp-Android-staging"; iosAppCenterAppName = "TastyFormsApp/TastyFormsApp-iOS-staging"; } else if (currentEnvironment == PROD_ENV) { apiUrl = "https://prod.tastyformsapp.com"; appName = APP_NAME; packageName = "com.tastyformsapp"; } var apkFileName = $"{packageName}-Signed.apk"; return new BuildInfo( apiUrl, buildNumber: (int)DateTimeOffset.UtcNow.ToUnixTimeSeconds(), appVersion: gitVersion.MajorMinorPatch, appName, packageName, androidAppCenterAppName, apkFileName, iosAppCenterAppName, ipaFileName, currentEnvironment); }); public string GetEnvironment() { if (String.IsNullOrEmpty(environmentArg)) { var gitVersion = GitVersion(); var branchName = gitVersion.BranchName; if (branchName.StartsWith("release/")) { return STAGING_ENV; } else if (branchName.Equals("master")) { return PROD_ENV; } else { return DEV_ENV; } } else { return environmentArg; } } //==================================================================== // Cleans all bin and obj folders. Task("Clean") .Does(() => { CleanDirectories("**/bin"); CleanDirectories("**/obj"); }); //==================================================================== // Restores NuGet packages for solution. Task("Restore") .Does(() => { NuGetRestore(PATH_TO_SOLUTION); }); //==================================================================== // Updates config files with proper values Task("UpdateConfigFiles") .Does<BuildInfo>(buildInfo => { var appSettingsFile = File("TastyFormsApp/AppSettings.cs"); TransformTextFile(appSettingsFile) .WithToken("API_URL", buildInfo.ApiUrl) .Save(appSettingsFile); }); //==================================================================== // Run unit tests Task("RunUnitTests") .IsDependentOn("Clean") .IsDependentOn("Restore") .Does(() => { var settings = new DotNetCoreTestSettings { Configuration = "Release", ArgumentCustomization = args=>args.Append("--logger trx") }; DotNetCoreTest(PATH_TO_UNIT_TESTS_PROJECT, settings); }); //==================================================================== Android ==================================================================== //==================================================================== // Update Android Manifest Task("UpdateAndroidManifest") .Does<BuildInfo>(buildInfo => { var androidManifestFilePath = new FilePath(PATH_TO_ANDROID_MANIFEST_FILE); var manifest = DeserializeAppManifest(androidManifestFilePath); manifest.VersionName = buildInfo.AppVersion; manifest.VersionCode = buildInfo.BuildNumber; manifest.ApplicationLabel = buildInfo.AppName; manifest.PackageName = buildInfo.PackageName; manifest.Debuggable = false; SerializeAppManifest(androidManifestFilePath, manifest); }); //==================================================================== // Publish Android APK Task("PublishAPK") .IsDependentOn("RunUnitTests") .IsDependentOn("UpdateConfigFiles") .IsDependentOn("UpdateAndroidManifest") .Does<BuildInfo>(buildInfo => { FilePath apkFilePath = null; if (buildInfo.Environment == PROD_ENV) { apkFilePath = BuildAndroidApk(PATH_TO_ANDROID_PROJECT, sign: true, configurator: msBuildSettings => { msBuildSettings.WithProperty("AndroidKeyStore", "True") .WithProperty("AndroidSigningKeyAlias", ANDROID_KEYSTORE_ALIAS) .WithProperty("AndroidSigningKeyPass", ANDROID_KEYSTORE_PASSWORD) .WithProperty("AndroidSigningKeyStore", PATH_TO_ANDROID_KEYSTORE_FILE) .WithProperty("AndroidSigningStorePass", ANDROID_KEYSTORE_PASSWORD); }); } else { apkFilePath = BuildAndroidApk(PATH_TO_ANDROID_PROJECT, sign: true); } MoveAppPackageToPackagesFolder(apkFilePath); }); //==================================================================== // Deploy APK to Google Play Internal track Task("DeployAPKToGooglePlayInternalTrack") .IsDependentOn("PublishAPK") .Does<BuildInfo>(buildInfo => { var configuration = new FastlaneSupplyConfiguration { ApkFilePath = $"{APP_PACKAGE_FOLDER_NAME}/{buildInfo.ApkFileName}", JsonKeyFilePath = GOOGLE_PLAY_CONSOLE_JSON_KEY_FILE_PATH, MetadataPath = "AndroidMetadata", SkipUploadMetadata = true, SkipUploadImages = true, SkipUploadScreenShots = true, Track = "internal", PackageName = buildInfo.PackageName }; Fastlane.Supply(configuration); }); //==================================================================== // Deploys APK to App Center Task("DeployAPKToAppCenter") .IsDependentOn("PublishAPK") .Does<BuildInfo>(buildInfo => { if (buildInfo.Environment == PROD_ENV) { throw new InvalidOperationException("Master branch being deployed to App Center."); } AppCenterDistributeRelease(new AppCenterDistributeReleaseSettings { File = $"{APP_PACKAGE_FOLDER_NAME}/{buildInfo.ApkFileName}", Group = "Collaborators", App = buildInfo.AndroidAppCenterAppName, Token = APP_CENTER_TOKEN }); }); //==================================================================== iOS ==================================================================== //==================================================================== // Update iOS Info.plist Task("UpdateIosInfoPlist") .Does<BuildInfo>(buildInfo => { var plist = File(PATH_TO_INFO_PLIST_FILE); dynamic data = DeserializePlist(plist); data["CFBundleShortVersionString"] = buildInfo.AppVersion; data["CFBundleVersion"] = buildInfo.BuildNumber.ToString(); data["CFBundleName"] = buildInfo.AppName; data["CFBundleDisplayName"] = buildInfo.AppName; data["CFBundleIdentifier"] = buildInfo.PackageName; SerializePlist(plist, data); }); //==================================================================== // Publish iOS IPA Task("PublishIPA") .IsDependentOn("RunUnitTests") .IsDependentOn("UpdateConfigFiles") .IsDependentOn("UpdateIosInfoPlist") .Does<BuildInfo>(buildInfo => { var buildConfiguration = "Release"; if (buildInfo.Environment == PROD_ENV) { buildConfiguration = "AppStore"; } var ipaFilePath = BuildiOSIpa(PATH_TO_IOS_PROJECT, buildConfiguration); MoveAppPackageToPackagesFolder(ipaFilePath); }); //==================================================================== // Deploys IPA to App Center Task("DeployIPAToAppCenter") .IsDependentOn("PublishIPA") .Does<BuildInfo>(buildInfo => { if (buildInfo.Environment == PROD_ENV) { throw new InvalidOperationException("Master branch being deployed to App Center."); } AppCenterDistributeRelease(new AppCenterDistributeReleaseSettings { File = $"{APP_PACKAGE_FOLDER_NAME}/{buildInfo.IpaFileName}", Group = "Collaborators", App = buildInfo.IosAppCenterAppName, Token = APP_CENTER_TOKEN }); }); //==================================================================== // Deploys IPA to Test Flight Task("DeployIPAToTestFlight") .IsDependentOn("PublishIPA") .Does<BuildInfo>(buildInfo => { Information("Preparing to upload new build number: " + buildInfo.BuildNumber); var ipaPath = MakeAbsolute(Directory($"{APP_PACKAGE_FOLDER_NAME}/{buildInfo.IpaFileName}")); StartProcess("fastlane", new ProcessSettings { Arguments = "pilot upload --ipa " + ipaPath + " --skip_waiting_for_build_processing true --api_key_path " + APP_STORE_CONNECT_API_JSON_KEY_FILE_PATH}); }); //==================================================================== App Center ==================================================================== //==================================================================== // Deploys APK and IPA to App Center Task("DeployAPKAndIPAToAppCenter") .IsDependentOn("DeployAPKToAppCenter") .IsDependentOn("DeployIPAToAppCenter") .Does<BuildInfo>(buildInfo => { }); //==================================================================== RunTarget(target);
Summary
The script with sample Xamarin.Forms app project is already available on my GitHub. In the next post, I will show how to run Cake script in Azure Dev Ops.