Как стать автором
Поиск
Написать публикацию
Обновить

Как сделать GitLab CI/CD вашим лучшим другом для сборки и доставки Flutter приложений. Часть 2

Уровень сложностиСредний
Время на прочтение22 мин
Количество просмотров280

Flutter & GitLab CI/CD. Сборка и подписание мобильных приложений

Привет! Меня зовут Данил Абдрафиков, я мобильный разработчик в компании TAGES. Эта статья является продолжением первой части руководства по настройке GitLab CI/CD для Flutter приложений, в котором мы подробно разобрали настройку физической машины и подготовили GitLab Runner для работы. Теперь, когда инфраструктура готова, перейдем к самому интересному – автоматизации сборки и подписания мобильных приложений.

Готовы превратить сборку мобильных приложений из рутинной задачи в полностью автоматизированный процесс? Тогда начинаем!

Введение

Мы подошли к самому важному этапу – созданию полноценного конвейера автоматизированной сборки и подписания ваших Flutter приложений для платформ Android и iOS. Весь процесс можно разделить на три ключевые фазы: сборка (build), деплой (deploy) и очистка (cleanup).

Этапы CI/CD-пайплайна.
Этапы CI/CD-пайплайна.

В этой статье мы сфокусируемся на фундаменте – этапе build. Именно на нем происходит компиляция кода, подписание и подготовка релизных артефактов (APK/IPA). Без него невозможна работа всего последующего конвейера.

Ключевые темы, которые мы раскроем сегодня:

  1. Децентрализованный подход к CI/CD – реализуем модульный и гибкий подход, создав центральный репозиторий с универсальными готовыми скриптами.

  2. Подготовка Flutter проекта – пошагово разберем все необходимые изменения в коде и конфигурации Flutter проекта, которые обеспечат его беспрепятственную интеграцию с GitLab CI/CD.

  3. Результаты автоматизации – увидим готовый пайплайн в действии: от коммита до получения подписанных релизных артефактов (APK/IPA), готовых к распространению.

Этапы deploy (публикация в сторы) и cleanup (управление артефактами) мы подробно разберем в следующей части материала.

Децентрализованный подход к CI/CD

При работе над несколькими Flutter проектами разработчики нередко сталкиваются с проблемой дублирования конфигураций CI/CD: одни и те же этапы сборки, тестирования и деплоя настраиваются заново для каждого репозитория, а это – потерянное время и риск новых ошибок.

Чтобы избежать этой проблемы, вместо обычного копирования скриптов между проектами мы воспользуемся децентрализованным подходом со встраиваемыми конфигурациями.

Создание репозитория

Для начала создадим отдельный репозиторий (например, flutter-ci-templates), где разместим набор готовых скриптов для различных этапов сборки. Эти скрипты я разработал специально для Flutter проектов с учетом особенностей сборки как для Android, так и для iOS:

.
│ └── build
│     └── android.gitlab-ci.yml
│     └── ios.gitlab-ci.yml
│ └── common.gitlab-ci.yml
.

./build/android.gitlab-ci.yml

Этот файл автоматизирует сборку и подпись Android приложений. Он получает ключи подписи из защищенного хранилища, настраивает переменные окружения и выполняет сборку APK/AAB.

android.gitlab-ci.yml
.import_keystore:
  script:
    - export ANDROID_KEYSTORE_PATH=$(find "$CI_PROJECT_DIR/.secure_files" -type f \( -name "*.keystore" -o -name "*.jks" \) | head -n 1)
    - |
      if [[ -z "$ANDROID_KEYSTORE_PATH" ]]; then
        echo "❌ Error: No .keystore or .jks file found in Secure Files."
        exit 1
      fi
      echo "✅ Found keystore file: $ANDROID_KEYSTORE_PATH"

.build_apk_or_aab:
  script:
    - |
      set -- --"$BUILD_TYPE" -t $PROJECT_ENTRY_POINT
      if [[ -n "$DART_DEFINE_PATH" && -f "$DART_DEFINE_PATH" ]]; then
        set -- "$@" --dart-define-from-file=$DART_DEFINE_PATH
      fi
      set -- "$@" \
        -Pandroid.injected.signing.store.file=$ANDROID_KEYSTORE_PATH \
        -Pandroid.injected.signing.store.password=$ANDROID_KEYSTORE_PASSWORD \
        -Pandroid.injected.signing.key.alias=$ANDROID_KEY_ALIAS \
        -Pandroid.injected.signing.key.password=${ANDROID_KEY_PASSWORD:-$ANDROID_KEYSTORE_PASSWORD}
    - flutter build apk "$@"
    - mv build/app/outputs/apk/$BUILD_TYPE/$BUILD_APK_FILE_NAME $BUILD_APK_FILE_PATH
    - |-
      if [[ "$BUILD_AAB_ARCHIVE" == "true" ]]; then
        flutter build appbundle "$@"
        mv build/app/outputs/bundle/$BUILD_TYPE/$BUILD_AAB_FILE_NAME $BUILD_AAB_FILE_PATH
      fi

.build_android:
  script:
    - !reference [ .import_keystore, script ]
    - !reference [ .build_apk_or_aab, script ]

.variables:build_android:
  variables:
    DEPENDENCIES_TO_INSTALL: "curl jq"

.before_script:build_android:
  before_script:
    - !reference [ .clone_project, before_script ]
    - !reference [ .setup_environment, before_script ]
    - !reference [ .configure_git, before_script ]
    - !reference [ .extract_secure_files, before_script ]
    - !reference [ .extract_dart_define, before_script ]
    - !reference [ .configure_flutter, before_script ]

./build/ios.gitlab-ci.yml

Данный файл автоматизирует сборку iOS приложений, а также позволяет регистрировать устройства в Apple Developer, создавать и настраивать связку ключей, импортировать сертификаты и профили, обновлять настройки подписи кода и выполнять сборку IPA.

ios.gitlab-ci.yml
.register_devices:
  script:
    - |
      if ! $APP_STORE_CONNECT_ENABLED; then
        echo "⚠️ Warning: App store connect integration disabled. The devices registration skipped."
      elif [ -z "$APPLE_DEVICES_FILE" ]; then
        echo "⚠️ Warning: Environment are missing: APPLE_DEVICES_FILE. The devices registration skipped."
      else
        bundle exec fastlane run register_devices \
          api_key_path:"$APP_STORE_CONNECT_API_KEY_PATH" \
          devices_file:"$APPLE_DEVICES_FILE"
      fi

.create_keychain:
  script:
    - |
      echo "ℹ️ Info: Using keychain $KEYCHAIN_NAME"
    - security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
    - security list-keychains -s "$KEYCHAIN_NAME"
    - security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
    - security set-keychain-settings -lut $CI_JOB_TIMEOUT "$KEYCHAIN_NAME"

.delete_keychain:
  script:
    - |
      if security list-keychains | grep "$KEYCHAIN_NAME" > /dev/null; then
        echo "ℹ️ Info: Deleting $KEYCHAIN_NAME keychain..."
        security delete-keychain "$KEYCHAIN_NAME"
        rm -f ~/Library/Keychains/"$KEYCHAIN_NAME"
      fi

.import_certificates:
  script:
    - CERTIFICATES=$(find "$CI_PROJECT_DIR/.secure_files" -name "*.p12")
    - |
      if [[ -z "$CERTIFICATES" ]]; then
        echo "❌ Error: No certificates found in Secure Files."
        exit 1
      fi
    - |
      for CERTIFICATE_PATH in $CERTIFICATES; do
        CERT_NAME=$(basename "$CERTIFICATE_PATH" .p12)
        CERT_PWD_ENV_NAME=$(echo "$CERT_NAME" | tr '[:lower:]' '[:upper:]' | tr '.' '_' | tr '-' '_')
        CERT_PWD_ENV_NAME="CERT_PASSWORD_${CERT_PWD_ENV_NAME%.*}"
        CERT_PASSWORD="${!CERT_PWD_ENV_NAME}"
        if [[ -z "$CERT_PASSWORD" ]]; then
          echo "❌ Error: Password for certificate $CERT_NAME is not set (variable $CERT_PWD_ENV_NAME)."
          exit 1
        fi
        echo "ℹ️ Info: Importing $CERT_NAME into keychain $KEYCHAIN_NAME..."
        security import "$CERTIFICATE_PATH" -k "$HOME/Library/Keychains/$KEYCHAIN_NAME" -P "$CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/productsign
        security set-key-partition-list -S "apple-tool:,apple:" -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
      done
    - security list-keychain -d user -s login.keychain-db "$KEYCHAIN_NAME"
    - echo "✅ All certificates imported successfully."

.import_profiles:
  script:
    - |
      if $APP_STORE_CONNECT_ENABLED; then
        echo "$TARGET_BUNDLE_MAPPING" | jq -c '.[]' | while IFS= read -r item; do
          BUNDLE_ID=$(echo "$item" | jq -r '.bundle_id_override // .bundle_id')
          TARGET_NAME=$(echo "$item" | jq -r '.target_name')
          echo "ℹ️ Info: Download profile for bundle id: $BUNDLE_ID ($TARGET_NAME)"
          SIGN_TYPE_ARGS=$([ "$SIGN_TYPE" == "adhoc" ] && echo "adhoc: true" || \
            { [ "$SIGN_TYPE" == "development" ] && echo "development: true"; } || echo "")
          bundle exec fastlane run sigh \
            force: true \
            $SIGN_TYPE_ARGS \
            app_identifier:"$BUNDLE_ID" \
            api_key_path:"$APP_STORE_CONNECT_API_KEY_PATH" \
            team_id:"$FASTLANE_TEAM_ID" \
            output_path:"$CI_PROJECT_DIR/.secure_files"
        done
      else
        echo "⚠️ Warning: App store connect integration disabled. Import provisioning profiles from Secure Files..."
        mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles/
        PROFILES=("$CI_PROJECT_DIR/.secure_files"/*.mobileprovision)
        if [[ ${#PROFILES[@]} -eq 0 || ! -f "${PROFILES[0]}" ]]; then
          echo "❌ Error: No provisioning profiles found"
          exit 1
        fi
        for profile in "${PROFILES[@]}"; do
          cp "$profile" ~/Library/MobileDevice/Provisioning\ Profiles/
          echo "ℹ️ Info: Provisioning profile imported: $(basename "$profile")"
        done
        echo "✅ All provisioning profiles imported successfully."
      fi

.update_code_signing_settings:
  script:
    - |
      case "$SIGN_TYPE" in
        appstore) TARGET_PROFILE_TYPE="AppStore";;
        adhoc) TARGET_PROFILE_TYPE="AdHoc";;
        development) TARGET_PROFILE_TYPE="Development";;
      *) echo "❌ Error: Unsupported SIGN_TYPE: $SIGN_TYPE"; exit 1;;
      esac
      export TARGET_PROFILE_TYPE
    - |
      case "$SIGN_TYPE" in
        appstore|adhoc) CODE_SIGN_IDENTITY="iPhone Distribution";;
        development) CODE_SIGN_IDENTITY="iPhone Developer";;
        *) echo "❌ Error: Unsupported SIGN_TYPE: $SIGN_TYPE"; exit 1;;
      esac
      export CODE_SIGN_IDENTITY
    - |
      echo "$TARGET_BUNDLE_MAPPING" | jq -c '.[]' | while IFS= read -r item; do
        BUNDLE_ID=$(echo "$item" | jq -r '.bundle_id_override // .bundle_id')
        TARGET_NAME=$(echo "$item" | jq -r '.target_name')
        PROFILE_NAME=$(echo "$item" | jq -r '.profile_name // empty')
        TARGET_PROFILE_NAME="${PROFILE_NAME:-"$BUNDLE_ID $TARGET_PROFILE_TYPE"}"
        echo "ℹ️ Info: Setting profile for target: $TARGET_NAME -> $TARGET_PROFILE_NAME"
        (cd ios && bundle exec fastlane run update_code_signing_settings \
          use_automatic_signing:false \
          team_id:"$FASTLANE_TEAM_ID" \
          code_sign_identity:"$CODE_SIGN_IDENTITY" \
          targets:"$TARGET_NAME" \
          profile_name:"$TARGET_PROFILE_NAME" \
          bundle_identifier:"$BUNDLE_ID")
      done
    - |
      if [ -z "$APP_GROUP_MAPPING" ]; then
        echo "ℹ️️ Info: APP_GROUP_MAPPING is not defined or empty. Skipping update."
      else
        echo "$APP_GROUP_MAPPING" | jq -c '.[]' | while IFS= read -r item; do
          ENTITLEMENTS_FILE=$(echo "$item" | jq -r '.entitlements_file')
          APP_GROUP_IDENTIFIERS=$(echo "$item" | jq -r '.app_group_identifiers | join(",")')
          echo "ℹ️ Info: Updating app group identifiers for entitlement: $ENTITLEMENTS_FILE"
          (cd ios && bundle exec fastlane run update_app_group_identifiers \
            entitlements_file:"$ENTITLEMENTS_FILE" \
            app_group_identifiers:"$APP_GROUP_IDENTIFIERS")
        done
      fi

.build_ipa:
  script:
    - (cd ios && pod repo update)
    - |
      FLUTTER_BUILD_CMD=(flutter build ipa --no-codesign --"$BUILD_TYPE" -t "$PROJECT_ENTRY_POINT")
      if [[ -n "$DART_DEFINE_PATH" && -f "$DART_DEFINE_PATH" ]]; then
        FLUTTER_BUILD_CMD+=(--dart-define-from-file="$DART_DEFINE_PATH")
      fi
      echo "ℹ️ Info: Running: ${FLUTTER_BUILD_CMD[*]}"
      "${FLUTTER_BUILD_CMD[@]}"
    - |
      (cd ios && bundle exec fastlane run build_ios_app silent:true output_directory:"$CI_PROJECT_DIR/build") || {
        LOG_FILE=$(ls -t "$GYM_BUILDLOG_PATH" 2>/dev/null | head -n 1)
        if [ -n "$LOG_FILE" ]; then
          echo "❌ Error: The iOS app build process failed. Please review the log file below for more details:"
          cat "$GYM_BUILDLOG_PATH/$LOG_FILE"
          exit 1
        else
          echo "⚠️ Warning: No log file found in $GYM_BUILDLOG_PATH"
        fi
      }

.build_ios:
  script:
    - !reference [ .register_devices, script ]
    - !reference [ .delete_keychain, script ]
    - !reference [ .create_keychain, script ]
    - !reference [ .update_code_signing_settings, script ]
    - !reference [ .import_certificates, script ]
    - !reference [ .import_profiles, script ]
    - !reference [ .build_ipa, script ]

.before_script:build_ios:
  before_script:
    - !reference [ .clone_project, before_script ]
    - !reference [ .configure_git, before_script ]
    - !reference [ .configure_fastlane, before_script ]
    - !reference [ .extract_secure_files, before_script ]
    - !reference [ .extract_dart_define, before_script ]
    - !reference [ .configure_flutter, before_script ]

.after_script:build_ios:
  after_script:
    - !reference [ .delete_keychain, script ]

./common.gitlab.ci.yml

Этот файл содержит общие настройки CI/CD для Flutter проектов и автоматизирует конфигурацию Git, установку зависимостей, извлечение файлов из Secure Files, настройку Dart-переменных, Flutter SDK и Fastlane.

common.gitlab.ci.yml
.configure_git:
  before_script:
    - echo -e "\e[0Ksection_start:`date +%s`:git_section\r\e[0KConfigure git"
    - |-
      for file in ./pubspec.yaml ./ios/Podfile; do
        sed -ie "s/\(https:\/\/\).*\($CI_SERVER_SHELL_SSH_HOST\)/\1$CI_REGISTRY_USER:$CI_JOB_TOKEN@\2/g" "$file" || true
      done
    - echo -e "\e[0Ksection_end:`date +%s`:git_section\r\e[0K"

.clone_project:
  before_script:
    - |
      if [ -n "${GIT_CLONE_PROJECT_PATH}" ]; then
        git clone --branch ${GIT_CLONE_REF_NAME} --single-branch --depth 1 https://gitlab-ci-token:${CI_JOB_TOKEN}@${CI_SERVER_SHELL_SSH_HOST}/${GIT_CLONE_PROJECT_PATH}.git ./temp_project
        cd ./temp_project
        if [ -f ./.gitmodules ]; then
          sed -ie "s/\(https:\/\/\).*\($CI_SERVER_SHELL_SSH_HOST\)/\1$CI_REGISTRY_USER:$CI_JOB_TOKEN@\2/g" ./.gitmodules
          git submodule sync --recursive && git submodule update --init --recursive;
        fi
        rm -rf ./.git && cd $CI_PROJECT_DIR
        mv ./temp_project/* ./temp_project/.[!.]* . || true
        rm -rf ./temp_project
      fi

.setup_environment:
  before_script:
    - echo -e "\e[0Ksection_start:`date +%s`:setup_environment_section\r\e[0KInstall dependencies"
    - |
      check_command() {
        if ! command -v "$1" >/dev/null 2>&1; then
          echo "⚠️ Warning: '$1' is not installed. Installing it now..."
          if [[ "$CI_RUNNER_TAGS" == *"$LINUX_RUNNER_TAG"* ]]; then
            apt-get update && apt-get install -y "$1"
          elif [[ "$CI_RUNNER_TAGS" == *"$MACOS_RUNNER_TAG"* ]]; then
            brew install "$1"
          else
            echo "❌ Error: Unsupported OS. CI_RUNNER_TAGS must contain '$LINUX_RUNNER_TAG' or '$MACOS_RUNNER_TAG'."
            exit 1
          fi
        else
          echo "✅ '$1' is already installed."
        fi
      }
      for dep in $(echo "$DEPENDENCIES_TO_INSTALL"); do
        check_command "$dep"
      done
    - echo -e "\e[0Ksection_end:`date +%s`:setup_environment_section\r\e[0K"

.extract_secure_files:
  before_script:
    - echo -e "\e[0Ksection_start:`date +%s`:extract_secure_files_section\r\e[0KExtract secure files"
    - curl -s https://gitlab.com/gitlab-org/incubation-engineering/mobile-devops/download-secure-files/-/raw/main/installer | bash
    - echo -e "\e[0Ksection_end:`date +%s`:extract_secure_files_section\r\e[0K"

.extract_dart_define:
  before_script:
    - echo -e "\e[0Ksection_start:`date +%s`:extract_dart_define_section\r\e[0KExtract dart define"
    - BRANCH_NAME=$(echo "$CI_COMMIT_REF_NAME" | tr '[:lower:]' '[:upper:]' | sed 's/[^a-zA-Z0-9]/_/g;')
    - DART_DEFINE_JSON_FOR_BRANCH="DART_DEFINE_JSON_${BRANCH_NAME}"
    - |-
      if [[ -n "${!DART_DEFINE_JSON_FOR_BRANCH}" ]]; then
        echo "⚠️ Warning: Using ${DART_DEFINE_JSON_FOR_BRANCH} for branch ${CI_COMMIT_BRANCH}."
        export DART_DEFINE_JSON="${!DART_DEFINE_JSON_FOR_BRANCH}"
      else
        echo "ℹ️ Info: Using default DART_DEFINE_JSON."
      fi
    - |-
      if [[ -n "$DART_DEFINE_JSON" ]]; then
        echo "$DART_DEFINE_JSON" > "$DART_DEFINE_PATH"
        if [[ -z "$DART_DEFINE_JSON_REQUIRED_KEYS" ]]; then
          echo "⚠️ Warning: DART_DEFINE_JSON_REQUIRED_KEYS is empty or not set. Skipping key validation."
        else
          MISSING_KEYS=$(echo "$DART_DEFINE_JSON_REQUIRED_KEYS" | jq -r --argjson DART_DEFINE_JSON "$DART_DEFINE_JSON" '
            .[] | select(. as $key | ($DART_DEFINE_JSON | has($key) | not))
          ' | paste -sd, -)
          if [[ -n "$MISSING_KEYS" ]]; then
            echo "❌ Error: Required environments are missing: $MISSING_KEYS";
            exit 1;
          fi
        fi
      else
        echo "ℹ️ Info: DART_DEFINE_JSON is empty or not set. Continuing without it."
      fi
    - echo -e "\e[0Ksection_end:`date +%s`:extract_dart_define_section\r\e[0K"

.configure_flutter:
  before_script:
    - echo -e "\e[0Ksection_start:`date +%s`:flutter_section\r\e[0KSetup Flutter SDK $FLUTTER_SDK_VERSION"
    - |
      if echo "$CI_RUNNER_TAGS" | grep -q "$LINUX_RUNNER_TAG"; then
        export PATH="$PATH:$HOME/.pub-cache/bin"
      elif echo "$CI_RUNNER_TAGS" | grep -q "$MACOS_RUNNER_TAG"; then
        if ! command -v fvm &> /dev/null; then
          echo "❌ Error: FVM is not installed. Please install FVM first."
          exit 1
        fi
        fvm install $FLUTTER_SDK_VERSION
        fvm use $FLUTTER_SDK_VERSION --force
        FVM_PATH="$HOME/fvm/versions/$FLUTTER_SDK_VERSION"
        export PATH="$PATH:$HOME/.pub-cache/bin"
        export PATH="$FVM_PATH/bin:$FVM_PATH/bin/cache/dart-sdk/bin:$FVM_PATH/.pub-cache/bin:$PATH"
        fvm doctor
      else
        echo "❌ Error: Unsupported OS. CI_RUNNER_TAGS must contain '$LINUX_RUNNER_TAG' or '$MACOS_RUNNER_TAG'."
        exit 1
      fi
    - flutter --version
    - echo -e "\e[0Ksection_end:`date +%s`:flutter_section\r\e[0K"

.configure_fastlane:
  before_script:
    - export LC_ALL="en_US.UTF-8"
    - export LANG="en_US.UTF-8"
    - |
      echo "ℹ️ Info: Set $SIGN_TYPE type for sign project"
    - export GYM_EXPORT_TEAM_ID="$FASTLANE_TEAM_ID"
    - export GYM_BUILDLOG_PATH="$CI_PROJECT_DIR/build_logs"
    - mkdir -p "$GYM_BUILDLOG_PATH"
    - |
      case "$SIGN_TYPE" in
        appstore) GYM_EXPORT_METHOD="app-store";;
        adhoc) GYM_EXPORT_METHOD="ad-hoc";;
        development) GYM_EXPORT_METHOD="development";;
        *) echo "❌ Error: Unsupported SIGN_TYPE: $SIGN_TYPE"; exit 1;;
      esac
      export GYM_EXPORT_METHOD
    - |
      export APP_STORE_CONNECT_ENABLED=$([[ \
        -n "$APP_STORE_CONNECT_API_KEY_KEY_ID" && \
        -n "$APP_STORE_CONNECT_API_KEY_KEY" && \
        -n "$APP_STORE_CONNECT_API_KEY_ISSUER_ID" ]] && \
        echo true || echo false)
    - |
      if $APP_STORE_CONNECT_ENABLED; then
        mkdir -p "$CI_PROJECT_DIR/.secure_files"
        export APP_STORE_CONNECT_API_KEY_PATH="$CI_PROJECT_DIR/.secure_files/$APP_STORE_CONNECT_API_KEY_KEY_ID.json"
        jq -n \
          --arg key_id "$APP_STORE_CONNECT_API_KEY_KEY_ID" \
          --arg key "$(echo "$APP_STORE_CONNECT_API_KEY_KEY" | base64 --decode)" \
          --arg issuer_id "$APP_STORE_CONNECT_API_KEY_ISSUER_ID" \
            '{
              key_id: $key_id,
              key: $key,
              issuer_id: $issuer_id,
              in_house: false
            }' > "$APP_STORE_CONNECT_API_KEY_PATH"
      else
        echo "⚠️ Warning: One or more required environment variables are missing for App Store Connect."
        echo "Please ensure the following variables are set: https://docs.gitlab.com/ee/user/project/integrations/apple_app_store.html"
        [ "$APP_STORE_CONNECT_REQUIRED" = "true" ] && exit 1
      fi
    - |
      export GOOGLE_PLAY_ENABLED=$([[ \
        -n "$SUPPLY_PACKAGE_NAME" && \
        -n "$SUPPLY_JSON_KEY_DATA" ]] && \
        echo true || echo false)
    - |
      if $GOOGLE_PLAY_ENABLED; then
        export SUPPLY_TRACK=${SUPPLY_TRACK:-alpha}
      else
        echo "⚠️ Warning: One or more required environment variables are missing for Google Play integration."
        echo "Please ensure the following variables are set: https://docs.gitlab.com/ee/user/project/integrations/google_play.html"
        [ "$GOOGLE_PLAY_REQUIRED" = "true" ] && exit 1
      fi
    - echo "✅ All required environment variables are set. Proceeding..."
    - bundle install

После этого коммитим и отправляем изменения:

git commit -a -m "Добавить шаблоны CI/CD для Android и iOS"
git tag v1.0.0
git push origin main v1.0.0

Далее в разделе SettingsCI/CDJob token permissionsAuthorized groups and projects нужно выбрать All groups and projects и нажать Save Changes, чтобы другие проекты могли получить доступ к этому репозиторию через CI/CD (см. ниже).

Flutter CI Templates. Settings → CI/CD → Job token permissions.
Flutter CI Templates. Settings → CI/CD → Job token permissions.

Как это будет выглядеть

GitLab CI/CD позволяет включать конфигурации из других репозиториев, поэтому ваш основной .gitlab-ci.yml сможет получить к ним доступ вот так:

include:
  - project: 'project-group/flutter-ci-templates'
    ref: v1.0.0
    file:
      - '/build/android.gitlab-ci.yml'
      - '/build/ios.gitlab-ci.yml'
      - '/common.gitlab-ci.yml'

В includeref может задаваться тег, название ветки или хэш коммита (см. подробнее). Такой подход особенно полезен в командах – все разработчики работают с единой версией скриптов, а не копируют их друг у друга. Этот подход: 

  1. Избавит от дублирования кода – один раз настраиваете шаблон и подключаете его в любом проекте. 

  2. Упростит поддержку – исправления и улучшения применяются сразу во всех проектах.

  3. Стандартизирует процесс – все сборки выполняются по единым правилам.

В третьей части статьи мы расширим репозиторий новыми шаблонами скриптов для доставки приложения в Google Play, TestFlight и Significa.

Подготовка Flutter проекта

В этом разделе мы подготовим Flutter проект, который будет работать с ранее созданным репозиторием, содержащим заготовленные нами шаблоны CI/CD.

Шаг 1. Настройка Fastlane

Fastlane – это мощный инструмент автоматизации, который помогает разработчикам мобильных приложений ускорить и упростить задачи, связанные с разработкой, тестированием, сборкой и публикацией.

Зачем нам Fastlane в проекте?

Мы интегрируем Fastlane, чтобы использовать его как готовый CLI инструмент для автоматизации самых сложных этапов, таких как управление подписью iOS и публикация в магазины приложений. 

Ключевое отличие нашего подхода в том, что мы будем вызывать команды Fastlane напрямую из терминала или CI-скриптов, полностью минуя этап написания и поддержки конфигурации в Fastfile.

Так мы получаем всю пользу от автоматизации критичных задач, но без сложностей, связанных с глубоким погружением в инструмент.

Что нужно сделать?

В предыдущей статье мы уже настроили физическую машину так, чтобы минимизировать зависимости и упростить управление версиями Fastlane. Теперь пришло время подготовить наш проект. Для этого в корне Flutter проекта необходимо создать файл с именем Gemfile со следующим содержимым (актуальную версию Fastlane можно проверить здесь):

Gemfile
source "https://rubygems.org"

gem "fastlane", "~> 2.228.0"
gem "abbrev"
gem "ostruct"

Теперь все команды Fastlane мы сможем запускать через Bundler, например:

bundle exec fastlane <команда>

Такой подход гарантирует, что используется именно та версия Fastlane, которая указана в Gemfile. Это особенно важно, когда на одной машине одновременно собираются несколько проектов.

Шаг 2. Настройка .gitlab-ci.yml

Сначала создаем конфигурационный файл .gitlab-ci.yml в корневой директории Flutter проекта. Этот файл определяет:

  1. Этапы пайплайна (например, сборку, тестирование, деплой).

  2. Переменные окружения, используемые в процессе выполнения.

  3. Сценарии выполнения (какие команды запускать на каждом этапе).

Рассмотрим его структуру и ключевые настройки:

.gitlab.ci.yml
stages:
  - build

include:
  - project: 'project-group/flutter-ci-templates'
    ref: v1.0.0
    file:
      - '/build/android.gitlab-ci.yml'
      - '/build/ios.gitlab-ci.yml'
      - '/common.gitlab-ci.yml'

variables:
  BUILD_AAB_ARCHIVE: false
  BUILD_AAB_FILE_NAME: "app-$BUILD_TYPE.aab"
  BUILD_AAB_FILE_PATH: "$CI_PROJECT_DIR/build/$BUILD_AAB_FILE_NAME"
  BUILD_APK_FILE_NAME: "app-$BUILD_TYPE.apk"
  BUILD_APK_FILE_PATH: "$CI_PROJECT_DIR/build/$BUILD_APK_FILE_NAME"
  BUILD_IPA_FILE_NAME: "Runner.ipa"
  BUILD_IPA_FILE_PATH: "$CI_PROJECT_DIR/build/$BUILD_IPA_FILE_NAME"
  BUILD_TYPE: release
  DART_DEFINE_JSON_REQUIRED_KEYS: '[
    "ENV_EXAMPLE_1",
    "ENV_EXAMPLE_2"
  ]'
  DART_DEFINE_PATH: "./launch.json"
  FLUTTER_SDK_VERSION: 3.35.2
  KEYCHAIN_NAME: "${CI_PROJECT_NAME}-${CI_COMMIT_REF_SLUG}.keychain-db"
  LINUX_RUNNER_TAG: linux-runner-tag-name
  MACOS_RUNNER_TAG: macos-runner-tag-name
  CI_BRANCHES: "/^(dev|release|master|main)$/"
  PROJECT_ENTRY_POINT: "./lib/main.dart"
  SIGN_TYPE: "adhoc"

default:
  interruptible: true

.common_rules: &common_rules
  rules:
    - if: $CI_MERGE_REQUEST_IID
    - if: $CI_COMMIT_BRANCH =~ $CI_BRANCHES

.build_project: &build_project
  - echo -e "\e[0Ksection_start:`date +%s`:build_project_section[collapsed=true]\r\e[0KBuild project"
  - flutter pub get
  - dart run build_runner build --delete-conflicting-outputs
  - echo -e "\e[0Ksection_end:`date +%s`:build_project_section\r\e[0K"

build_android:
  image: ghcr.io/cirruslabs/flutter:$FLUTTER_SDK_VERSION
  stage: build
  needs: [ ]
  script:
    - echo -e "\e[0Ksection_start:`date +%s`:build_app_section\r\e[0KBuild & sign app in $BUILD_TYPE mode"
    - *build_project
    - !reference [ .build_android, script ]
    - echo -e "\e[0Ksection_end:`date +%s`:build_app_section\r\e[0K"
  <<: *common_rules
  when: manual
  allow_failure: false
  artifacts:
    name: "android-$CI_COMMIT_REF_SLUG"
    expire_in: 1 week
    paths:
      - $BUILD_APK_FILE_PATH
      - $BUILD_AAB_FILE_PATH
  extends:
    - .before_script:build_android
    - .variables:build_android
  tags:
    - $MACOS_RUNNER_TAG

build_ios:
  stage: build
  needs: [ ]
  script:
    - *build_project
    - !reference [ .build_ios, script ]
  <<: *common_rules
  when: manual
  allow_failure: false
  artifacts:
    name: "ios-$CI_COMMIT_REF_SLUG"
    expire_in: 1 week
    paths:
      - $BUILD_IPA_FILE_PATH
  extends:
    - .before_script:build_ios
    - .after_script:build_ios
  tags:
    - $MACOS_RUNNER_TAG

Настройка переменных окружений

Для управления процессом сборки настройте следующие переменные окружения под ваши требования:

Название

Описание

BUILD_AAB_ARCHIVE

Определяет, нужно ли собирать Android App Bundle (AAB) в дополнение к APK.

BUILD_AAB_FILE_NAME

Имя итогового AAB-файла (Android App Bundle) после сборки.

BUILD_AAB_FILE_PATH

Полный путь до папки с AAB-файлом.

BUILD_APK_FILE_NAME

Имя итогового APK-файла (Android Package) после сборки.

BUILD_APK_FILE_PATH

Полный путь до папки с APK-файлом.

BUILD_IPA_FILE_NAME

Имя итогового IPA-файла после сборки.

BUILD_IPA_FILE_PATH

Полный путь до папки с IPA файлом.

BUILD_TYPE

Тип сборки проекта. Возможные значения: debug, release.

DART_DEFINE_JSON_REQUIRED_KEYS

Список ключей переменных окружений, которые обязательно должны быть переданы во время сборки приложения.

DART_DEFINE_PATH

Путь, по которому будет формироваться временный файл с переменными окружения из DART_DEFINE_JSON (см. подробнее в следующем разделе).

FLUTTER_SDK_VERSION

Версия Flutter SDK, используемая для сборки.

KEYCHAIN_NAME

Имя для создания временной связки ключей для импортирования iOSPKCS#12.

LINUX_RUNNER_TAG

Опционально. Имя тега для использования раннера на машине под управлением Linux. Можно использовать в build_android и других процессах, которые не требуют выполнения команд на macOS.

MACOS_RUNNER_TAG

Имя тега для использования раннера на машине под управлением macOS (как создать Gitlab Runner см. в предыдущей части статьи).

CI_BRANCHES

Список веток, для которых будет доступен запуск пайплайнов.

PROJECT_ENTRY_POINT

Путь к главному файлу для запуска приложения.

SIGN_TYPE

Режим подписи iOS приложения: adhoc, development, appstore.

Примечание: эти ключевые переменные явно прописаны в .gitlab-ci.yml, чтобы гарантировать прозрачность изменений в системе контроля версий.

Настройка правил запуска пайплайнов

Чтобы избежать бесполезных запусков, мы должны настроить триггеры для пайплайна. Якорь .common_rules в файле .gitlab-ci.yml определяет, когда пайплайн будет выполняться: 

  1. Для код-ревью: при создании или обновлении Merge Request.

  2. Для сборки релиза: при коммите в одну из ключевых веток (список задается в переменной CI_BRANCHES).

.common_rules: &common_rules
  rules:
    - if: $CI_MERGE_REQUEST_IID
    - if: $CI_COMMIT_BRANCH =~ $CI_BRANCHES

В дальнейшем эти правила можно изменить или дополнить под свои потребности.

Настройка этапов сборки проекта

Каждый Flutter проект требует индивидуального подхода к сборке. К счастью, якорь .build_project в .gitlab-ci.yml это предусматривает и дает нам некоторую гибкость при настройке шагов:

.build_project: &build_project
  - echo -e "\e[0Ksection_start:`date +%s`:build_project_section[collapsed=true]\r\e[0KBuild project"
  - flutter pub get
  - dart run build_runner build --delete-conflicting-outputs
  - echo -e "\e[0Ksection_end:`date +%s`:build_project_section\r\e[0K"

В приведенном шаблоне заложена базовая логика загрузки зависимостей и запуска кодогенерации, так что нам остается только оставить нужные команды или добавить свои, чтобы по окончании их выполнения приложение было готово к работе.

Шаг 3. Загрузка ключей и сертификатов в Secure Files

Для подписания мобильных приложений необходимо загрузить ключи в GitLab Secure Files. Сделать это можно через веб-интерфейс Flutter проекта, перейдя в SettingsCI/CDSecure FilesUpload File.

Flutter project. Settings → CI/CD → Secure files.
Flutter project. Settings → CI/CD → Secure files.

Android: добавление Keystore

Загрузите файл подписи (*.keystore или *.jks). Название файла может быть любым – скрипт автоматически определит его по расширению во время сборки.

iOS: добавление сертификата (PKCS#12)

Для подписи iOS-приложения загрузите файл *.p12, содержащий закрытый ключ и сертификат. В зависимости от цели сборки (AdHoc, AppStore или Development) используйте соответствующий сертификат.

Важно: Название файла должно быть простым и осмысленным – оно напрямую влияет на имя переменной окружения, в которой будет храниться пароль. Например, для файла release.p12 переменная будет называться CERT_PASSWORD_RELEASE.

iOS: управление Provisioning Profiles

Для автоматизации процессов сборки и публикации iOS-приложений через GitLab CI/CD необходимо правильно настроить интеграцию с Apple Developer Account и App Store Connect. В зависимости от уровня доступа к аккаунту разработчика можно выбрать один из двух подходов:

  1. Интеграция с App Store Connect (рекомендуемый способ).

  2. Ручная загрузка Provisioning Profiles (если доступ ограничен).

Если у вас есть полный доступ к Apple Developer Account, настройте интеграцию на уровне Flutter проекта GitLab

  1. Перейдите в SettingsIntegrationsApple App Store Connect.

  2. Следуйте официальной документации для настройки подключения.

Flutter project. Settings → Integrations → Apple App Store Connect.
Flutter project. Settings → Integrations → Apple App Store Connect.

После успешной интеграции вы сможете:

  1. Управлять Provisioning Profiles (создавать, загружать и обновлять их автоматически).

  2. Регистрировать тестовые устройства для установки приложения.

  3. Загружать сборки в TestFlight напрямую из CI/CD-пайплайна. 

Этот способ избавляет от ручного управления профилями, тем самым минимизируя ошибки при сборке. Однако, если у вас нет полного доступа к Apple Developer Account, можно загрузить Provisioning Profiles вручную: 

  1. Добавьте файлы профилей (.mobileprovision) в Secure Files проекта GitLab.  

  2. При сборке GitLab автоматически импортирует их и будет использовать для подписи приложения. 

Ограничения этого подхода:

  1. Невозможно автоматически регистрировать новые тестовые устройства – при добавлении устройства через портал разработчика придется вручную обновлять профили в Secure Files.

  2. Требуется следить за актуальностью профилей обеспечения на портале разработчиков и своевременно обновлять их.

Шаг 4. Настройка глобальных переменных окружения

На этом шаге мы добавим и настроим переменные, содержащие конфиденциальные данные (ключи, пароли и т.д.). В отличие от обычных переменных, которые хранятся прямо в .gitlab-ci.yml, эти переменные мы зададим на уровне проекта в интерфейсе GitLab, где для таких переменных есть функции сокрытия и маскировки.

Название

Тип

Описание

ANDROID_KEY_ALIAS

Переменная

Алиас ключа для подписи Android приложения.

ANDROID_KEY_PASSWORD

Переменная

Пароль от ключа для подписи Android приложения.

ANDROID_KEYSTORE_PASSWORD

Переменная

Пароль от ключа хранилища для подписи Android приложения.

APPLE_DEVICES_FILE

Файл

Список устройств для регистрации на портале Apple Developer (указывается в текстовом формате, пример).

CERT_PASSWORD_*

Переменная

Пароль от загруженного сертификата iOS. Вместо * необходимо подставить имя загруженного сертификата в раздел Secure Files на уровне проекта.

DART_DEFINE_JSON

Переменная

Опционально. Переменные окружения Flutter приложения в формате JSON. По умолчанию применяется ко всем веткам. Для переопределения конфигурации в конкретной ветке создайте переменную DART_DEFINE_JSON_*. Например, для ветки dev используйте DART_DEFINE_JSON_DEV.

FASTLANE_TEAM_ID

Переменная

Идентификатор команды разработчика на портале Apple Developer (в разделе Membership details).

KEYCHAIN_PASSWORD

Переменная

Пароль для автоматически генерируемой связки ключей, в которую будут импортироваться сертификаты iOS. Рекомендуется использовать заглавные, строчные буквы, цифры, дефисы.

TARGET_BUNDLE_MAPPING

Переменная

Сопоставление iOS Target проекта к его Bundle ID и Profile Name в формате JSON (подробнее см. в следующем шаге).

APP_GROUP_MAPPING

Переменная

Опционально. Позволяет переназначить идентификаторы App Groups, чтобы они соответствовали новым Bundle ID (подробнее см. в следующем шаге). 

Как добавить глобальную переменную?

  1. Переходим в настройки Flutter проекта: SettingsCI/CDVariables.

  2. Нажимаем Add variable и заполняем следующие поля: Type, Key, Value, Options (можно отметить Masked – если нужно скрыть значение в логах или Masked and hidden для полного скрытия).

  3. В поле Flags снимаем галочку с пункта Expand variable reference.

Flutter project. Settings → CI/CD → CI/CD Variables.
Flutter project. Settings → CI/CD → CI/CD Variables.

Важно: этот раздел доступен пользователям с ролью Maintainer и выше.

Шаг 5. Настройка подписи для нескольких целей (Targets)

Современные iOS-приложения часто состоят из нескольких компонентов: основного приложения и расширений (например, Widget, Notification Service Extension и т.д.). Каждый из этих компонентов – отдельная цель (Target) в Xcode и требует:

  1. Свой уникальный идентификатор (bundle id).

  2. Свой профиль обеспечения (profile provisioning).

Чтобы автоматизировать подпись для всех целей и гарантировать, что каждый компонент будет связан с правильным профилем, следует настроить переменную TARGET_BUNDLE_MAPPING

Формат переменной – это JSON-массив, где каждый объект содержит:

  • target_name: имя цели в Xcode;

  • bundle_id: идентификатор приложения;

  • profile_name (опционально): имя профиля для использования;

  • bundle_id_override (опционально): новый идентификатор приложения.

Важно: все идентификаторы приложения должны быть заранее созданы на Apple Developer Account (в разделе Identifiers).

Базовая конфигурация

Укажите связь между целью (target_name) и идентификатором приложения (bundle_id):

[
    {
        "bundle_id": "com.example.myapp",
        "target_name": "Runner"
    },
    {
        "bundle_id": "com.example.myapp.notifications",
        "target_name": "NotificationServiceExtension"
    }
]

Обычно этого достаточно, если имена ваших профилей следуют стандартной маске Fastlane, например:

  • com.example.myapp AdHoc соответствует режиму adhoc;

  • com.example.myapp Development соответствует режиму development;

  • com.example.myapp AppStore соответствует режиму appstore.

Если включена интеграция с App Store Connect, то Fastlane сам создаст необходимые профили с нужным типом из переменной SIGN_TYPE.

Конфигурация с кастомными именами provisioning profiles

Используйте поле profile_name для явного указания названия профиля, созданного на портале Apple Developer:

[
    {
        "bundle_id": "com.example.myapp",
        "target_name": "Runner",
        "profile_name": "My App AdHoc"
    },
    {
        "bundle_id": "com.example.myapp.notifications",
        "target_name": "NotificationServiceExtension",
        "profile_name": "My App Notifications AdHoc"
    }
]

Примечание: может быть полезно, если профиль обеспечения уже ранее был кем-то создан и необходимо использовать именно его.

Конфигурация с переопределением Bundle ID

Используйте поле bundle_id_override, чтобы подписать приложение с идентификатором, отличным от указанного в проекте:

[
    {
        "bundle_id": "com.example.myapp",
        "bundle_id_override": "com.example2.myapp",
        "target_name": "Runner"
    },
    {
        "bundle_id": "com.example.myapp.notifications",
        "bundle_id_override": "com.example2.myapp.notifications",
        "target_name": "NotificationServiceExtension"
    }
]

Примечание: полезно для подписания сборок под другим аккаунтом «на лету» без необходимости вносить изменения в сам проект.

Стоит учитывать, что при переопределении Bundle ID связанные с ним App Groups не изменяются автоматически. Чтобы задать новые имена для групп приложений, укажите соответствующие значения в APP_GROUP_MAPPING

Формат переменной – это JSON-массив, где каждый объект определяет:

  • entitlements_file: путь к файлу с расширением .entitlements;

  • app_group_identifiers: новый массив идентификаторов App Group.

[
  {
    "entitlements_file": "Runner/Runner.entitlements",
    "app_group_identifiers": ["com.example2.myapp", "com.example2.myapp.notifications"]
  }
]

Примечание: Во время сборки скрипт заменит содержимое ключа com.apple.security.application-groups в указанных файлах на значения из app_group_identifiers.

Результаты автоматизации

Как только все изменения закоммичены и запушены в репозиторий, а также выполнены предварительные условия (загрузка сертификатов, ключей и настройка необходимых переменных окружения), Gitlab CI/CD активирует автоматическую проверку каждого пуша в ветку репозитория. 

В интерфейсе Merge Requests и на ветках, определенных в переменной CI_BRANCHES, отобразится индикатор выполнения пайплайна, который обеспечивает визуализацию статуса и хода выполнения задач (см. ниже).

Flutter project. Merge Request.
Flutter project. Merge Request.

Для запуска сборки под конкретную платформу вручную запускаем соответствующие задачи:

  1. Находим этап build в пайплайне.

  2. Жмем на иконку-шестеренку рядом с ним.

  3. В выпадающем меню выбираем build_android и/или build_ios.

После успешного выполнения пайплайна собранные билды (APK для Android и IPA для iOS) будут сохранены и доступны для скачивания в интерфейсе GitLab в качестве артефактов (см. ниже):

Flutter project. Merge Request (download artifacts).
Flutter project. Merge Request (download artifacts).

Таким образом, вы быстро получите готовые сборки для тестирования прямо из интерфейса GitLab.

Заключение

В этой статье мы реализовали ключевой этап CI/CD – автоматическую сборку и подписание Android (APK/AAB) и iOS (IPA) приложений с помощью GitLab CI. Это фундаментальный шаг, который позволяет не просто экономить время и минимизировать человеческие ошибки, но и выстроить надежный, предсказуемый и повторяемый процесс подготовки релизов.

Следующий логичный этап – доставка приложений на платформы, такие как Google Play, TestFlight и Significa. В следующей статье мы разберем, как настроить автоматический деплой, чтобы билды попадали к QA-инженерам или пользователям буквально одним коммитом, замыкая полный цикл CI/CD.

Теги:
Хабы:
+2
Комментарии0

Публикации

Ближайшие события