Cómo pasar de despliegues locales a CI/CD rápido con GitHub Actions
He estado creando una aplicación web para entrenamiento de pronunciación para ayudarme a mejorar mi prosodia en inglés. Soy hablante nativo de español y pensé que podría ser útil para otros que están aprendiendo inglés como segunda lengua. El backend está hecho en Python con FastAPI, utilizando Parselmouth (un wrapper de Python para Praat) para el análisis de audio y Google Gemini para generar comentarios de entrenamiento. El frontend es React con Vite y TypeScript.
Para la infraestructura elegí Google Cloud. El backend se ejecuta en Cloud Run, el frontend está alojado en Firebase Hosting, y los datos de usuario residen en Firestore. Las imágenes de Docker se envían a Artifact Registry. El código reside en GitHub. Es una configuración bastante estándar para un proyecto secundario que quería mantener dentro de los límites del nivel gratuito.
El problema
Estaba desplegando tanto el frontend como el backend desde mi máquina local. El frontend era lo suficientemente rápido, pero el backend era una tortura. Cada despliegue significaba construir la imagen de Docker desde cero, lo que tardaba entre 5 y 10 minutos mientras pip descargaba todas las dependencias de Python.
Quería pasar a CI/CD para que los despliegues se realizaran automáticamente cuando hiciera push a main. Pero tampoco quería esperar 10 minutos por cada push. Así que me propuse hacer que GitHub Actions fuera inteligente sobre lo que realmente había cambiado.
La estructura del monorepo
Todo reside en un repositorio con una carpeta frontend/ y una carpeta backend/. El flujo de trabajo se activa al hacer push a main y quería que averiguara qué había cambiado realmente y solo desplegara esa parte. ¿Cambiaron archivos del frontend? Solo desplegar en Firebase. ¿Cambió el código del backend? Solo desplegar en Cloud Run. Y si cambió requirements.txt, reconstruir la imagen base de Docker. Pero solo entonces.
Encontrando una forma de detectar cambios
Busqué y encontré una acción llamada dorny/paths-filter. Comprueba qué archivos cambiaron en un push y genera valores booleanos que otros trabajos pueden usar. La configuré como el primer trabajo en mi flujo de trabajo:
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
requirements: ${{ steps.filter.outputs.requirements }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
backend:
- 'backend/**'
frontend:
- 'frontend/**'
requirements:
- 'backend/requirements.txt'
- 'backend/Dockerfile.base'Esto me dio tres salidas que podía referenciar en otros trabajos para decidir si debían ejecutarse.
Lidiando con las compilaciones lentas de Docker
Incluso con la detección de cambios funcionando, los despliegues del backend seguían siendo lentos. Cada compilación ejecutaba pip install desde cero. Mi requirements.txt tiene librerías de ML, cosas de procesamiento de audio, de todo. Eso es mucha descarga.
Me di cuenta de que quizás cambiaba las dependencias una vez al mes. El resto del tiempo solo estaba cambiando el código de la aplicación. Así que intenté dividir el Dockerfile en dos.
Dockerfile.base
Puse todo lo lento aquí: paquetes del sistema, dependencias de pip. La idea era compilar esto una vez y enviarlo a Artifact Registry, y luego reutilizarlo.
FROM python:3.11-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
espeak-ng \
libsndfile1 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --upgrade pip && \
pip install --no-cache-dir -r requirements.txtDockerfile.fast
Este comienza desde la imagen base y solo copia el código. Sin apt-get. Sin pip. Solo un comando COPY.
FROM us-central1-docker.pkg.dev/lescoach/prosody-repo/prosody-api-base:latest
WORKDIR /app
COPY app/ ./app/
ENV PYTHONUNBUFFERED=1
ENV PORT=8080
EXPOSE 8080
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]Esto redujo el tiempo de compilación de 5-10 minutos a unos 30 segundos, lo cual fue una buena mejora.
Juntando el flujo de trabajo
Configuré el trabajo de la imagen base para que solo se ejecutara cuando las dependencias realmente cambiaran:
build-base-image:
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.requirements == 'true'
steps:
- uses: actions/checkout@v4
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}
- uses: google-github-actions/setup-gcloud@v2
- run: gcloud auth configure-docker us-central1-docker.pkg.dev --quiet
- name: Build and push base image
working-directory: backend
run: |
docker build -f Dockerfile.base -t ${{ env.ARTIFACT_REGISTRY }}/prosody-api-base:latest .
docker push ${{ env.ARTIFACT_REGISTRY }}/prosody-api-base:latestEl trabajo de despliegue del backend requirió algunas pruebas y errores. Necesitaba esperar la compilación de la imagen base si se ejecutaba, pero también funcionar cuando se omitía. Terminé con esto:
deploy-backend:
runs-on: ubuntu-latest
needs: [detect-changes, build-base-image]
if: |
always() &&
needs.detect-changes.outputs.backend == 'true' &&
(needs.build-base-image.result == 'success' || needs.build-base-image.result == 'skipped')
steps:
# auth steps...
- name: Check if base image exists
id: check-base
run: |
if docker manifest inspect ${{ env.ARTIFACT_REGISTRY }}/prosody-api-base:latest > /dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Build base image if missing
if: steps.check-base.outputs.exists == 'false'
working-directory: backend
run: |
docker build -f Dockerfile.base -t ${{ env.ARTIFACT_REGISTRY }}/prosody-api-base:latest .
docker push ${{ env.ARTIFACT_REGISTRY }}/prosody-api-base:latest
- name: Build and push app image
working-directory: backend
run: |
docker build -f Dockerfile.fast -t ${{ env.ARTIFACT_REGISTRY }}/prosody-api:${{ github.sha }} .
docker push ${{ env.ARTIFACT_REGISTRY }}/prosody-api:${{ github.sha }}
- uses: google-github-actions/deploy-cloudrun@v2
with:
service: prosody-api
region: us-central1
image: ${{ env.ARTIFACT_REGISTRY }}/prosody-api:${{ github.sha }}
flags: --allow-unauthenticatedAprendí que always() es necesario allí. Sin él, el trabajo ni siquiera evaluará su condición si un trabajo anterior fue omitido. También agregué una comprobación con docker manifest inspect para manejar el caso en que la imagen base aún no existe, como en un primer despliegue o si alguien la eliminó accidentalmente.
La parte del frontend
El trabajo del frontend terminó siendo más simple ya que no depende en absoluto del backend:
deploy-frontend:
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.frontend == 'true'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- run: npm ci
working-directory: frontend
- run: npm run build
working-directory: frontend
- uses: FirebaseExtended/action-hosting-deploy@v0
with:
repoToken: ${{ secrets.GITHUB_TOKEN }}
firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_LESCOACH }}
channelId: live
projectId: lescoach
entryPoint: frontendSi solo cambió el frontend, este es el único trabajo que se ejecuta.
Configuración de autenticación
Elegí Workload Identity Federation en lugar de claves de cuenta de servicio. Requirió más configuración inicial, pero me gustó no tener que lidiar con la rotación de claves o preocuparme por archivos JSON guardados en secretos. GitHub obtiene tokens de corta duración a través de OIDC, lo que me pareció más limpio.
Dónde terminé
Los cambios solo en el frontend ahora se despliegan en menos de un minuto. Los cambios en el código del backend tardan unos 90 segundos. Los cambios en las dependencias todavía tardan unos 4 minutos, pero esos son raros para mí.

La mayoría de los días estoy ajustando cosas de la interfaz de usuario y ahora se despliega rápidamente. Todo el flujo de trabajo terminó siendo de unas 130 líneas de YAML. La acción paths-filter realiza la mayor parte del trabajo pesado para averiguar qué cambió.