Pull to refresh

Автоматическая сборка Unity-проектов для Android и iOS с помощью Gitlab CI

Reading time 14 min
Views 13K

В этой статье хочу рассказать о подходе к сборке 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 поддерживает гораздо больше параметров, документация).


Пример файла конфигурации .gitlab-ci.yml
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 режиме позволяет выполнить только статический метод класса, имеющегося в проекте. Поэтому скрипт сборки “подкидывает” в проект класс с методами для запуска сборки:


CustomBuild.cs
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


buildAndroid.sh
GREEN='\033[0;32m'
RED='\033[0;33m'
NC='\033[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, ссылка на которую рассылается всем заинтересованным лицам.

Сборка Ad-hoc: buildAdhocIOS.sh
GREEN='\033[0;32m'
RED='\033[0;33m'
NC='\033[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

Сборка релиза: buildIOS.sh
GREEN='\033[0;32m'
RED='\033[0;33m'
NC='\033[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. Из минусов — сейчас в интерфейсе нельзя увидеть очередь сборки для определенного раннера.

Интерфейс просмотра логов сборки:


image


Результаты


Таким образом, получившаяся система является простой в использовании, позволяет добавлять проверки и валидации со стороны сервера (code style, тесты), при этом менеджеры видят ссылки на сборки в Slack и нет проблем со сборкой на iOS.


Из минусов — необходима ее поддержка для добавления новых версий Unity, signing identity и обеспечения работоспособности маков.


На текущий момент у нас работают два раннера (около двух лет), через систему прошло более 4000 сборок. Скорость сборки зависит от характеристик раннера и количества ассетов в проекте, ведь они импортируются каждый раз заново и она варьируется в пределах 3 — 30 минут для Android и 10 — 60 для iOS.

Tags:
Hubs:
+8
Comments 4
Comments Comments 4

Articles