Introduction
In this post, I’ll present how to use Cake to build Xamarin app for different environments. You will learn how to update platform specific files (AndroidManifest.xml and Info.plist) and create platform specific packages (APK and IPA).
Below you can find previous post from the series:
Preparing build information
For most of the apps, there are couple of properties that need to be set:
- API url
- app build number
- app version
- app name
- package name
- APK file name
- IPA file name
For these properties, we need a class, that will be used in different Tasks in script:
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; } }
These properties will have different values depending on environment for which app build is being prepared for. In our Cake script, I have assumed three environments:
- DEV
- STAGING
- PROD
In order to properly set values for these properties based on environment, we can use Setup
method that will be executed once before all Tasks:
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; } }
Setup
method is creating instance of BuildInfo class with proper values based on environment.
To detect for which environment values needs to be set we are using method GetEnvironment
. If environment is passed as a script argument, then this method will return argument value. Otherwise, script uses GitVersion to detect which Git branch script is being executed on. If name of branch starts with “release/” then we assume this is STAGING environment. If branch name starts with “master” then we assume this is PROD environment. Default environment is DEV for other branches.
GitVersion is also used in Setup
method to set value for appVersion
property. To set value for this property, we are using value of MajorMinorPatch
. GitVersion has a few workflows for Version Incrementing. For example for branch named “release/2.3.4” GitVersion will return value 2.3.4
for property MajorMinorPatch
.
One more thing worth to mention is how Setup
method sets value for buildNumber
. Cake script can be run on any computer in the development team, so we can’t save current build number anywhere. To solve this issue, we simply can set current unix timestamp as our build number. Unix timestamp will be always bigger, so it makes perfect value for build number.
Updating config files
API url for our sample app is being kept in AppSettings
file:
namespace TastyFormsApp { public class AppSettings { #if DEBUG public const string ApiUrl = "https://dev.tastyformsapp.com"; #else public const string ApiUrl = "<%API_URL%>"; #endif } }
If application is build in DEBUG
configuration then hard coded url will be used. For other build configurations, we will update url with proper value depending on environment:
Task("UpdateConfigFiles") .Does<BuildInfo>(buildInfo => { var appSettingsFile = File("TastyFormsApp/AppSettings.cs"); TransformTextFile(appSettingsFile) .WithToken("API_URL", buildInfo.ApiUrl) .Save(appSettingsFile); });
Task UpdateConfigFiles
is using instance of BuildInfo
class to get API url. Method WithToken
is used to replace key with value.
Android
Updating Android Manifest
Before building an APK, we need to update AndroidManifest.xml
file with proper values:
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); });
UpdateAndroidManifest
task is using BuildInfo
instance to get proper values. AndroidAppManifest
adding is used to DeserializeAppManifest
and SerializeAppManifest
.
Building Android Application Package
To build Android APK, we need to use Cake.Xamarin
adding in order to invoke method BuildAndroidApk
:
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); });
After successfully build, we are moving APK to app package folder using method MoveAppPackageToPackagesFolder
:
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(); }
iOS
Updating Info.plist
Before building an IPA, we need to update Info.plist
file with proper values:
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); });
UpdateIosInfoPlist
task is using BuildInfo
instance to get proper values. Cake.Plist
adding is used to DeserializePlist
and SerializePlist
.
Building IPA
To build iOS IPA we need to use Cake.Xamarin
adding in order to invoke method BuildiOSIpa
:
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); });
Full script
Here is a full script including Tasks from previous posts in the series:
#addin "Cake.Xamarin" #addin "Cake.Plist" #addin "Cake.AndroidAppManifest" #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"; // 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"); // iOS const string PATH_TO_IOS_PROJECT = "TastyFormsApp.iOS/TastyFormsApp.iOS.csproj"; const string PATH_TO_INFO_PLIST_FILE = "TastyFormsApp.iOS/Info.plist"; //==================================================================== // 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); }); //==================================================================== // 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); }); //==================================================================== // 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); }); //==================================================================== 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); }); //==================================================================== 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); }); //==================================================================== 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 deploy Xamarin app to App Center, Google Play Internal and Test Flight.
This is a little bit more complex because there are different outputs depending on whether we want to target Windows (the win10-arm runtime) or Ubuntu (the ubuntu.16.04-arm runtime). But it s still easy enough to do this we just create an argument to allow the user to pass their desired runtime to the build script. I ve decided to make the win10-arm runtime the default. As I mentioned earlier, it s easy for me to deploy the published application to a device running Windows since the device is on my network and I know the IP address, I can just specify the IP address of the device, and the directory that I want to deploy to. These can both be parameters that I pass to the build script, or set as defaults in the build.cake file.