Saltar al contenido
Programemos.netDecisiones pragmáticas sobre .NET, Azure, arquitectura de software e IA.

DevOps también llega a la tienda: CI/CD con .NET MAUI y Android

Para mí, DevOps es una cultura, no un puesto. Pero este artículo no va a convertir esa frase en otra discusión sobre cómo debe llamarse cada rol. Me interesa algo más práctico: entender que DevOps no termina en los servidores ni pertenece únicamente a las aplicaciones web.

Estamos tan acostumbrados a construir y desplegar aplicaciones web que muchas veces reducimos la entrega a ejecutar el último comando del pipeline. Compilamos, publicamos y desplegamos en un App Service. O copiamos el artefacto a un VPS y reiniciamos un servicio. Como conocemos ese camino, lo resumimos en un paso y dejamos de pensar en todo lo que existe alrededor.

Ahí está la trampa. El comando de despliegue no es el proceso de entrega.

Todo lo que deba entregarse de forma repetible en algún destino puede entrar en una conversación DevOps. Ese destino puede ser un clúster, una cuenta de almacenamiento, un dispositivo industrial o una tienda de aplicaciones. Como parte de la entrega continua, nuestra responsabilidad también es conocer cómo funciona ese destino y diseñar el flujo alrededor de sus restricciones.

Con .NET MAUI el ejemplo es bastante claro. No vamos a desplegar un sitio web. Vamos a compilar una aplicación Android, validarla, firmar un Android App Bundle y llevarlo a Google Play. La tienda es parte de nuestro entorno de entrega.

La entrega continua no siempre termina en un servidor

Cuando hablamos de CI/CD solemos imaginar un flujo que toma el código, crea una imagen de contenedor y la despliega en algún servidor. Ese es un caso común, no la definición completa.

En Azure App Service, por ejemplo, las herramientas abstraen una parte importante del proceso. El pipeline puede autenticarse, publicar el artefacto y desplegarlo en la Web App o en un deployment slot. Eso no significa que no existan decisiones sobre configuración, migraciones, health checks o rollback. Significa que conocemos mejor ese modelo y el camino suele resultarnos familiar.

Con una tienda móvil la conversación cambia. Antes de publicar debemos entender la identidad de firma, la diferencia entre upload key y app signing key, el versionCode, los tracks de prueba, los testers, las revisiones, las políticas y la promoción hacia producción. No porque Google Play sea peor. El problema es otro: el deployment target tiene un modelo operativo diferente.

En una aplicación móvil cambia el destino, pero no cambia la responsabilidad. Seguimos necesitando responder preguntas conocidas:

  • ¿El código compila y las pruebas pasan?
  • ¿Podemos generar siempre el mismo tipo de artefacto?
  • ¿Quién puede firmarlo y con qué identidad?
  • ¿A qué entorno o grupo de usuarios se entrega primero?
  • ¿Cómo promovemos una versión sin reconstruir algo distinto?
  • ¿Qué hacemos si la entrega falla o Google Play la rechaza?
  • ¿Qué track representa QA, preproducción o producción para nuestro equipo?

Aquí también conviene separar términos cercanos. La integración continua valida cada cambio y evita que acumulemos sorpresas. La entrega continua mantiene la aplicación en condiciones de ser liberada y puede llevarla automáticamente a un entorno previo, como el track interno de Google Play. Cuando cada cambio aprobado llega automáticamente a los usuarios de producción, hablamos con más precisión de despliegue continuo.

Los dos últimos conceptos comparten las siglas CD y por eso se mezclan con frecuencia. No me preocupa convertirlo en una pelea semántica. Me preocupa que llamemos entrega continua a un pipeline que solo deja un archivo perdido entre los artefactos. Un .aab compilado es un resultado técnico; una versión disponible para probar o publicar ya forma parte de un proceso de entrega.

El destino también forma parte del diseño

Este no es un tutorial sobre cómo abrir una cuenta de Google Play, completar la ficha de la aplicación o resolver cada pantalla de Play Console. Ese proceso tiene sus propias reglas y cambia con el tiempo. Repetirlo paso a paso nos alejaría del punto que quiero discutir.

Lo que sí debemos entender es el modelo de releases del sitio donde vamos a desplegar. Google Play no organiza el ciclo con ambientes llamados literalmente QA, staging y production. Ofrece tracks de prueba con distintos niveles de exposición:

  • internal permite distribuir versiones rápidamente a un grupo pequeño para las primeras validaciones.
  • closed permite probar con grupos controlados y ampliar la validación sin exponer la versión a todo el público.
  • open funciona como una prueba más amplia a la que pueden incorporarse usuarios.
  • production es el track disponible para los usuarios finales y también permite hacer staged rollouts en las actualizaciones.

Esos tracks no deciden nuestro proceso. Lo decidimos nosotros. Un equipo puede usar internal como QA y closed como preproducción. Otro puede mantener varios closed tracks para grupos o funcionalidades distintas. Una aplicación pequeña quizá solo necesite internal antes de producción. La herramienta ofrece mecanismos; la estrategia de entrega sigue siendo una decisión de ingeniería.

Aquí está el detalle importante: no deberíamos tomar un pipeline web, cambiar el último comando y asumir que ya entendimos DevOps para aplicaciones móviles. Primero debemos definir quién recibe cada versión, qué validaciones permiten promoverla, qué credencial puede hacerlo y cómo actuamos cuando la versión tiene un problema.

Para este ejemplo voy a usar el track internal como primer destino desplegable. El flujo será deliberadamente sencillo:

  1. Cada pull request restaura dependencias, ejecuta pruebas y compila el target Android.
  2. Cada cambio aceptado en main vuelve a validar la aplicación.
  3. El pipeline genera un .aab firmado con la upload key.
  4. GitHub Actions conserva ese bundle como artefacto trazable.
  5. La misma ejecución lo publica en el track internal de Google Play.
  6. La promoción hacia producción queda separada y puede requerir aprobación.

Google Play usa Play App Signing: nosotros firmamos el bundle con la clave de carga y Google administra la clave con la que se firman los APK que finalmente reciben los dispositivos. La diferencia importa. La clave privada no debe vivir en el repositorio ni copiarse de manera informal entre equipos.

El track interno no es un simple directorio temporal. Es un destino de despliegue administrado por Google Play, con testers y versiones propias. Producción es otro destino. La infraestructura no tiene que ser un servidor para que exista una estrategia de ambientes.

Para seguir el ejemplo usaré .NET 10 y estos valores de muestra:

Proyecto MAUI: src/MyApp/MyApp.csproj
Proyecto de pruebas: tests/MyApp.Tests/MyApp.Tests.csproj
Target Android: net10.0-android
ApplicationId: net.programemos.myapp
Destino inicial: Google Play Internal Testing

Debes sustituir las rutas y el ApplicationId por los de tu proyecto. Ese identificador tiene que coincidir exactamente con el package name registrado en Google Play.

Lo que el pipeline debe conocer antes de ejecutar

No voy a explicar paso a paso cómo crear la aplicación o la cuenta en Google Play. No es el objetivo de este artículo ni quiero convertirlo en un tutorial de Android. Pero tampoco voy a esconder esos requisitos detrás del YAML, porque el pipeline depende de ellos.

Antes de automatizar debemos saber, como mínimo, que:

  • la aplicación debe existir en Play Console y su package name debe coincidir con nuestro ApplicationId;
  • la acción de publicación que usaremos presupone una primera carga manual del APK o AAB para registrar ese package en Play Console;
  • Play App Signing y la upload key forman parte del modelo de confianza de la publicación;
  • la service account necesita permisos sobre la aplicación y los tracks que realmente utilizará;
  • los testers y tracks deben estar definidos según el flujo de validación del equipo;
  • las políticas, declaraciones y revisiones de Google Play pueden detener una release aunque el pipeline esté en verde.

Automatizar no significa que una plataforma externa deje de tener reglas. Significa que entendemos esas reglas lo suficiente para convertirlas en un proceso repetible.

En la práctica, el pipeline presupone que la aplicación ya existe en Play Console, que la configuración obligatoria está completa y que Play App Signing está activo. Las cuentas personales creadas después del 13 de noviembre de 2023 deben cumplir los requisitos de pruebas para acceder a producción: actualmente, una prueba cerrada con al menos 12 testers inscritos durante 14 días continuos antes de solicitar ese acceso. El pipeline no evita políticas, revisiones ni declaraciones pendientes de la tienda.

Luego genera una upload key. Este comando se ejecuta una vez, no en cada build:

Terminal window
keytool -genkeypair -v \
-keystore upload.keystore \
-alias upload \
-keyalg RSA \
-keysize 2048 \
-validity 10000

Guarda una copia recuperable del keystore y sus contraseñas fuera de GitHub. Perder la clave de carga tiene solución mediante el proceso de restablecimiento de Google Play, pero convertir eso en el procedimiento normal agrega una fricción completamente innecesaria.

Después prepara el acceso automatizado:

  1. Habilita Google Play Developer API en un proyecto de Google Cloud.
  2. Crea una service account.
  3. Invita su correo desde Users and permissions de Play Console.
  4. Dale acceso únicamente a esta aplicación y concede View app information and download bulk reports (read only) junto con Release apps to testing tracks, que es el permiso que permite crear y desplegar releases de prueba. No concedas Release to production a esta identidad.
  5. Crea en GitHub el Environment google-play-internal.
  6. Agrega los secretos de firma y publicación dentro de ese Environment.
  7. Protege main con un ruleset o branch protection que exija el check Validate Android app antes del merge.

La guía de Google Play Developer API explica la relación entre Google Cloud, la service account y Play Console. No le daría permiso de producción a una credencial cuyo único trabajo es entregar versiones internas. Ese límite no es decoración de seguridad: expresa qué parte del release process puede ejecutar esa identidad.

Estos son los secretos que usará el workflow:

Secreto Contenido
ANDROID_KEYSTORE_BASE64 upload.keystore convertido a Base64
ANDROID_KEY_ALIAS Alias de la upload key, por ejemplo upload
ANDROID_KEY_PASSWORD Contraseña de la clave
ANDROID_STORE_PASSWORD Contraseña del keystore
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON JSON completo de la service account

En PowerShell puedes obtener el valor Base64 del keystore así:

Terminal window
[Convert]::ToBase64String([IO.File]::ReadAllBytes("./upload.keystore"))

El Base64 no cifra el keystore. Solo permite almacenarlo como texto dentro de un secreto. La protección real está en el control de acceso al Environment, el alcance mínimo de la service account y el respaldo seguro de la clave.

Para mantener el ejemplo legible usaré el JSON de la service account como secreto. Es válido, pero sigue siendo una credencial de larga duración. En una organización que ya trabaje con OIDC preferiría Workload Identity Federation y credenciales temporales; la acción también admite ese flujo. No voy a desarrollarlo aquí porque cambia la autenticación, no la idea central del release pipeline.

Un pipeline de GitHub Actions para demostrarlo

Ahora sí podemos bajar la idea a código. Crea .github/workflows/android-ci-cd.yml con este flujo y adapta las rutas del bloque env:

name: Android CI/CD
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
env:
DOTNET_VERSION: '10.0.x'
MAUI_PROJECT: 'src/MyApp/MyApp.csproj'
TEST_PROJECT: 'tests/MyApp.Tests/MyApp.Tests.csproj'
ANDROID_TFM: 'net10.0-android'
APPLICATION_ID: 'net.programemos.myapp'
jobs:
validate:
name: Validate Android app
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Install .NET MAUI Android workload
run: dotnet workload install maui-android
- name: Restore
run: |
dotnet restore "$MAUI_PROJECT"
dotnet restore "$TEST_PROJECT"
- name: Test
run: dotnet test "$TEST_PROJECT" --configuration Release --no-restore
- name: Build Android target
run: >-
dotnet build "$MAUI_PROJECT"
--framework "$ANDROID_TFM"
--configuration Release
--no-restore
deliver-internal:
name: Deliver to Google Play Internal
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
needs: validate
runs-on: ubuntu-latest
environment: google-play-internal
concurrency:
group: ${{ github.repository }}-google-play-internal
queue: max
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Install .NET MAUI Android workload
run: dotnet workload install maui-android
- name: Restore app
run: dotnet restore "$MAUI_PROJECT"
- name: Define version
shell: bash
run: |
echo "VERSION_CODE=$((10000 + GITHUB_RUN_NUMBER))" >> "$GITHUB_ENV"
echo "DISPLAY_VERSION=1.0.${GITHUB_RUN_NUMBER}" >> "$GITHUB_ENV"
- name: Restore signing material
shell: bash
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
run: |
umask 077
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/upload.keystore"
printf '%s' "$ANDROID_KEY_PASSWORD" > "$RUNNER_TEMP/key-pass.txt"
printf '%s' "$ANDROID_STORE_PASSWORD" > "$RUNNER_TEMP/store-pass.txt"
- name: Publish signed Android App Bundle
shell: bash
env:
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
run: |
dotnet publish "$MAUI_PROJECT" \
--framework "$ANDROID_TFM" \
--configuration Release \
--no-restore \
-p:ApplicationId="$APPLICATION_ID" \
-p:ApplicationVersion="$VERSION_CODE" \
-p:ApplicationDisplayVersion="$DISPLAY_VERSION" \
-p:AndroidPackageFormats=aab \
-p:AndroidKeyStore=true \
-p:AndroidSigningKeyStore="$RUNNER_TEMP/upload.keystore" \
-p:AndroidSigningKeyAlias="$ANDROID_KEY_ALIAS" \
-p:AndroidSigningKeyPass="file:$RUNNER_TEMP/key-pass.txt" \
-p:AndroidSigningStorePass="file:$RUNNER_TEMP/store-pass.txt"
- name: Keep signed bundle as workflow artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: android-signed-aab-${{ github.run_number }}
path: '**/bin/Release/${{ env.ANDROID_TFM }}/publish/*-Signed.aab'
if-no-files-found: error
- name: Upload to Google Play Internal Testing
uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1.1.5
with:
serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
packageName: ${{ env.APPLICATION_ID }}
releaseFiles: '**/bin/Release/${{ env.ANDROID_TFM }}/publish/*-Signed.aab'
tracks: internal
status: completed

La documentación de .NET MAUI recomienda ejecutar dotnet publish sobre el proyecto de la aplicación, no sobre toda la solución, y permite pasar las contraseñas de firma mediante archivos. Esto último evita colocarlas directamente como propiedades visibles del comando.

El ApplicationVersion merece atención. Google Play exige que cada nueva entrega use un versionCode superior. Usar GITHUB_RUN_NUMBER con un desplazamiento sencillo mantiene el ejemplo legible. En un producto con más de un pipeline o con una numeración existente, define una estrategia compartida para no generar colisiones.

Flujo de entrega de una aplicación .NET MAUI desde GitHub hasta un bundle firmado, Google Play Internal Testing y los teléfonos de prueba
El destino cambia: repositorio, validación, firma, track interno y finalmente usuarios. La responsabilidad de entrega sigue siendo la misma.

El último paso usa la acción mantenida en r0adkll/upload-google-play, que llama a Google Play Developer API. Todas las acciones del ejemplo están fijadas a commits completos para ejecutar exactamente el código revisado; los comentarios conservan la versión legible y facilitan actualizaciones controladas. En una organización también usaría únicamente acciones aprobadas por el equipo.

La concurrencia serializa las publicaciones al track interno. La API de Google Play trabaja con edits y dos releases simultáneas pueden entrar en conflicto; queue: max conserva las ejecuciones pendientes y las procesa en orden en lugar de lanzar varios deployments al mismo destino a la vez.

Hay otra frontera de confianza que el YAML no puede resolver solo. Durante dotnet publish, el proyecto MSBuild se ejecuta en el mismo runner que contiene la upload key. Por eso proteger main, revisar cambios en el proyecto y en el workflow, limitar quién puede aprobar el Environment y exigir el status check no son controles opcionales alrededor del pipeline: son parte de la protección de la firma.

Lo valioso de este workflow no es que tenga pocas líneas. Es que representa decisiones que tomamos antes: el pull request solo valida, main genera una release firmada, la credencial solo puede entregar a testing y el primer deployment target es internal. Si copiamos los comandos sin entender esas decisiones, tendremos automatización, pero no necesariamente un buen proceso de entrega.

Llevar una versión a la tienda no termina el trabajo

El workflow informa si un pull request está roto, pero no bloquea el merge por sí solo. Cuando main exige Validate Android app como required status check, un cambio que no restaura, no pasa las pruebas o no compila no puede fusionarse por la vía normal. Después del merge, el cambio aceptado termina disponible para testers internos. Eso ya es mucho más útil que producir manualmente un .aab, buscarlo en una carpeta y subirlo desde una computadora personal.

Pero no confundiría automatización con madurez. Todavía quedan decisiones de producto y operación:

  • Las pruebas unitarias no sustituyen una prueba real en dispositivos y versiones de Android relevantes.
  • Google Play puede detener una entrega por políticas, permisos sensibles o formularios pendientes.
  • Un lanzamiento móvil no tiene el mismo rollback inmediato de un servidor; muchas veces corregimos publicando una versión nueva con un versionCode superior.
  • El track interno valida distribución, no demuestra por sí solo que la aplicación funciona bien para usuarios reales.
  • Promover automáticamente a producción solo tiene sentido si el equipo tiene pruebas, telemetría y capacidad de respuesta acordes con ese riesgo.

Yo empezaría con entrega automática al track interno. Después agregaría un Environment separado para producción, permisos distintos y una aprobación antes de promover la versión ya validada. Los GitHub Environments permiten aplicar revisores, restricciones de ramas y secretos por destino.

No porque poner una aprobación haga al proceso más DevOps. Lo hace razonable cuando la tienda, las políticas y la experiencia del usuario introducen un costo de recuperación diferente.

La pregunta incómoda no es si sabemos escribir el comando que sube un .aab. Es si entendemos qué ocurre después de ejecutarlo. Quién recibe la versión, qué versión obtiene un tester elegible para varios tracks, cómo se incrementa el versionCode, qué señales observamos y qué capacidad real tenemos para corregir un fallo.

Para mí, esa es la idea que vale la pena conservar: DevOps no depende de que exista un servidor al final del diagrama. Depende de que el equipo piense cómo un cambio se integra, se valida, se entrega y se opera. En .NET MAUI, Google Play forma parte del sistema de entrega y su track production es uno de nuestros deployment targets de producción.

El pipeline no es el objetivo. El objetivo es que llevar una aplicación desde el código hasta las manos de quien la usa deje de ser una ceremonia manual y se convierta en un proceso confiable.

Si quieres profundizar en esta forma de pensar, también puedes leer La IA no acabó con DevOps: nos obliga a volver a entregar valor.

Si quieres seguir aprendiendo sobre estos temas te invito a ver mis otras publicaciones.

Jose Antonio Arias
Jose Antonio Arias

Senior .NET Developer e Ingeniero en Informática con amplia experiencia en backend, Azure, DevOps, arquitectura e IA empresarial. Ayudo a convertir problemas y procesos de negocio en soluciones tecnológicas con valor real.