В этой статье хочу рассказать о подходе к сборке Unity-проектов на android и ios через Gitlab на собственных сборщиках с macOS.
Я работаю в небольшой gamedev компании, и задача автоматизации сборки появилась из-за следующих проблем:
- 5 распределенных команд должны собирать проекты из любой точки мира
- должны поддерживаться разные версии юнити
- сборщик должен обеспечивать как минимум 5 сборок в неделю от каждой команды
- сертификаты должны храниться централизованно, а не у разработчиков
- собранные билды должны быть доступны по ссылке в любой точке мира
- проекты должны проверяться на наличие обязательных библиотек (рекламные sdk и коды, локализация, сохранения)
- конфигурирование сборки для команд должно производиться в одном месте
Для решения этих проблем уже созданы готовые решения: Unity Cloud Build, TeamCity, Jenkins, Gitlab CI, Bitbucket Pipelines.
Первый из них, хоть и подготовлен для сборки Unity-проектов, но не позволяет автоматизировать работу с сертификатами, и для каждого проекта их приходится заводить вручную. TeamCity и Jenkins требуют настройки проекта в админках (это немного усложняет конфигурирование для разработчиков), установку дополнительного программного обеспечения на отдельный сервер и его поддержку. В итоге, самыми простыми и быстрыми в реализации остались два варианта — Gitlab и Bitbucket.
На момент решения проблемы Bitbucket Pipelines еще не анонсировали, поэтому было принято решение использовать Gitlab.
Для реализации подхода выполнены следующие шаги:
- Настройка проекта
- Настройка раннера
- Создание скриптов сборки
1. Настройка проекта
Проекты, которые собираются на сборщике мы храним на Gitlab. Бесплатная версия сервиса никак не ограничивает сами репозитории и их количество.
Для каждого проекта включается раннер (сервис, выполняющий команды от gitlab-сервера), работающий на маке.
Конфигурация для сборщика лежит в корне проекта в виде .gitlab-ci.yml файла. В нем описывается id приложения, требуемый signing identity (keystore для android и имя аккаунта для ios), требуемая версия Unity, ветка, режим запуска: ручной или автоматический и команда, которая запускает сборку (при необходимости, gitlab поддерживает гораздо больше параметров, документация).
variables:
BUNDLE: com.banana4apps.evolution
SIGNING: banana4apps
UNITY_VERSION: 2017.1
build:android:
script:
- buildAndroid.sh $BUNDLE $SIGNING $UNITY_VERSION
only:
- releaseAndroid
when: manual
build:ios:
script:
- buildIOS.sh $BUNDLE $SIGNING $UNITY_VERSION
only:
- releaseIOS
when: manual
2. Настройка раннера
Gitlab CI работает с общими (shared) и собственными раннерами (документация). Бесплатная версия ограничивает число часов использования shared раннеров, но позволяет безлимитно использовать собственные раннеры. Shared раннеры запускаются на linux, поэтому на них iOS приложения собирать не получится (но Unity запустить получится, на хабре была статья об этом). Из-за этого пришлось поднимать раннеры на собственных маках. В приведенном выше примере раннер запускает скрипт buildAndroid.sh или buildIOS.sh (в зависимости от ветки), в котором описаны подготовительные шаги, запуск Unity и уведомление о результате сборки.
Процесс настройки раннера хорошо описан в документации и сводится к запуску gitlab-runner install
и gitlab-runner start
.
После этого на мак устанавливаются необходимые версии Unity.
3. Создание скриптов сборки
Для каждой из платформ, ввиду различий процесса сборки, пришлось написать собственный скрипт. Но алгоритм одинаковый:
- проверяем корректность id проекта, наличие сертификатов для нужного signing identity
- определяем пути до SDK, Java
- создаем в проекте C# класс с методом для запуска сборки
- проверяем наличие необходимой версии Unity и запускаем сборку. Если нет, то пытаемся собрать на версии по умолчанию
- Проверяем наличие apk или Xcode проекта, и если их нет — сигнализируем об ошибке в Slack
- для iOS: собираем Xcode проект
- загружаем артефакты (apk, ipa) на сервер (например, Amazon S3)
- сигнализируем об успешной сборке в Slack и отправляем ссылку на скачивание артефактов
Особенность сборки Unity проекта в том, что Unity в batch режиме позволяет выполнить только статический метод класса, имеющегося в проекте. Поэтому скрипт сборки “подкидывает” в проект класс с методами для запуска сборки:
public class CustomBuild
{
static string outputProjectsFolder = Environment.GetEnvironmentVariable("OutputDirectory");
static string xcodeProjectsFolder = Environment.GetEnvironmentVariable("XcodeDirectory");
static void BuildAndroid()
{
BuildTarget target = BuildTarget.Android;
EditorUserBuildSettings.SwitchActiveBuildTarget(target);
PlayerSettings.applicationIdentifier = Environment.GetEnvironmentVariable("AppBundle");
PlayerSettings.Android.keystoreName = Environment.GetEnvironmentVariable("KeystoreName");
PlayerSettings.Android.keystorePass = Environment.GetEnvironmentVariable("KeystorePassword");
PlayerSettings.Android.keyaliasName = Environment.GetEnvironmentVariable("KeyAlias");
PlayerSettings.Android.keyaliasPass = Environment.GetEnvironmentVariable("KeyPassword");
BuildPipeline.BuildPlayer(GetScenes(), string.Format("{0}/{1}.apk" , outputProjectsFolder, PlayerSettings.applicationIdentifier), target, options);
}
static void BuildIOS()
{
BuildTarget target = BuildTarget.iOS;
EditorUserBuildSettings.SwitchActiveBuildTarget(target);
PlayerSettings.applicationIdentifier = Environment.GetEnvironmentVariable("AppBundle");
PlayerSettings.iOS.appleDeveloperTeamID = Environment.GetEnvironmentVariable("GymTeamId");
BuildPipeline.BuildPlayer(GetScenes(), xcodeProjectsFolder, target, options);
}
// Добавляем выбранные в настройках сцены в билд
static string[] GetScenes()
{
var projectScenes = EditorBuildSettings.scenes;
List<string> scenesToBuild = new List<string>();
for (int i = 0; i < projectScenes.Length; i++)
{
if (projectScenes[i].enabled) {
scenesToBuild.Add(projectScenes[i].path);
}
}
return scenesToBuild.ToArray();
}
}
Метод Environment.GetEnvironmentVariable получает значение environment переменных, которые предварительно были указаны в bash-скриптах.
Пример скрипта сборки для Android
GREEN='33[0;32m'
RED='33[0;33m'
NC='33[0m' # No Color
export COMMIT=$(git log -1 --oneline —no-merges)
if [ "$1" = "" ]; then
echo -e "${RED}You must provide application Id${NC}"
exit 1
fi
export ANDROID_HOME=/Library/Android
export OutputDirectory=./
export AppBundle=$1
if [ "$2" = "account1" ]; then
export KeystoreName="$CI_DATA_PATH/keystores/account1.keystore"
export KeystorePassword="..."
export KeyAlias="..."
export KeyPassword="..."
elif [ "$2" = "account2" ]; then
export KeystoreName="$CI_DATA_PATH/keystores/account2.keystore"
export KeystorePassword="..."
export KeyAlias="..."
export KeyPassword="..."
else
echo "${RED}No keystore config found for $2${NC}"
exit 1
fi
echo -e "${GREEN}BundleId: ${AppBundle}${NC}"
echo -e "${GREEN}Signing: ${KeyAlias}${NC}"
# Копируем файл для запуска сборки
mkdir -p $CI_PROJECT_DIR/Assets/Editor && cp $CI_DATA_PATH/CustomBuild.cs "$_"
# Запускаем сборку Unity
if [ "$3" = "5.5" ]; then
/Applications/Unity5.5/Unity.app/Contents/MacOS/Unity -buildTarget android -projectPath $CI_PROJECT_DIR -batchmode -executeMethod CustomBuild.Android -quit -logFile /dev/stdout -username "..." -password "..."
elif [ "$3" = "2017.1" ]; then
/Applications/Unity2017.1/Unity.app/Contents/MacOS/Unity -buildTarget android -projectPath $CI_PROJECT_DIR -batchmode -executeMethod CustomBuild.Android -quit -logFile /dev/stdout -username "..." -password "..."
else
/Applications/Unity5.6.4/Unity.app/Contents/MacOS/Unity -buildTarget android -projectPath $CI_PROJECT_DIR -batchmode -executeMethod CustomBuild.Android -quit -logFile /dev/stdout -username "..." -password "..."
fi
# Сборка успешна, если имеем apk
export APK="${CI_PROJECT_DIR}/${OutputDirectory}/${AppBundle}.${CI_BUILD_ID}.apk"
echo "Testing apk exists: ${APK}..."
if [ -f ${APK} ]; then
echo -e "${GREEN}BUILD FOR ANDROID SUCCESS${NC}"
# Загрузить apk и дать разрешение на чтение
aws s3 cp ${APK} s3://ci-data/android/${AppBundle}.${CI_BUILD_ID}.apk --grants read=uri=http://acs.amazonaws.com/groups/global/AllUsers
echo "<html><title>Download apk: ${AppBundle}</title><body><a href="https://ci-data.s3.amazonaws.com/android/${AppBundle}.${CI_BUILD_ID}.apk">Install<br><br><strong>${AppBundle}</strong><br><small>${COMMIT}<br>(build ${CI_BUILD_ID} - android)</small></a></body></html>" >> ${CI_PROJECT_DIR}/download.html
# Загрузить html и дать разрешение на чтение
aws s3 cp ${CI_PROJECT_DIR}/download.html s3://ci-data/android/${AppBundle}.${CI_BUILD_ID}.html --grants read=uri=http://acs.amazonaws.com/groups/global/AllUsers
# Отправить ссылку в Slack
${CI_DATA_PATH}/notifySlack.sh android success "https://ci-data.s3.amazonaws.com/android/${AppBundle}.${CI_BUILD_ID}.html"
exit 0
else
echo -e "${RED}BUILD FOR ANDROID FAILED${NC}"
${CI_DATA_PATH}/notifySlack.sh android failure
exit 1
fi
Пример скрипта сборки для iOS
Сборка проектов осуществляется в два шага: формирование Xcode проекта из Unity и сборка Xcode проекта. Разработчики не могут напрямую влиять на Xcode проект, что вносит ограничения: нельзя напрямую изменять настройки проекта, информацию о сборке.
Также, особенность сборки на iOS в том, что тестовые устройства должны быть зарегистрированы в provisioning профиле приложения. А чтобы собрать Xcode проект, нужно до сборки создать сертификат, provisioning профиль и id приложения в developer консоли Apple.
Для автоматизации этого процесса используется fastlane. Этот инструмент создает и синхронизирует сертификаты, профили и позволяет загружать билды и мета-информацию в itunes connect.
При сборке Unity проектов без доступа к Xcode есть нюансы:
- в Unity перед сборкой проекта нужно указать TeamId, который есть в консоли разработчика — это делается через PlayerSettings.iOS.appleDeveloperTeamID
- в postprocess скрипте проекта необходимо выполнить предварительную обработку Xcode проекта: настроить info.plist, build settings
Релизная и Ad-Hoc сборка также имеют разные скрипты, отличающиеся формированием результата: релизная грузит архив в itunes connect, а ad-hoc грузит ipa, создает манифест и страницу для скачивания over the air, ссылка на которую рассылается всем заинтересованным лицам.
GREEN='33[0;32m'
RED='33[0;33m'
NC='33[0m' # No Color
export COMMIT=$(git log -1 --oneline --no-merges)
if [ "$1" = "" ]; then
echo -e "${RED}You must provide application Id${NC}"
exit 1
fi
if [ "$2" = "account1" ]; then
# Описываем аккаунт для fastlane утилит
export AccountName="account email"
export AccountDesc="account description"
export FastlanePassword="..."
export GymExportTeamId="..."
export FastlaneRepository="fastlane-keys.git"
export ProduceTeamName="team name"
else
echo "${RED}No keystore config found for $2${NC}"
exit 1
fi
echo -e "${GREEN}BundleId: ${AppBundle}${NC}"
echo -e "${GREEN}Account: ${AccountDesc} (${2})${NC}"
# Копируем файл для запуска сборки
mkdir -p $CI_PROJECT_DIR/Assets/Editor && cp $CI_DATA_PATH/CustomBuild.cs "$_"
# Запускаем сборку Unity
if [ "$3" = "5.5" ]; then
/Applications/Unity5.5/Unity.app/Contents/MacOS/Unity -buildTarget ios -projectPath $CI_PROJECT_DIR -batchmode -executeMethod CustomBuild.IOS -quit -logFile /dev/stdout -username "..." -password "..."
elif [ "$3" = "2017.1" ]; then
/Applications/Unity2017.1/Unity.app/Contents/MacOS/Unity -buildTarget ios -projectPath $CI_PROJECT_DIR -batchmode -executeMethod CustomBuild.IOS -quit -logFile /dev/stdout -username "..." -password "..."
else
/Applications/Unity5.6.4/Unity.app/Contents/MacOS/Unity -buildTarget ios -projectPath $CI_PROJECT_DIR -batchmode -executeMethod CustomBuild.IOS -quit -logFile /dev/stdout -username "..." -password "..."
fi
# Проверяем, что Unity создал XCode проект
XCODE_FILES="${CI_PROJECT_DIR}/${XcodeDirectory}"
if [ -d ${XCODE_FILES} ]; then
# Создаем приложение в Apple Developer Console
export PRODUCE_APP_IDENTIFIER=${AppBundle}
export PRODUCE_APP_NAME=${AppBundle}
export PRODUCE_USERNAME=${AccountName}
export PRODUCE_SKU=${AppBundle}
# skip_itc не создает приложение в itunes connect - для adhoc это необязательно
fastlane produce --app_version "1.0" --language "English" --skip_itc
# Скачиваем или создаем code signing keys and profiles
cd "${CI_PROJECT_DIR}/${XcodeDirectory}"
rm -f Matchfile
echo "git_url "${FastlaneRepository}"" >> Matchfile
echo "app_identifier ["${AppBundle}"]" >> Matchfile
echo "username "${AccountName}"" >> Matchfile
# Пароль, которым зашифрован репозиторий с ключами
export MATCH_PASSWORD='...'
# В зависимости от вида сборки, запрашиваем нужные сертификаты
# force_for_new_devices true добавляет все новые тестовые устройства, которые указаны в
developer console
fastlane match adhoc --force_for_new_devices true
# Создаем Gymfile и собираем XCode project и подписываем Ad-Hoc сертификатом
rm -f Gymfile
echo "export_options(" >> Gymfile
echo " manifest: {" >> Gymfile
echo " appURL: "https://ci-data.s3.amazonaws.com/ios/${AppBundle}.${CI_BUILD_ID}.ipa","
>> Gymfile
echo " displayImageURL: "https://ci-data.s3.amazonaws.com/ios-icon.png"," >> Gymfile
echo " fullSizeImageURL: "https://ci-data.s3.amazonaws.com/ios-icon-big.png"" >> Gymfile
echo " }," >> Gymfile
echo ")" >> Gymfile
fastlane gym --scheme "Unity-iPhone" --export_method ${GYM_EXPORT_METHOD} --xcargs "DEVELOPMENT_TEAM="${GYM_EXPORT_TEAM_ID}" PROVISIONING_PROFILE_SPECIFIER="match AdHoc ${AppBundle}" CODE_SIGN_IDENTITY="iPhone Distribution: ${AccountDesc}"" -o "${CI_PROJECT_DIR}/" -n "${AppBundle}.${CI_BUILD_ID}.ipa"
# Создаем страницу для скачивания на S3
export IPA="${CI_PROJECT_DIR}/${AppBundle}.${CI_BUILD_ID}.ipa"
ls -l "${CI_PROJECT_DIR}/${XcodeDirectory}/*.ipa"
echo "Testing ipa exists: ${IPA}..."
if [ -f ${IPA} ]; then
echo -e "Begin uploading to S3..."
aws s3 cp ${CI_PROJECT_DIR}/${AppBundle}.${CI_BUILD_ID}.ipa s3://ci-data/ios/${AppBundle}.${CI_BUILD_ID}.ipa --grants read=uri=http://acs.amazonaws.com/groups/global/AllUsers
aws s3 cp ${CI_PROJECT_DIR}/manifest.plist s3://ci-data/ios/${AppBundle}.${CI_BUILD_ID}.plist --grants read=uri=http://acs.amazonaws.com/groups/global/AllUsers
echo "<html><title>Download ipa: ${AppBundle}</title>" >> ${CI_PROJECT_DIR}/download.html
echo "<body><a href="itms-services://?action=download-manifest&url=https://ci-data.s3.amazonaws.com/ios/${AppBundle}.${CI_BUILD_ID}.plist">Install<br><br><strong>${AppBundle}</strong><br><small>${COMMIT}<br>(build ${CI_BUILD_ID} - iOS)</small></a></body></html>" >> ${CI_PROJECT_DIR}/download.html
aws s3 cp ${CI_PROJECT_DIR}/download.html s3://ci-data/ios/${AppBundle}.${CI_BUILD_ID}.html --grants read=uri=http://acs.amazonaws.com/groups/global/AllUsers
${CI_DATA_PATH}/notifySlack.sh ios ad-hoc "https://ci-data.s3.amazonaws.com/ios/${AppBundle}.${CI_BUILD_ID}.html"
echo -e "${GREEN}BUILD AD-HOC FOR IOS SUCCESS${NC}"
exit 0
else
echo -e "${RED}BUILD AD-HOC FOR IOS FAILED${NC}"
${CI_DATA_PATH}/notifySlack.sh ios failure
exit 1
fi
else
echo -e "${RED}BUILD FOR IOS FAILED${NC}"
${CI_DATA_PATH}/notifySlack.sh ios failure
exit 1
fi
GREEN='33[0;32m'
RED='33[0;33m'
NC='33[0m' # No Color
export COMMIT=$(git log -1 --oneline --no-merges)
if [ "$1" = "" ]; then
echo -e "${RED}You must provide application Id${NC}"
exit 1
fi
if [ "$2" = "account1" ]; then
# Описываем аккаунт для fastlane утилит
export AccountName="account email"
export AccountDesc="account description"
export FastlanePassword="..."
export GymExportTeamId="..."
export FastlaneRepository="fastlane-keys.git"
export ProduceTeamName="team name"
else
echo "${RED}No keystore config found for $2${NC}"
exit 1
fi
echo -e "${GREEN}BundleId: ${AppBundle}${NC}"
echo -e "${GREEN}Account: ${AccountDesc} (${2})${NC}"
# Копируем файл для запуска сборки
mkdir -p $CI_PROJECT_DIR/Assets/Editor && cp $CI_DATA_PATH/CustomBuild.cs "$_"
# Запускаем сборку Unity
if [ "$3" = "5.5" ]; then
/Applications/Unity5.5/Unity.app/Contents/MacOS/Unity -buildTarget ios -projectPath $CI_PROJECT_DIR -batchmode -executeMethod CustomBuild.IOS -quit -logFile /dev/stdout -username "..." -password "..."
elif [ "$3" = "2017.1" ]; then
/Applications/Unity2017.1/Unity.app/Contents/MacOS/Unity -buildTarget ios -projectPath $CI_PROJECT_DIR -batchmode -executeMethod CustomBuild.IOS -quit -logFile /dev/stdout -username "..." -password "..."
else
/Applications/Unity5.6.4/Unity.app/Contents/MacOS/Unity -buildTarget ios -projectPath $CI_PROJECT_DIR -batchmode -executeMethod CustomBuild.IOS -quit -logFile /dev/stdout -username "..." -password "..."
fi
# Проверяем, что Unity создал XCode проект
XCODE_FILES="${CI_PROJECT_DIR}/${XcodeDirectory}"
if [ -d ${XCODE_FILES} ]; then
# Создаем приложение в Apple Developer Console and Itunes Connect
export PRODUCE_APP_IDENTIFIER=${AppBundle}
export PRODUCE_APP_NAME=${AppBundle}
export PRODUCE_USERNAME=${AccountName}
export PRODUCE_SKU=${AppBundle}
fastlane produce --app_version "1.0" --language "English"
# Скачиваем или создаем code signing keys and profiles
cd "${CI_PROJECT_DIR}/${XcodeDirectory}"
rm -f Matchfile
echo "git_url "${FastlaneRepository}"" >> Matchfile
echo "app_identifier ["${AppBundle}"]" >> Matchfile
echo "username "${AccountName}"" >> Matchfile
# Пароль, которым зашифрован репозиторий с ключами
export MATCH_PASSWORD='...'
# Запрашиваем нужные сертификаты
fastlane match appstore
# Собираем в XCode
fastlane gym --scheme "Unity-iPhone" --xcargs "DEVELOPMENT_TEAM="${GymExportTeamId}" PROVISIONING_PROFILE_SPECIFIER="match AppStore ${AppBundle}" CODE_SIGN_IDENTITY="iPhone Distribution: ${AccountDesc}"" -o "${CI_PROJECT_DIR}/" -n "${AppBundle}.${CI_BUILD_ID}.ipa"
# Загружаем в itunes connect
export IPA="${CI_PROJECT_DIR}/${AppBundle}.${CI_BUILD_ID}.ipa"
ls -l "${CI_PROJECT_DIR}/${XcodeDirectory}/*.ipa"
echo "Testing ipa exists: ${IPA}..."
if [ -f ${IPA} ]; then
rm -f Deliverfile
echo "app_identifier "${AppBundle}"" >> Deliverfile
echo "username "${AccountName}"" >> Deliverfile
echo "ipa "${IPA}"" >> Deliverfile
echo "submit_for_review false" >> Deliverfile
echo "force true" >> Deliverfile
fastlane deliver
echo -e "${GREEN}BUILD FOR IOS SUCCESS${NC}"
exit 0
else
echo -e "${RED}BUILD FOR IOS FAILED${NC}"
${CI_DATA_PATH}/notifySlack.sh ios failure
exit 1
fi
else
echo -e "${RED}BUILD FOR IOS FAILED${NC}"
${CI_DATA_PATH}/notifySlack.sh ios failure
exit 1
fi
Как с этой системой работают другие
- Разработчик добавляет в корень проекта шаблонный файл .gitlab-ci.yml, включает раннер в настройках проекта на gitlab.com, пушит код в нужную ветку.
- Геймдизайнер и тестировщик получают в Slack уведомление об успешной сборке и ссылку на скачивание apk и ipa архивов.
- Я слежу за сборками, вижу логи и могу помогать разработчикам разобраться с ошибками. Логи и запущенные сборки можно увидеть прямо на gitlab. Из минусов — сейчас в интерфейсе нельзя увидеть очередь сборки для определенного раннера.
Интерфейс просмотра логов сборки:
Результаты
Таким образом, получившаяся система является простой в использовании, позволяет добавлять проверки и валидации со стороны сервера (code style, тесты), при этом менеджеры видят ссылки на сборки в Slack и нет проблем со сборкой на iOS.
Из минусов — необходима ее поддержка для добавления новых версий Unity, signing identity и обеспечения работоспособности маков.
На текущий момент у нас работают два раннера (около двух лет), через систему прошло более 4000 сборок. Скорость сборки зависит от характеристик раннера и количества ассетов в проекте, ведь они импортируются каждый раз заново и она варьируется в пределах 3 — 30 минут для Android и 10 — 60 для iOS.
Автор: muxapet