Since HockeyApp has been retired our Android team has been using Microsoft App Center to distribute builds internally. We needed a way to automate app builds with release notes using our CI pipeline.
In the past with HockeyApp we were using this super simple curl to distribute a build. Isn’t it nice and succinct? 😍
curl \ | |
-F "status=2" \ | |
-F "notify=0" \ | |
-F "notes=$RELEASE_NOTES" \ | |
-F "notes_type=1" \ | |
-F "teams=Internal-Testers" \ | |
-F "ipa=@app/build/outputs/apk/$ENV_NAME/$APK_NAME" \ | |
-H "X-HockeyAppToken: $API_TOKEN" \ | |
https://rink.hockeyapp.net/api/2/apps/$2/app_versions/upload |
Now onto App Center! According to the aforementioned article the transition from HockeyApp to App Center would be a “seamless transition process”.
Disbelief from Giphy
It has definitely not been a seamless transition process. Even after following their documentation things did not work as expected. With the help of the App Center support team and a sample script they provided I was able to get our build pipeline working again.
The following is everything you need to know to automate your Android build process.
App Center deploy script
This is the deploy script you’ll call from your CI pipeline. Call this script after the step that creates an APK build. In our project this deploy script is in ProjectRoot/.signing
.
As you can see it requires 7 steps and is much longer than the code required to release HockeyApp builds. 😆
#!/bin/sh | |
# Description: | |
# This script releases a build to App Center using the last commit message for release notes. | |
# Each new line in the commit will be a separate bullet in the release notes. | |
# If the release notes contain the word "skip_appcenter_release" then no release will be generated. | |
# Example script usage: | |
# deploy.sh [dev|release] {API key} | |
# Environment variables defined in CircleCi: | |
# $APP_CENTER_API_TOKEN_STAGING | |
# $APP_CENTER_API_TOKEN_PROD | |
# $APP_CENTER_DISTRIBUTION_GROUP | |
# $APP_CENTER_OWNER_NAME | |
cd ProjectRoot | |
APPCENTER_OUTPUT_DIRECTORY="." | |
VERSION=$(./gradlew -q ":app:printVersion" | tail -n 1) | |
APP_NAME=ParentAppStaging | |
CONTENT_TYPE='application/vnd.android.package-archive' | |
ENV_NAME=staging | |
APK_NAME="AppName-staging-$VERSION.apk" | |
if [ "$1" == "release" ]; then | |
APK_NAME="AppName-production-$VERSION.apk" | |
ENV_NAME=release | |
APP_NAME=ParentAppProd | |
fi | |
echo "Preparing to build $APK_NAME" | |
RELEASE_FILE_LOCATION=$(pwd)"/app/build/outputs/apk/$ENV_NAME/$APK_NAME" | |
FILE_SIZE_BYTES=$(wc -c $RELEASE_FILE_LOCATION | awk '{print $1}') | |
# -------------- Handle release notes -------------- | |
# Get the last commit message and use it as release notes | |
RELEASE_NOTES=$(git log -1 --pretty=%B | grep -v -e '^$' | grep -v "^Merge pull request" | sed -e 's/^/\* /' | awk '{printf "%s\\n", $0}') | |
echo -e "Formatted release notes are below:\n$RELEASE_NOTES\n\n" | |
if [[ $RELEASE_NOTES == *"skip_appcenter_release"* ]]; then | |
echo "**** Skipping App Center release. ****" | |
exit 0 | |
fi | |
# -------------- Begin upload process -------------- | |
UPLOAD_DOMAIN="https://file.appcenter.ms/upload" | |
API_URL="https://api.appcenter.ms/v0.1/apps/$APP_CENTER_OWNER_NAME/$APP_NAME" | |
AUTH="X-API-Token: $2" | |
ACCEPT_JSON="Accept: application/json" | |
# Create upload resource - step 1/7 ************************** | |
echo "Creating upload resource (1/7)" | |
upload_json=$(curl -s -X POST -H "Content-Type: application/json" -H "$ACCEPT_JSON" -H "$AUTH" "$API_URL/uploads/releases") | |
releases_id=$(echo $upload_json | jq -r '.id') | |
package_asset_id=$(echo $upload_json | jq -r '.package_asset_id') | |
url_encoded_token=$(echo $upload_json | jq -r '.url_encoded_token') | |
# Upload metadata - step 2/7 ************************** | |
echo "Creating metadata (2/7)" | |
metadata_url="$UPLOAD_DOMAIN/set_metadata/$package_asset_id?file_name=$APK_NAME&file_size=$FILE_SIZE_BYTES&token=$url_encoded_token&content_type=$CONTENT_TYPE" | |
meta_response=$(curl -s -d POST -H "Content-Type: application/json" -H "$ACCEPT_JSON" -H "$AUTH" "$metadata_url") | |
chunk_size=$(echo $meta_response | jq -r '.chunk_size') | |
split_dir=$APPCENTER_OUTPUT_DIRECTORY/split-dir | |
mkdir -p $split_dir | |
eval split -b $chunk_size $RELEASE_FILE_LOCATION $split_dir/split | |
# Upload APK chunks - step 3/7 ************************** | |
echo "Uploading chunked binary (3/7)" | |
binary_upload_url="$UPLOAD_DOMAIN/upload_chunk/$package_asset_id?token=$url_encoded_token" | |
block_number=1 | |
for i in $split_dir/* | |
do | |
echo "start uploading chunk $i" | |
url="$binary_upload_url&block_number=$block_number" | |
size=$(wc -c $i | awk '{print $1}') | |
curl -X POST $url --data-binary "@$i" -H "Content-Length: $size" -H "Content-Type: $CONTENT_TYPE" | |
block_number=$(($block_number + 1)) | |
printf "\n" | |
done | |
# Finalize upload - step 4/7 ************************** | |
echo "Finalising upload (4/7)" | |
finish_url="$UPLOAD_DOMAIN/finished/$package_asset_id?token=$url_encoded_token" | |
curl -d POST -H "Content-Type: application/json" -H "$ACCEPT_JSON" -H "$AUTH" "$finish_url" | |
# Commit release - step 5/7 ************************** | |
echo "Committing release (5/7)" | |
commit_url="$API_URL/uploads/releases/$releases_id" | |
curl -H "Content-Type: application/json" -H "$ACCEPT_JSON" -H "$AUTH" \ | |
--data '{"upload_status": "uploadFinished","id": "$releases_id"}' \ | |
-X PATCH \ | |
$commit_url | |
# Poll for release ID - step 6/7 ************************** | |
echo "\nPolling for release ID (6/7)" | |
release_id=null | |
counter=0 | |
max_poll_attempts=15 | |
while [[ $release_id == null && ($counter -lt $max_poll_attempts)]] | |
do | |
poll_result=$(curl -s -H "Content-Type: application/json" -H "$ACCEPT_JSON" -H "$AUTH" $commit_url) | |
release_id=$(echo $poll_result | jq -r '.release_distinct_id') | |
echo $counter $release_id | |
counter=$((counter + 1)) | |
sleep 3 | |
done | |
if [[ $release_id == null ]]; | |
then | |
echo "Failed to find release from App Center" | |
exit 1 | |
fi | |
# Distribute build - step 7/7 ************************** | |
echo "Distributing build (7/7)" | |
distribute_url="$API_URL/releases/$release_id" | |
distribute_response=$(curl -H "Content-Type: application/json" -H "$ACCEPT_JSON" -H "$AUTH" \ | |
--data '{"destinations": [{ "name": "'"$APP_CENTER_DISTRIBUTION_GROUP"'"}], "notify_testers": true, "release_notes": "'"$RELEASE_NOTES"'" }' \ | |
-X PATCH \ | |
$distribute_url) | |
status_code=$(echo $distribute_response | jq -r '.status') | |
if [[ $status_code == 500 ]]; | |
then | |
echo "Failed to distribute app. Response = $distribute_response" | |
exit 1 | |
fi | |
# -------------- End upload process -------------- |
That’s a lot of code! It is. Let’s go over the parts that you’ll need to make this work.
App info from App Center
We’ll need the owner name and app name from App Center. We have a staging and production App Center variant so our deploy script takes both into account. The App Center URL format is as follows.
https://appcenter.ms/orgs/{owner_name}/apps/{app_name}
This gives you APP_CENTER_OWNER_NAME
and APP_NAME
. We also added APP_CENTER_DISTRIBUTION_GROUP
as an environment variable for flexibility. If you look at release history in App Center this is under the Destinations column.
API keys
Head over to App Center and navigate to the target app > Settings > App API tokens
. Create a full access token. You’ll need to do this for each one of your apps in App Center.
Save the API tokens as environment variables in your CI settings. We used APP_CENTER_API_TOKEN_STAGING
and APP_CENTER_API_TOKEN_PROD
as we have a staging and production variant in App Center.
Version named APKs
When we generate a build our APK file names have a version. For example AppName-staging-2.3.4–567.apk
. There are two parts to this. 1) Generating APK files with versions and 2) reading that version in the deploy script (line 20 from the deploy script above).
- Generating version named APKs
This goes in the android.buildTypes
block. For each build type that we have, we name it.
buildTypes { | |
applicationVariants.all { variant -> | |
def version = "${variant.versionName}-${variant.versionCode}" | |
variant.outputs.all { output -> | |
if (variant.buildType.name == "debug") { | |
outputFileName = "AppName-debug-${version}.apk" | |
} else if (variant.buildType.name == "staging") { | |
outputFileName = "AppName-staging-${version}.apk" | |
} else if (variant.buildType.name == "release") { | |
outputFileName = "AppName-production-${version}.apk" | |
} | |
} | |
} | |
... | |
} |
Job Offers
2. Reading the version from the deploy script
In order to access the version in the deploy script we’ll use a gradle task.
task printVersion { | |
println "${android.defaultConfig.versionName}-${android.defaultConfig.versionCode}" | |
} |
Then as you can see on line 20 from the deploy script we read this version.
VERSION=$(./gradlew -q ":app:printVersion" | tail -n 1)
Automatic build number increments
All this build automation is great but wouldn’t it be nice if the build number could automatically be incremented on each new build? There’s a way! (Assuming your CI system has a build number environment variable.)
We use CircleCi for Continuous Integration. CircleCi has built-in environment variables. One of these variables is CIRCLE_BUILD_NUM
.
In order to update the app build number we need to get that number into the app build.gradle
file. One way to do that is have our CI system write the build number to a file and then read that file in our gradle file.
Write the CI build number to a file
echo "VERSION_CODE=$CIRCLE_BUILD_NUM" > version.properties
This creates a file called version.properties
in ProjectRoot/.signing
. This is one of the steps in our CI file that runs before we build the app.
Read this number in our app build.gradle file
android { | |
//version.properties is only used to get the version number from CircleCi | |
def versionFile = file('../.signing/version.properties') | |
def buildNumber = 456 | |
if(versionFile.canRead()) { | |
Properties props = new Properties() | |
props.load(new FileInputStream(versionFile)) | |
//Add skip_appcenter_release in a commit message to develop/master/release if you don't want a build to be created by CircleCi | |
if(props['VERSION_CODE'] != null) { | |
def circleBuildNumber = props['VERSION_CODE'].toInteger() | |
buildNumber = circleBuildNumber - 3870 //subtract offset to get close to our real build number | |
} | |
} | |
defaultConfig { | |
versionCode buildNumber | |
versionName "1.2.3" | |
... | |
} | |
... | |
} |
Branch level execution
Using a CircleCi configuration file, we are able to specify that we only want to deploy builds when we commit to develop, master, or release branches.
Here’s the step in our CI system where we do this.
- run: | |
name: Upload build to App Center (if develop, release, or master) | |
command: | | |
if [ "$CIRCLE_BRANCH" == "develop" ] || [[ "$CIRCLE_BRANCH" == "release/"* ]]; then | |
bash ./ProjectRoot/.signing/deploy.sh dev $APP_CENTER_API_TOKEN_STAGING | |
fi | |
if [ "$CIRCLE_BRANCH" == "master" ]; then | |
bash ./ProjectRoot/.signing/deploy.sh release $APP_CENTER_API_TOKEN_PROD | |
fi |
And there you have it! A slick way to automate your Android app builds.