Flutter & GitLab CI/CD. Доставка мобильного приложения в Significa, TestFlight и Google Play

Привет! Меня зовут Данил Абдрафиков, я мобильный разработчик в компании TAGES. После успешной настройки сборки и подписания Flutter-приложений во второй части, остался последний, но не менее важный этап — дистрибуция приложения.

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

Введение

Когда речь заходит о «доставке» мобильного приложения, большинство думает только о публикации в App Store или Google Play. На практике же доставка — это не одно действие, а набор процессов, которые зависят от разных задач: нужно ли выкатить сборку коллегам внутри компании или же отправить релиз миллионам пользователей. В первом случае важны скорость, простота и доступность сборок, во втором — изолированность, прозрачный процесс и контроль релизов.

Чтобы достичь этой цели, мы охватим следующие темы:

  1. Подготовка сервера дистрибуции Significa.

  2. Добавление скриптов деплоя в репозиторий с шаблонами.

  3. Деплой приложения в Significa.

  4. Деплой приложения в Google Play и TestFlight.

Все подробно разберем на примерах и готовых конфигурациях.

Подготовка сервера дистрибуции Significa

Significa App Distribution Server — это минималистичный сервер для внутренней дистрибуции мобильных сборок (.ipa и .apk). Его преимущество в том, что он решает одну конкретную задачу максимально просто: принимает готовые артефакты, хранит их и выдает простую ссылку/QR‑код для быстрой установки на устройстве.

Если хотите сильнее погрузиться в эту тему, то у проекта есть подробный материал с описанием данного инструмента и пошаговым разбором использования. Важно понимать, что это не замена дистрибуции приложения через TestFlight/Google Play, а легкий инструмент для оперативной доставки мобильных приложений из CI (подходит для Ad‑hoc/Enterprise сценариев).

В рамках статьи мы не будем углубляться в подробности настройки и развертывания самого сервера Significa — это хорошо описано в официальной документации. Вашим результатом на этом этапе должен стать запущенный и доступный экземпляр Significa, в который мы далее настроим автоматическую отправку сборок из GitLab CI.

Добавление скриптов деплоя в репозиторий с шаблонами

В прошлой части статьи мы уже создали децентрализованный репозиторий с общими скриптами сборки, следующий шаг — добавить скрипты для доставки артефактов в Significa, TestFlight и Google Play. Для этого создадим папку deploy и поместим туда следующие файлы:

.
│ └── deploy
│     └── google_play.gitlab-ci.yml
│     └── significa.gitlab-ci.yml
│     └── testflight.gitlab-ci.yml
.

./deploy/google_play.gitlab-ci.yml

Этот файл помогает автоматизировать отправку AAB-файла в Google Play, загружая его в указанную группу распространения.

google_play.gitlab-ci.yml
include:
  - local: '/common.gitlab-ci.yml'

.supply_track:
  script:
    - bundle exec fastlane run supply track:"$SUPPLY_TRACK" aab:"$BUILD_AAB_FILE_PATH"

.variables:deploy_google_play:
  variables:
    GOOGLE_PLAY_REQUIRED: true

.before_script:deploy_google_play:
  before_script:
    - !reference [ .clone_project, before_script ]
    - !reference [ .configure_fastlane, before_script ]

.script:deploy_google_play:
  script:
    - !reference [ .supply_track, script ]

./deploy/significa.gitlab-ci.yml

Данный файл автоматизирует выгрузку артефакта на сервер Significa с целью внутренней дистрибуции и выдачи ссылки для установки:

significa.gitlab-ci.yml
include:
  - local: '/common.gitlab-ci.yml'

.configure_significa:
  before_script:
    - export SIGNIFICA_ENABLED=$([ -n "$SIGNIFICA_SERVER_URL" ] && [ -n "$SIGNIFICA_SECRET" ] && echo "true" || echo "false")
    - |
      if ! $SIGNIFICA_ENABLED; then
        echo "❌️ Error: App distribution server is disabled. Please specify the required variables: SIGNIFICA_SERVER_URL, SIGNIFICA_SECRET"
        echo "Read more: https://github.com/significa/app-distribution-server"
        exit 1
      fi

.upload_to_significa:
  script:
    - echo -e "\e[0Ksection_start:`date +%s`:upload_to_significa_section\r\e[0KDistribute app via Significa"
    - |
      i=1
      while [ $i -le "$SIGNIFICA_UPLOAD_MAX_RETRIES" ]; do
        echo "ℹ️ Attempt $i to upload to Significa..."
        BUILD=$(curl -X "POST" \
          "$SIGNIFICA_SERVER_URL/api/upload" \
          -H "Accept: application/json" \
          -H "X-Auth-Token: $SIGNIFICA_SECRET" \
          -H "Content-Type: multipart/form-data" \
          -F "app_file=@$APP_FILE")
        if echo "$BUILD" | jq empty >/dev/null 2>&1; then
          BUNDLE_VERSION=$(echo "$BUILD" | jq -r '.bundle_version // empty')
          UPLOAD_ID=$(echo "$BUILD" | jq -r '.upload_id // empty')
          PLATFORM_NAME=$(echo "$BUILD" | jq -r '.platform // empty')
        else
          echo "⚠️ Warning: Received invalid JSON response or no response at all:"
          echo "$BUILD"
        fi
        if [[ -n "$BUNDLE_VERSION" && -n "$UPLOAD_ID" && -n "$PLATFORM_NAME" ]]; then
          echo "✅ Upload successful!"
          break
        else
          echo "⚠️ Warning: Upload failed. Missing required fields. Retrying in $SIGNIFICA_UPLOAD_RETRY_DELAY seconds..."
          sleep "$SIGNIFICA_UPLOAD_RETRY_DELAY"
        fi
        i=$((i + 1))
      done
    - |
      if [ -z "$BUNDLE_VERSION" ] || [ -z "$UPLOAD_ID" ] || [ -z "$PLATFORM_NAME" ]; then
        echo "❌ Error: All attempts to upload to Significa failed."
        exit 1
      fi
    - |
      echo "ℹ️ Info: Distribute app version $BUNDLE_VERSION ($UPLOAD_ID)"
    - echo "UPLOAD_ID=$UPLOAD_ID" > "$DEPLOY_ENV_PATH"
    - |
      if [[ -n "$CI_PROJECT_TOKEN" ]]; then
        if [ -z "${!SIGNIFICA_UPLOADS_VAR}" ]; then
          export ${SIGNIFICA_UPLOADS_VAR}='{}'
        fi
        SIGNIFICA_UPLOADS_NEW_VALUE=$(echo "${!SIGNIFICA_UPLOADS_VAR}" | jq --arg branch "$CI_COMMIT_REF_SLUG" --arg upload_id "$UPLOAD_ID" '
          .[$branch] = (.[$branch] // []) + [$upload_id]
        ')
        eval "export $SIGNIFICA_UPLOADS_VAR='$SIGNIFICA_UPLOADS_NEW_VALUE'"
        echo "✅ Updated ${SIGNIFICA_UPLOADS_VAR}: ${!SIGNIFICA_UPLOADS_VAR}"
        curl --request POST --header "PRIVATE-TOKEN: $CI_PROJECT_TOKEN" \
          "$CI_API_V4_URL/projects/$CI_PROJECT_ID/variables" \
          --form "key=$SIGNIFICA_UPLOADS_VAR" \
          --form "value=${!SIGNIFICA_UPLOADS_VAR}" \
          --form "description=These upload IDs are used to manage files, particularly for deletion or cleanup operations. Variable auto synced." \
          --form "raw=true"
        curl --request PUT --header "PRIVATE-TOKEN: $CI_PROJECT_TOKEN" \
          "$CI_API_V4_URL/projects/$CI_PROJECT_ID/variables/$SIGNIFICA_UPLOADS_VAR" \
          --form "value=${!SIGNIFICA_UPLOADS_VAR}"
      else
        echo "⚠️ Warning: The `CI_PROJECT_TOKEN` variable is required to enable automatic cleanup of builds in Significa."
        echo "If this variable is not set, builds will not be deleted automatically when environment stopped."
      fi
    - echo -e "\e[0Ksection_end:`date +%s`:upload_to_significa_section\r\e[0K"

.cleanup_significa:
  script:
    - echo -e "\e[0Ksection_start:`date +%s`:cleanup_significa_section\r\e[0KCleaning up Significa resources"
    - |
      if [[ -z "$CI_PROJECT_TOKEN" ]]; then
        echo "⚠️ Warning: The `CI_PROJECT_TOKEN` variable is required to enable automatic cleanup of builds in Significa."
        echo "If this variable is not set, builds will not be deleted automatically when environment stopped."
        exit 1
      fi
    - |
      if [ -z "${!SIGNIFICA_UPLOADS_VAR}" ]; then
        export ${SIGNIFICA_UPLOADS_VAR}='{}'
      fi
      UPLOAD_IDS=$(echo "${!SIGNIFICA_UPLOADS_VAR}" | jq -r --arg branch "$CI_COMMIT_REF_SLUG" '.[$branch] // []')
    - |
      if [ "$(echo "$UPLOAD_IDS" | jq length)" -gt 0 ]; then
        echo "ℹ️ Info: Found uploads to delete: $UPLOAD_IDS"
        for UPLOAD_ID in $(echo "$UPLOAD_IDS" | jq -r '.[]'); do
          echo "ℹ️ Info: Deleting upload $UPLOAD_ID"
          curl -X "DELETE" \
            "$SIGNIFICA_SERVER_URL/api/delete/$UPLOAD_ID" \
            -H "Accept: application/json" \
            -H "X-Auth-Token: $SIGNIFICA_SECRET"
        done
      else
        echo "ℹ️ Info: No uploads found for this branch."
        exit 0
      fi
    - |
      eval "SIGNIFICA_UPLOADS_VALUE=\${$SIGNIFICA_UPLOADS_VAR}"
      SIGNIFICA_UPLOADS_VALUE=$(echo "$SIGNIFICA_UPLOADS_VALUE" | jq --arg branch "$CI_COMMIT_REF_SLUG" 'del(.[$branch])')
      if ! curl --request PUT --header "PRIVATE-TOKEN: $CI_PROJECT_TOKEN" \
        "$CI_API_V4_URL/projects/$CI_PROJECT_ID/variables/$SIGNIFICA_UPLOADS_VAR" \
        --form "value=$SIGNIFICA_UPLOADS_VALUE"; then
        echo "❌ Failed to update variable $SIGNIFICA_UPLOADS_VAR"
        exit 1
      fi
    - echo -e "\e[0Ksection_end:`date +%s`:cleanup_significa_section\r\e[0K"

# Deploy android

.variables:deploy_android:
  variables:
    APP_FILE: $BUILD_APK_FILE_PATH
    DEPENDENCIES_TO_INSTALL: "curl jq"
    DEPLOY_ENV_PATH: "./deploy.env"
    SIGNIFICA_UPLOAD_MAX_RETRIES: 3
    SIGNIFICA_UPLOAD_RETRY_DELAY: 10
    SIGNIFICA_UPLOADS_VAR: "SIGNIFICA_ANDROID_UPLOADS"

.variables:cleanup_android:
  variables:
    DEPENDENCIES_TO_INSTALL: "curl jq"
    SIGNIFICA_UPLOADS_VAR: "SIGNIFICA_ANDROID_UPLOADS"

.before_script:deploy_android:
  before_script:
    - !reference [ .setup_environment, before_script ]
    - !reference [ .configure_significa, before_script ]

.script:deploy_android:
  script:
    - !reference [ .upload_to_significa, script ]

.before_script:cleanup_android:
  before_script:
    - !reference [ .setup_environment, before_script ]
    - !reference [ .configure_significa, before_script ]

.script:cleanup_android:
  script:
    - !reference [ .cleanup_significa, script ]

# Deploy iOS

.variables:deploy_ios:
  variables:
    APP_FILE: $BUILD_IPA_FILE_PATH
    DEPENDENCIES_TO_INSTALL: "curl jq"
    DEPLOY_ENV_PATH: "./deploy.env"
    SIGNIFICA_UPLOAD_MAX_RETRIES: 3
    SIGNIFICA_UPLOAD_RETRY_DELAY: 10
    SIGNIFICA_UPLOADS_VAR: "SIGNIFICA_IOS_UPLOADS"

.variables:cleanup_ios:
  variables:
    DEPENDENCIES_TO_INSTALL: "curl jq"
    SIGNIFICA_UPLOADS_VAR: "SIGNIFICA_IOS_UPLOADS"

.before_script:deploy_ios:
  before_script:
    - !reference [ .setup_environment, before_script ]
    - !reference [ .configure_significa, before_script ]

.script:deploy_ios:
  script:
    - !reference [ .upload_to_significa, script ]

.before_script:cleanup_ios:
  before_script:
    - !reference [ .setup_environment, before_script ]
    - !reference [ .configure_significa, before_script ]

.script:cleanup_ios:
  script:
    - !reference [ .cleanup_significa, script ]

./deploy/testflight.gitlab-ci.yml

Этот файл автоматизирует загрузку iOS‑сборки в App Store Connect и ее публикацию в TestFlight:

testflight.gitlab-ci.yml
include:
  - local: '/common.gitlab-ci.yml'

.upload_to_testflight:
  script:
    - |
      bundle exec fastlane run upload_to_testflight \
        api_key_path:"$APP_STORE_CONNECT_API_KEY_PATH" \
        ipa:"$BUILD_IPA_FILE_PATH" \
        skip_submission:true \
        skip_waiting_for_build_processing:true

.variables:deploy_testflight:
  variables:
    APP_STORE_CONNECT_REQUIRED: true

.before_script:deploy_testflight:
  before_script:
    - !reference [ .clone_project, before_script ]
    - !reference [ .configure_fastlane, before_script ]

.script:deploy_testflight:
  script:
    - !reference [ .upload_to_testflight, script ]

После этого коммитим, создаем новый тег и пушим изменения в удаленный репозиторий:

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

Деплой приложения в Significa

Мы уже на полпути к полной автоматизации! Осталось научить наш CI/CD-пайплайн не только собирать приложение, но и автоматически загружать его на наш сервер дистрибуции Significa. Для этого нужно выполнить всего два ключевых шага.

Шаг 1. Добавить секреты в GitLab

Чтобы GitLab Runner мог безопасно общаться с нашим сервером Significa, нам нужно добавить в проект три переменные окружения. Сделать это можно в настройках проекта: SettingsCI/CDVariables.

Название

Тип

Описание

CI_PROJECT_TOKEN

Переменная

Токен проекта GitLab с правами API (Settings → Access tokens → Add new token). Нужен для управления сборками после загрузки.

SIGNIFICA_SECRET

Переменная

Секретный ключ для доступа к Significa API.

SIGNIFICA_SERVER_URL

Переменная

Базовый адрес сервера Significa (например, https://example.com).

Зачем нужен CI_PROJECT_TOKEN?

Это «умная» часть автоматизации. После успешной загрузки сборки наш скрипт сохранит ее уникальный ID из Significa в переменную проекта GitLab. Это позвол��т позже, при закрытии Merge Request или удалении ветки, автоматически удалить устаревшую сборку с сервера дистрибуции.

Шаг 2. Подключить новые шаги деплоя в .gitlab-ci.yml

Теперь нужно расширить конфигурацию, использовав новые шаблоны для деплоя. Обновляем файл .gitlab-ci.yml нашего Flutter-проекта:

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

deploy_android:
  image: node:$NODE_VERSION
  stage: deploy
  needs:
    - build_android
  <<: *common_rules
  artifacts:
    reports:
      dotenv: $DEPLOY_ENV_PATH
  extends:
    - .before_script:deploy_android
    - .script:deploy_android
    - .variables:deploy_android
  environment:
    name: android/$CI_COMMIT_REF_SLUG
    url: $SIGNIFICA_SERVER_URL/get/$UPLOAD_ID
    on_stop: cleanup_android
    auto_stop_in: 1 month
  tags:
    - $MACOS_RUNNER_TAG

cleanup_android:
  image: node:$NODE_VERSION
  stage: cleanup
  needs: [ ]
  rules:
    - if: |
        $CI_PROJECT_TOKEN != "" &&
        ($CI_MERGE_REQUEST_IID || $CI_COMMIT_BRANCH =~ $CI_BRANCHES)
      when: manual
      allow_failure: true
    - when: never
  extends:
    - .before_script:cleanup_android
    - .script:cleanup_android
    - .variables:cleanup_android
  environment:
    name: android/$CI_COMMIT_REF_SLUG
    action: stop
  tags:
    - $MACOS_RUNNER_TAG

deploy_ios:
  image: node:$NODE_VERSION
  stage: deploy
  needs:
    - build_ios
  rules:
    - if: $CI_MERGE_REQUEST_IID || $CI_COMMIT_BRANCH =~ $CI_BRANCHES
      when: on_success
    - when: never
  extends:
    - .before_script:deploy_ios
    - .script:deploy_ios
    - .variables:deploy_ios
  artifacts:
    reports:
      dotenv: $DEPLOY_ENV_PATH
  environment:
    name: ios/$CI_COMMIT_REF_SLUG
    url: $SIGNIFICA_SERVER_URL/get/$UPLOAD_ID
    on_stop: cleanup_ios
    auto_stop_in: 1 month
  tags:
    - $MACOS_RUNNER_TAG

cleanup_ios:
  image: node:$NODE_VERSION
  stage: cleanup
  needs: [ ]
  rules:
    - if: |
        $CI_PROJECT_TOKEN != "" &&
        ($CI_MERGE_REQUEST_IID || $CI_COMMIT_BRANCH =~ $CI_BRANCHES)
      when: manual
      allow_failure: true
    - when: never
  extends:
    - .before_script:cleanup_ios
    - .script:cleanup_ios
    - .variables:cleanup_ios
  environment:
    name: ios/$CI_COMMIT_REF_SLUG
    action: stop
  tags:
    - $MACOS_RUNNER_TAG

Далее коммитим и пушим новые изменения в удаленный репозиторий, создаем Merge Request и смотрим на результат выполненной работы.

Шаг 3. Проверить интеграцию

Автоматический деплой после успешной сборки

В пайплайне появятся две новые ключевые задачи: deploy_android и deploy_ios. Они будут автоматически запускаться после успешной сборки, загружать артефакт (.apk/.ipa) на сервер Significa и выдавать вам готовую ссылку для установки.

Наглядный пример: у запущенного для Merge Request пайплайна видна стадия deploy, которая автоматически выполняется после стадии build.

Flutter project. Merge Request (deploy stage).
Flutter project. Merge Request (deploy stage).

Автоматическая очистка и управление артефактами

Чтобы не захламлять сервер устаревшими сборками, мы также реализовали стадию cleanup. Она срабатывает автоматически в одном из двух сценариев:

  1. При вливании Merge Request. Сборки, созданные для целей код-ревью и тестирования этой ветки, становятся неактуальными и удаляются.

  2. При удалении ветки. Если ветка была удалена вручную, то все связанные с ней артефакты также очищаются.

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

Flutter project. Merge Request (cleanup stage).
Flutter project. Merge Request (cleanup stage).

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

Централизованное управление окружениями в GitLab

Теперь каждое выполнение пайплайна, связанное с деплоем, автоматически создает или обновляет запись в соответствующем окружении, используя встроенные возможности GitLab Environments:

  1. Все сборки, их статус (active, stopped), коммит и ссылка для скачивания доступны в одном месте.

  2. Не нужно искать ссылки в логах пайплайна или чатах. Достаточно пе��ейти в раздел Environments в вашем проекте GitLab.

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

  4. В интерфейсе Merge Request отображается ссылка на развернутую сборку, что ускоряет процесс проверки.

Наглядный пример: список всех активных окружений в проекте с прямыми ссылками на последние развернутые сборки.

Flutter project. Environments.
Flutter project. Environments.

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

Деплой приложения в Google Play и App Store

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

Шаг 1. Создать новый репозиторий

Создайте новый проект (например, flutter-infra) в GitLab и в корне этого репозитория поместите файл .gitlab-ci.yml со следующим содержимым:

.gitlab-ci.yml
stages:
  - build
  - deploy

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

variables:
  BUILD_AAB_ARCHIVE: true
  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"
  FASTLANE_TEAM_ID: "EXAMPLE_TEAM_ID"
  FLUTTER_SDK_VERSION: 3.35.1
  GIT_CLONE_PROJECT_PATH: "${CI_PROJECT_NAMESPACE}/flutter-example"
  GIT_CLONE_REF_NAME: "v1.0.0"
  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
  PROJECT_ENTRY_POINT: "./lib/main.dart"
  SIGN_TYPE: "appstore"
  SUPPLY_TRACK: "beta"
  TARGET_BUNDLE_MAPPING: |
    [
      {
          "bundle_id": "com.example.myapp",
          "target_name": "Runner"
      }
    ]

default:
  interruptible: true

.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"
  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 ]
  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

deploy_testflight:
  stage: deploy
  needs:
    - build_ios
  when: manual
  extends:
    - .before_script:deploy_testflight
    - .script:deploy_testflight
    - .variables:deploy_testflight
  tags:
    - $MACOS_RUNNER_TAG

deploy_google_play:
  stage: deploy
  needs:
    - build_android
  when: manual
  extends:
    - .before_script:deploy_google_play
    - .script:deploy_google_play
    - .variables:deploy_google_play
  tags:
    - $MACOS_RUNNER_TAG

Важно: на этом этапе главное — создать структуру пайплайна. Конкретные команды сборки и переменные нужно настроить на следующем шаге.

Шаг 2. Задать необходимые переменные

Рассмотрим переменные окружения, которые непосредственно участвуют при сборке и дистрибуции приложения.

Название

Описание

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 (см. подробнее в следующем разделе).

FASTLANE_TEAM_ID

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

FLUTTER_SDK_VERSION

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

GIT_CLONE_PROJECT_PATH

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

GIT_CLONE_REF_NAME

Название тега, хэша коммита или ветки для сборки проекта из репозитория с мобильным приложением.

KEYCHAIN_NAME

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

LINUX_RUNNER_TAG

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

MACOS_RUNNER_TAG

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

PROJECT_ENTRY_POINT

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

SIGN_TYPE

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

SUPPLY_TRACK

Группа дистрибуции приложения после публикации в Google Play Console. Доступные значения: production, beta, alpha, internal.

TARGET_BUNDLE_MAPPING

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

Ключевой момент: как можно заметить, некоторые переменные явно задаются в файле  .gitlab-ci.yml, а не на уровне проекта GitLab. Это нужно для того, чтобы было проще отслеживать изменения в системе контроля версий.

Шаг 3. Настроить доступ к репозиторию приложения

Инфраструктура требует наличие доступа на уровне CI_JOB_TOKEN к репозиторию с Flutter-приложением. Для этого:

  1. Перейдите в основной репозиторий вашего приложения в GitLab.

  2. Откройте раздел SettingsCI/CD.

  3. В подразделе Job token permissions найдите таблицу CI/CD job token allowlist.

  4. Добавьте в нее путь к вашему репозиторию инфраструктуры в формате «группа/проект» (например, project-group/flutter-infra) и нажмите на кнопку Save changes.

Flutter Example. Job token permissions.
Flutter Example. Job token permissions.

После этого пайплайн в flutter-infra сможет склонировать код вашего приложения с помощью CI_JOB_TOKEN.

Шаг 4. Загрузить секретные ключи в Secure Files

Для подписи приложения и публикации в магазинах нужны закрытые ключи. Загрузите их через интерфейс GitLab в разделе SettingsCI/CDSecure Files (см. подробнее в предыдущей части).

Шаг 5. Нас��роить интеграции с магазинами приложений в GitLab

Для автоматической загрузки сборок в Google Play и App Store GitLab предоставляет встроенные интеграции. Их нужно активировать и настроить один раз в вашем проекте flutter-infra.

Общий принцип:

  1. Вы создаете сервисный аккаунт (для Google Play) или ключ API (для App Store Connect) в соответствующих консолях разработчика.

  2. Вы вводите данные этого аккаунта (файл JSON или ключ) в настройках проекта GitLab.

  3. GitLab CI/CD получает безопасный доступ к магазинам и может использовать команды (вроде fastlane supply) для публикации.

Google Play

Настроить интеграцию можно на уровне проекта GitLab в разделе: SettingsIntegrationsGoogle Play, следуя официальной документации.

Flutter Infra. Google Play.
Flutter Infra. Google Play.

App Store Connect

Настроить интеграцию можно на уровне проекта GitLab в разделе: SettingsIntegrationsApple App Store Connect, следуя официальной документации.

Flutter Infra. Apple App Store Connect.
Flutter Infra. Apple App Store Connect.

Шаг 6. Проверить работу пайплайна

Теперь протестируем настройку. Отправьте изменения в репозиторий GitLab и создайте новый Merge Request. При успешной настройке в интерфейсе GitLab отобразятся все запланированные этапы:

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

После успешного завершения стадий build_android или build_ios в пайплайне появятся две ручные стадии — deploy_google_play и deploy_testflight. Они отвечают за публикацию собранных артефактов (APK/IPA) в соответствующие магазины приложений. Запустить их нужно вручную через интерфейс GitLab, что позволяет контролировать момент выпуска новой версии.

Заключение

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

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

Удачи в разработке и пусть выпуск ваших релизов будет быстрым!