Creating Cake script for building and deploying Xamarin app: Part 3 deployment

In this post you will learn how to use Cake script to deploy Xamarin app to App Center, Google Play Internal and Test Flight.

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:

Table of contents

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.

Leave a Reply

Your email address will not be published. Required fields are marked *