
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: 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
Далее в разделе 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
- 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.