CI/CD con GitHub Actions en Flutter: ejercicio resuelto

CI/CD con GitHub Actions en Flutter: ejercicio resuelto

Sin CI/CD, la calidad del código depende de que cada desarrollador recuerde ejecutar los tests y el análisis antes de cada push. Con GitHub Actions, esas comprobaciones se ejecutan automáticamente en cada PR y cada merge a main, sin intervención manual.

Enunciado

Crea un pipeline de GitHub Actions para un proyecto Flutter que:

  1. Análisis y tests: corre en cada push y PR a main.
  2. Build de APK: genera un APK release al hacer merge a main.
  3. Usa caché de pub: reduce el tiempo de setup de ~2 min a ~15 s.
  4. Matrix de versiones: prueba en Flutter stable y beta.

Estructura de archivos

1
2
3
4
.github/
└── workflows/
    ├── ci.yml       ← análisis + tests (pull requests)
    └── release.yml  ← build APK/IPA (push a main)

Solución: ci.yml (análisis + tests)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  analyze_and_test:
    name: Analyze & Test (Flutter ${{ matrix.flutter_channel }})
    runs-on: ubuntu-latest

    strategy:
      matrix:
        flutter_channel: [stable, beta]
      fail-fast: false  # no cancela el otro canal si uno falla

    steps:
      - name: Checkout código
        uses: actions/checkout@v4

      - name: Configurar Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: '' # usa la última del canal
          channel: ${{ matrix.flutter_channel }}
          cache: true         # cachea el SDK de Flutter

      - name: Caché de pub packages
        uses: actions/cache@v4
        with:
          path: |
            ~/.pub-cache
            .dart_tool
          key: pub-${{ runner.os }}-${{ hashFiles('**/pubspec.lock') }}
          restore-keys: |
            pub-${{ runner.os }}-

      - name: Instalar dependencias
        run: flutter pub get

      - name: Verificar formato
        run: dart format --set-exit-if-changed .

      - name: Análisis estático
        run: dart analyze --fatal-warnings

      - name: Tests unitarios y de widget
        run: flutter test --coverage

      - name: Subir cobertura a Codecov
        if: matrix.flutter_channel == 'stable'
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          files: coverage/lcov.info

Solución: release.yml (build APK)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# .github/workflows/release.yml
name: Release Build

on:
  push:
    branches: [main]
  workflow_dispatch:  # permite lanzar manualmente desde GitHub UI

jobs:
  build_apk:
    name: Build APK (prod)
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: subosito/flutter-action@v2
        with:
          channel: stable
          cache: true

      - name: Caché pub
        uses: actions/cache@v4
        with:
          path: ~/.pub-cache
          key: pub-${{ runner.os }}-${{ hashFiles('**/pubspec.lock') }}

      - name: Instalar dependencias
        run: flutter pub get

      - name: Crear env de producción
        run: |
          echo '{
            "APP_ENV": "prod",
            "APP_NAME": "MiApp",
            "API_BASE_URL": "${{ secrets.PROD_API_URL }}",
            "ENABLE_LOGGING": "false"
          }' > envs/prod.json

      - name: Build APK release
        run: flutter build apk --release --dart-define-from-file=envs/prod.json

      - name: Subir APK como artifact
        uses: actions/upload-artifact@v4
        with:
          name: app-release-${{ github.sha }}
          path: build/app/outputs/flutter-apk/app-release.apk
          retention-days: 14

  build_ios:
    name: Build IPA (prod)
    runs-on: macos-latest
    if: github.ref == 'refs/heads/main'

    steps:
      - uses: actions/checkout@v4

      - uses: subosito/flutter-action@v2
        with:
          channel: stable
          cache: true

      - run: flutter pub get

      - name: Build iOS (sin firma — para distribución interna)
        run: flutter build ios --release --no-codesign

      - name: Crear IPA sin firma
        run: |
          mkdir -p build/ios/ipa
          cd build/ios/iphoneos
          mkdir Payload
          cp -r Runner.app Payload/
          zip -r ../ipa/app-release.ipa Payload

      - uses: actions/upload-artifact@v4
        with:
          name: app-ios-release-${{ github.sha }}
          path: build/ios/ipa/app-release.ipa
          retention-days: 14

Añadir badges al README

1
2
3
<!-- README.md -->
[![CI](https://github.com/tuusuario/turepositorio/actions/workflows/ci.yml/badge.svg)](https://github.com/tuusuario/turepositorio/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/tuusuario/turepositorio/branch/main/graph/badge.svg)](https://codecov.io/gh/tuusuario/turepositorio)

Secretos de GitHub necesarios

En Settings → Secrets and variables → Actions:

SecretoUso
CODECOV_TOKENSubir cobertura a Codecov
PROD_API_URLURL de API en prod (no en código)
KEYSTORE_BASE64Keystore Android codificado en base64 (para firma)
KEYSTORE_PASSWORDPassword del keystore

Firma del APK en CI (avanzado)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Pasos adicionales en build_apk para firmar el APK
- name: Decodificar keystore
  run: |
    echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/keystore.jks

- name: Build APK firmado
  env:
    KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
    KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
    KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
  run: |
    flutter build apk --release \
      --dart-define-from-file=envs/prod.json

Y en android/app/build.gradle:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
android {
    signingConfigs {
        release {
            storeFile file("keystore.jks")
            storePassword System.getenv("KEYSTORE_PASSWORD")
            keyAlias System.getenv("KEY_ALIAS")
            keyPassword System.getenv("KEY_PASSWORD")
        }
    }
    buildTypes {
        release {
            signingConfig signingConfigs.release
        }
    }
}

Tiempos de ejecución aproximados

JobTiempo sin cachéTiempo con caché
flutter pub get~90 s~10 s
dart analyze~20 s~20 s
flutter test~30–120 s~30–120 s
flutter build apk~3–5 min~2–3 min

Errores frecuentes

  • dart analyze pasa local pero falla en CI: diferente versión de Dart. Fija la versión en flutter-action con flutter-version: '3.32.0' o usa el mismo canal que en local.
  • Tests fallan en CI pero no en local: suelen ser tests con DateTime.now() o que dependen del timezone. Usa fechas fijas en los tests.
  • APK demasiado grande: añade --split-debug-info=build/debug-info y --obfuscate para reducir tamaño y ofuscar código.

Aplicación práctica

Con este pipeline: cada PR pasa por análisis + tests automáticamente, los reviewers ven el resultado antes de aprobar, y cada merge a main genera un APK listo para distribución interna o subida a la Play Store.

Siguiente ejercicio recomendado

Práctica guiada y siguiente paso

FAQ

¿Cuántos minutos de GitHub Actions tiene el plan gratuito?

2.000 minutos/mes en Linux para repositorios privados. Para repositorios públicos, los runners Linux son gratuitos e ilimitados. macOS consume 10× más minutos que Linux.

¿Puedo hacer deploy automático a la Play Store?

Sí, con fastlane o la acción r0adkll/upload-google-play. Necesitas una clave de API de Google Play y subir el AAB en lugar del APK.

¿Cómo evito que un PR pueda llegar a main sin pasar los tests?

En Settings → Branches → Branch protection rules, activa “Require status checks to pass before merging” y selecciona el job de CI como check requerido.