Start Debugging

Как нацелиться на несколько версий Flutter из одного CI-пайплайна

Практическое руководство по запуску одного Flutter-проекта против нескольких версий SDK в CI: матрица GitHub Actions с subosito/flutter-action v2, .fvmrc от FVM 3 как источник истины, фиксация канала, кеширование и подводные камни, которые кусают, когда матрица вырастает за пределы трёх версий.

Короткий ответ: зафиксируйте основную версию Flutter проекта в .fvmrc (стиль FVM 3) и используйте этот файл как источник истины для локальной разработки. В CI запустите задание strategy.matrix по дополнительным версиям Flutter, которые вас интересуют, устанавливайте каждую с помощью subosito/flutter-action@v2 (он читает flutter-version-file: .fvmrc для основной сборки и принимает явный flutter-version: ${{ matrix.flutter-version }} для записей матрицы), включите как cache: true, так и pub-cache: true, и защитите матрицу с помощью fail-fast: false, чтобы одна сломанная версия не скрывала остальные. Считайте основную версию обязательной, а версии матрицы — информационными, пока не стабилизируете их.

Это руководство для проектов Flutter 3.x в мае 2026 года, проверено против subosito/flutter-action@v2 (последний v2.x), FVM 3.2.x и Flutter SDK 3.27.x и 3.32.x на размещённых GitHub раннерах Ubuntu и macOS. Предполагается один репозиторий, один pubspec.yaml и цель ловить регрессии между версиями Flutter до того, как они достигнут релизной ветки. Шаблоны переносятся на GitLab CI и Bitbucket Pipelines с небольшими изменениями синтаксиса; концепции матрицы идентичны.

Почему один репозиторий против нескольких версий Flutter — это вообще тема

У Flutter есть два релизных канала, stable и beta, и в продакшене поддерживается только stable. Документация Flutter рекомендует stable для новых пользователей и для продакшен-релизов, что верно, и было бы прекрасно, если бы каждая команда могла выбрать один stable-патч и оставаться на нём. На практике три давления выталкивают команды с этого пути:

  1. Пакет, от которого вы зависите, поднимает свою нижнюю границу environment.flutter, и новая граница на один minor впереди вашей текущей.
  2. Выходит новый stable с фиксом Impeller или фиксом сборки iOS, который вам нужен, но транзитивный пакет ещё не сертифицирован против него.
  3. Вы поставляете библиотеку или шаблон (стартовый набор, внутреннюю систему дизайна), которые приложения-потребители используют на любой версии Flutter, на которой стандартизировалась их команда, и вам нужно знать, что это не ломается ни на одном из stable - 1, stable или beta.

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

Краткое напоминание о том, что pubspec.yaml действительно навязывает. Ограничение environment.flutter проверяется pub только как нижняя граница. Как описано в flutter/flutter#107364 и #113169, SDK не навязывает верхнюю границу ограничения flutter:, поэтому запись flutter: ">=3.27.0 <3.33.0" не остановит разработчика на Flutter 3.40 от установки вашего пакета. Вам нужен внешний механизм. Этот механизм — FVM для людей и flutter-action для CI.

Шаг 1: сделайте .fvmrc источником истины проекта

Установите FVM 3 один раз на рабочую станцию, затем зафиксируйте проект из корня репозитория:

# FVM 3.2.x, May 2026
dart pub global activate fvm
fvm install 3.32.0
fvm use 3.32.0

fvm use записывает .fvmrc и обновляет .gitignore, чтобы тяжёлый каталог .fvm/ не попадал в коммиты. Согласно документации по конфигурации FVM, только .fvmrc (и устаревший fvm_config.json, если у вас есть с FVM 2) принадлежит системе контроля версий. Закоммитьте его, и файл становится контрактом, который читает каждый разработчик и каждое задание CI.

Минимальный .fvmrc выглядит так:

{
  "flutter": "3.32.0",
  "flavors": {
    "next": "3.33.0-1.0.pre",
    "edge": "beta"
  },
  "updateVscodeSettings": true,
  "updateGitIgnore": true
}

Карта flavors — это концепция FVM, которая идеально проецируется на матрицу CI: каждая запись — это именованная версия Flutter, которую ваш проект терпит. next — это предстоящий stable, на котором вы хотите видеть зелёный свет, edge — это активный beta-канал для сигнала раннего предупреждения. Локально разработчик может запустить fvm use next, чтобы провести проверку перед открытием PR. В CI вы будете итерировать те же имена flavor из матрицы, поэтому имена остаются согласованными.

Шаг 2: один workflow, одна основная сборка, одно задание матрицы

Ловушка, в которую попадает большинство команд при первой попытке, — поместить каждую версию Flutter в одну матрицу и считать их все обязательными. Это раздувает время выполнения и превращает одну нестабильную beta в красную ветку main. Шаблон, который масштабируется, — это два задания в одном файле workflow:

Вот workflow с v6 от actions/checkout (актуальной на май 2026) и subosito/flutter-action@v2:

# .github/workflows/flutter-ci.yml
name: Flutter CI

on:
  push:
    branches: [main]
  pull_request:

concurrency:
  group: flutter-ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  primary:
    name: Primary (.fvmrc)
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v6
      - uses: subosito/flutter-action@v2
        with:
          flutter-version-file: .fvmrc
          channel: stable
          cache: true
          pub-cache: true
      - run: flutter --version
      - run: flutter pub get
      - run: dart format --output=none --set-exit-if-changed .
      - run: flutter analyze
      - run: flutter test --coverage

  compat:
    name: Compat (Flutter ${{ matrix.flutter-version }})
    needs: primary
    runs-on: ${{ matrix.os }}
    timeout-minutes: 20
    continue-on-error: ${{ matrix.experimental }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - flutter-version: "3.27.4"
            channel: stable
            os: ubuntu-latest
            experimental: false
          - flutter-version: "3.32.0"
            channel: stable
            os: macos-latest
            experimental: false
          - flutter-version: "3.33.0-1.0.pre"
            channel: beta
            os: ubuntu-latest
            experimental: true
    steps:
      - uses: actions/checkout@v6
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: ${{ matrix.flutter-version }}
          channel: ${{ matrix.channel }}
          cache: true
          pub-cache: true
      - run: flutter pub get
      - run: flutter analyze
      - run: flutter test

Несколько вещей в этом файле сделаны намеренно, и стоит их выделить, прежде чем вы скопируете.

fail-fast: false обязателен для матрицы совместимости. Без этого первая упавшая версия отменяет остальные, что лишает смысла. Вы хотите видеть в одном запуске CI, что 3.27 проходит, 3.32 падает и beta проходит, а не просто “что-то упало”.

continue-on-error на запись матрицы позволяет пометить beta как допустимое красное. Защита ветки должна требовать имя проверки Primary (.fvmrc) и любые записи совместимости, которые вы классифицировали как обязательные. Beta и “next” остаются зеленоватыми на дашборде, но никогда не блокируют merge.

needs: primary — это маленькая, но важная деталь последовательности. Это означает, что минуты CI не сжигаются на матрице, пока основная сборка не докажет, что изменение хотя бы синтаксически здраво. На матрице из 30 заданий это важно. На матрице из 3 заданий это всё ещё бесплатная победа.

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

Шаг 3: кеш, который действительно попадает между версиями

subosito/flutter-action@v2 кеширует установку Flutter SDK с помощью actions/cache@v5 под капотом. Каждая уникальная комбинация (os, channel, version, arch) производит отдельную запись кеша, что именно то, чего вы хотите. Ключ кеша по умолчанию — это функция от этих токенов, поэтому матрица из 3 версий производит 3 кеша SDK, а матрица из 2 ОС на 3 версии производит 6. Это нормально, пока вы не начнёте кастомизировать.

Две настройки, которые стоит знать:

Если у вас монорепозиторий с несколькими Flutter-проектами, разделяющими зависимости, установите cache-key и pub-cache-key, которые включают хеш всех релевантных файлов pubspec.lock, а не только дефолтный. Иначе каждый подпроект перезаписывает кеш других. Action предоставляет токены :hash: и :sha256: именно для этого; смотрите README для синтаксиса.

Что не принадлежит вашему ключу кеша матрицы — это имя канала Flutter SDK, когда вы фиксируетесь на сборке *-pre. Beta-теги иногда пересобираются, поэтому попадание кеша в версию *-pre может вернуть устаревший бинарник. Самое простое решение — пропустить кеширование для записей experimental: true:

- uses: subosito/flutter-action@v2
  with:
    flutter-version: ${{ matrix.flutter-version }}
    channel: ${{ matrix.channel }}
    cache: ${{ !matrix.experimental }}
    pub-cache: ${{ !matrix.experimental }}

Вы отказываетесь от минуты времени установки на beta-записи и получаете уверенность, что beta-сборка воспроизводима.

Шаг 4: свяжите .fvmrc и матрицу

Смысл flavors FVM плюс матрица в том, что имена выравниваются. Добавление новой цели совместимости должно быть однострочным изменением в .fvmrc и однострочным изменением в workflow. Чтобы держать их синхронизированными без ручной координации, генерируйте матрицу из файла во время задания. GitHub Actions может сделать это с помощью небольшого bootstrap-задания, которое выдаёт JSON-матрицу:

  matrix-builder:
    name: Build matrix from .fvmrc
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.build.outputs.matrix }}
    steps:
      - uses: actions/checkout@v6
      - id: build
        run: |
          MATRIX=$(jq -c '
            {
              include: (
                .flavors // {} | to_entries
                | map({
                    "flutter-version": .value,
                    "channel": (if (.value | test("pre|dev")) then "beta" else "stable" end),
                    "os": "ubuntu-latest",
                    "experimental": (.key == "edge")
                  })
              )
            }' .fvmrc)
          echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"

  compat:
    needs: [primary, matrix-builder]
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix: ${{ fromJson(needs.matrix-builder.outputs.matrix) }}
    # ... same steps as before

Теперь добавление "perf-investigation": "3.31.2" в .fvmrc автоматически добавляет задание совместимости при следующем запуске CI. Нет второго источника истины, нет расхождения между тем, что пытается локальный FVM, и тем, что проверяет CI. GitHub-action flutter-actions/pubspec-matrix-action делает похожее, если вы предпочитаете использовать поддерживаемую зависимость вместо встроенного jq; оба подхода работают.

Подводные камни, которые появляются после второй записи матрицы

Как только в матрице больше трёх версий, вы натолкнётесь хотя бы на одно из этого.

Отравление pub-кеша. Пакет, использующий условные импорты для более новых символов Flutter, может разрешаться по-разному на 3.27 против 3.32. Если обе версии используют общий pub-cache, lock-файл, написанный 3.32, может быть возвращён 3.27 и произвести сборку, которая “работает” с неправильным путём кода. Используйте pub-cache-key, включающий токен версии Flutter (:version:), чтобы держать их раздельными. Цена — более холодный кеш; выгода — воспроизводимость.

Шум pubspec.lock. Если вы коммитите pubspec.lock (рекомендуется для репозиториев приложений, не для библиотек), матрица будет регенерировать его по-разному для каждой версии Flutter, и разработчик, работающий на версии из .fvmrc, увидит другой lock, чем видят записи матрицы CI. Решение — пропустить запись lock-файла в задании матрицы: передайте --enforce-lockfile в flutter pub get, который падает на расхождении разрешения вместо мутации lock. Применяйте это только в задании матрицы; основное задание должно по-прежнему позволять обновления, чтобы PR от Renovate или Dependabot могли стать зелёными.

Сборки iOS и beta-канал. subosito/flutter-action@v2 устанавливает Flutter SDK, но не меняет версию Xcode на macos-latest. Xcode раннера обновляется в другой каденции, чем beta-канал Flutter, и Flutter beta иногда требует Xcode, который раннер ещё не поставляет. Когда шаг сборки iOS (flutter build ipa --no-codesign) начинает падать только на beta, проверьте Xcode раннера против требований flutter doctor прежде чем предполагать, что ваш код сломан. Фиксация раннера через runs-on: macos-15 вместо macos-latest даёт вам контроль над этой переменной.

Дефолты архитектуры. На май 2026 размещённые GitHub раннеры по умолчанию ARM64 на macOS и x64 на Ubuntu. Если вы собираете нативные плагины, токен архитектуры в ключе кеша важен; иначе кеш Apple Silicon может быть отдан x64-раннеру при будущей миграции. Дефолтный cache-key action включает :arch: именно по этой причине; не убирайте его при кастомизации.

Расхождение Dart SDK. Каждая версия Flutter поставляется со специфическим Dart SDK. Запуск dart format на Flutter 3.32 (Dart 3.7) производит другое форматирование в нескольких краевых случаях, чем Flutter 3.27 (Dart 3.5). Запускайте форматирование только в основном задании, не в матрице, чтобы избежать ложных отчётов “format check failed” на старых версиях. Та же логика применяется к линтам: новый линт, введённый в Dart 3.7, сработает на 3.32 и не сработает на 3.27. Используйте analysis_options.yaml уровня проекта и включайте новые линты только когда самая старая версия матрицы их поддерживает.

Когда прекратить добавлять версии

Смысл всего этого — ловить регрессии рано, а не тестировать исчерпывающе. Матрица из более чем трёх или четырёх версий обычно означает, что команда боится обновляться, а не уверена в обновлении. Если ваша матрица выросла до пяти, спросите, какая запись не поймала регрессию за шесть месяцев. Эта запись, вероятно, должна быть выведена из эксплуатации. Правильная каденция для большинства приложений — текущий stable, следующий stable, когда анонсирован и beta, что означает, что скрипт matrix-builder из Шага 4 держит её ограниченной тем, что декларирует .fvmrc.

Дисциплина, которая окупается, та же самая, что заставляет воспроизводимую фиксацию Flutter SDK работать в первую очередь: декларируйте версии, которые вам важны, устанавливайте только эти версии и относитесь ко всему вне этого набора как находящемуся вне контракта. Матрица — это принуждение.

Связанное

Ссылки на источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад