
Flutter & GitLab CI/CD. Сборка и подписание мобильных приложений
Привет! Меня зовут Данил Абдрафиков, я мобильный разработчик в компании TAGES. Эта статья является продолжением первой части руководства по настройке GitLab CI/CD для Flutter приложений, в котором мы подробно разобрали настройку физической машины и подготовили GitLab Runner для работы. Теперь, когда инфраструктура готова, перейдем к самому интересному — автоматизации сборки и подписания мобильных приложений.
Готовы превратить сборку мобильных приложений из рутинной задачи в полностью автоматизированный процесс? Тогда начинаем!
Введение
Мы подошли к самому важному этапу — созданию полноценного конвейера автоматизированной сборки и подписания ваших Flutter приложений для платформ Android и iOS. Весь процесс можно разделить на три ключевые фазы: сборка (build), деплой (deploy) и очистка (cleanup).

В этой статье мы сфокусируемся на фундаменте — этапе build. Именно на нем происходит компиляция кода, подписание и подготовка релизных артефактов (APK/IPA). Без него невозможна работа всего последующего конвейера.
Ключевые темы, которые мы раскроем сегодня:
Децентрализованный подход к CI/CD — реализуем модульный и гибкий подход, создав центральный репозиторий с универсальными готовыми скриптами.
Подготовка Flutter проекта — пошагово разберем все необходимые изменения в коде и конфигурации Flutter проекта, которые обеспечат его беспрепятственную интеграцию с GitLab CI/CD.
Результаты автоматизации — увидим готовый пайплайн в действии: от коммита до получения подписанных релизных артефактов (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: Removing $KEYCHAIN_NAME from keychain list..." security delete-keychain "$KEYCHAIN_NAME" fi if [ -f ~/Library/Keychains/"$KEYCHAIN_NAME" ]; then echo "ℹ️ Info: Removing $KEYCHAIN_NAME file..." 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" - export GIT_CONFIG_COUNT=1 - export GIT_CONFIG_KEY_0="url.https://$CI_REGISTRY_USER:$CI_JOB_TOKEN@$CI_SERVER_SHELL_SSH_HOST/.insteadOf" - export GIT_CONFIG_VALUE_0="https://$CI_SERVER_SHELL_SSH_HOST/" - 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 --skip-pub-get 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
Далее в разделе Settings → CI/CD → Job token permissions → Authorized groups and projects нужно выбрать All groups and projects и нажать Save Changes, чтобы другие проекты могли получить доступ к этому репозиторию через CI/CD (см. ниже).

Как это будет выглядеть
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'
В include → ref может задаваться тег, название ветки или хэш коммита (см. подробнее). Такой подход особенно полезен в командах — все разработчики работают с единой версией скриптов, а не копируют их друг у друга. Этот подход:
Избавит от дублирования кода — один раз настраиваете шаблон и подключаете его в любом проекте.
Упростит поддержку — исправления и улучшения применяются сразу во всех проектах.
Стандартизирует процесс — все сборки выполняются по единым правилам.
В третьей части статьи мы расширим репозиторий новыми шаблонами скриптов для доставки приложения в 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 проекта. Этот файл определяет:
Этапы пайплайна (например, сборку, тестирование, деплой).
Переменные окружения, используемые в процессе выполнения.
Сценарии выполнения (какие команды запускать на каждом этапе).
Рассмотрим его структуру и ключевые настройки:
.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 определяет, когда пайплайн будет выполняться:
Для код‑ревью: при создании или обновлении Merge Request.
Для сборки релиза: при коммите в одну из ключевых веток (список задается в переменной 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 --enforce-lockfile - 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 проекта, перейдя в Settings → CI/CD → Secure Files → Upload File.

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. В зависимости от уровня доступа к аккаунту разработчика можно выбрать один из двух подходов:
Интеграция с App Store Connect (рекомендуемый способ).
Ручная загрузка Provisioning Profiles (если доступ ограничен).
Если у вас есть полный доступ к Apple Developer Account, настройте интеграцию на уровне Flutter проекта GitLab:
Перейдите в Settings → Integrations → Apple App Store Connect.
Следуйте официальной документации для настройки подключения.

После успешной интеграции вы сможете:
Управлять Provisioning Profiles (создавать, загружать и обновлять их автоматически).
Регистрировать тестовые устройства для установки приложения.
Загружать сборки в TestFlight напрямую из CI/CD‑пайплайна.
Этот способ избавляет от ручного управления профилями, тем самым минимизируя ошибки при сборке. Однако, если у вас нет полного доступа к Apple Developer Account, можно загрузить Provisioning Profiles вручную:
Добавьте файлы профилей (.mobileprovision) в Secure Files проекта GitLab.
При сборке GitLab автоматически импортирует их и будет использовать для подписи приложения.
Ограничения этого подхода:
Невозможно автоматически регистрировать новые тестовые устройства — при добавлении устройства через портал разработчика придется вручную обновлять профили в Secure Files.
Требуется следить за актуальностью профилей обеспечения на портале разработчиков и своевременно обновлять их.
Шаг 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 (подробнее см. в следующем шаге). |
Как добавить глобальную переменную?
Переходим в настройки Flutter проекта: Settings → CI/CD → Variables.
Нажимаем Add variable и заполняем следующие поля: Type, Key, Value, Options (можно отметить Masked — если нужно скрыть значение в логах или Masked and hidden для полного скрытия).
В поле Flags снимаем галочку с пункта Expand variable reference.

Важно: этот раздел доступен пользователям с ролью Maintainer и выше.
Шаг 5. Настройка подписи для нескольких целей (Targets)
Современные iOS‑приложения часто состоят из нескольких компонентов: основного приложения и расширений (например, Widget, Notification Service Extension и так далее). Каждый из этих компонентов — отдельная цель (Target) в Xcode и требует:
Свой уникальный идентификатор (bundle id).
Свой профиль обеспечения (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, отобразится индикатор выполнения пайплайна, который обеспечивает визуализацию статуса и хода выполнения задач (см. ниже).

Для запуска сборки под конкретную платформу вручную запускаем соответствующие задачи:
Находим этап build в пайплайне.
Жмем на иконку‑шестеренку рядом с ним.
В выпадающем меню выбираем build_android и/или build_ios.
После успешного выполнения пайплайна собранные билды (APK для Android и IPA для iOS) будут сохранены и доступны для скачивания в интерфейсе GitLab в качестве артефактов (см. ниже):

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