Come passare dai deploy locali a una CI/CD veloce con GitHub Actions
Sto costruendo una web app per l'allenamento della pronuncia per aiutarmi a migliorare la mia prosodia in inglese. Sono madrelingua spagnola e ho pensato di renderla disponibile nel caso potesse aiutare altri che imparano l'inglese come seconda lingua. Il backend è in Python con FastAPI, utilizza Parselmouth (un wrapper Python per Praat) per l'analisi audio e Google Gemini per generare feedback di coaching. Il frontend è React con Vite e TypeScript.
Per l'infrastruttura ho scelto Google Cloud. Il backend gira su Cloud Run, il frontend è ospitato su Firebase Hosting e i dati utente risiedono in Firestore. Le immagini Docker vengono caricate su Artifact Registry. Il codice si trova su GitHub. È una configurazione piuttosto standard per un progetto secondario che volevo mantenere entro i limiti del livello gratuito.
Il problema
Stavo distribuendo sia il frontend che il backend dalla mia macchina locale. Il frontend era abbastanza veloce, ma il backend era una seccatura. Ogni deploy significava ricostruire l'immagine Docker da zero, il che richiedeva 5-10 minuti mentre pip scaricava tutte le dipendenze Python.
Volevo passare al CI/CD in modo che i deploy avvenissero automaticamente quando facevo il push su main. Ma non volevo nemmeno aspettare 10 minuti per ogni push. Così mi sono messo a rendere GitHub Actions intelligente riguardo a ciò che era effettivamente cambiato.
La struttura monorepo
Tutto risiede in un unico repository con una cartella frontend/ e una cartella backend/. Il workflow si attiva al push su main e volevo che capisse cosa era cambiato e distribuisse solo quella parte. File del frontend modificati? Distribuisci solo su Firebase. Codice del backend modificato? Distribuisci solo su Cloud Run. E se requirements.txt fosse cambiato, ricostruisci l'immagine base di Docker. Ma solo allora.
Trovare un modo per rilevare le modifiche
Ho cercato in giro e ho trovato un'azione chiamata dorny/paths-filter. Controlla quali file sono cambiati in un push e restituisce valori booleani che altri job possono utilizzare. L'ho impostata come primo job nel mio workflow:
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'Questo mi ha fornito tre output a cui potevo fare riferimento in altri job per decidere se dovessero essere eseguiti.
Gestire le build Docker lente
Anche con il rilevamento delle modifiche funzionante, i deploy del backend erano ancora lenti. Ogni build eseguiva pip install da zero. Il mio requirements.txt contiene librerie ML, materiale per l'elaborazione audio, di tutto. Molto da scaricare.
Mi sono reso conto che forse modificavo le dipendenze una volta al mese. Il resto del tempo stavo solo modificando il codice dell'applicazione. Quindi ho provato a dividere il Dockerfile in due.
Dockerfile.base
Ho messo qui tutte le cose lente: pacchetti di sistema, dipendenze pip. L'idea era di costruire questo una volta e caricarlo su Artifact Registry, quindi riutilizzarlo.
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
Questo parte dall'immagine base e copia solo il codice. Niente apt-get. Niente 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"]Questo ha ridotto il tempo di build da 5-10 minuti a circa 30 secondi, il che è stato un bel miglioramento.
Mettere insieme il workflow
Ho impostato il job per l'immagine base in modo che venisse eseguito solo quando le dipendenze cambiavano effettivamente:
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:latestIl job di deploy del backend ha richiesto qualche prova ed errore. Doveva attendere la build dell'immagine base se veniva eseguita, ma funzionare anche se veniva saltata. Alla fine ho ottenuto questo:
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-unauthenticatedHo imparato che always() è necessario lì. Senza di esso, il job non valuterà nemmeno la sua condizione se un job precedente è stato saltato. Ho anche aggiunto un controllo docker manifest inspect per gestire il caso in cui l'immagine base non esista ancora, come in un primo deploy o se qualcuno l'ha cancellata accidentalmente.
La parte frontend
Il job del frontend è risultato più semplice poiché non dipende affatto dal 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: frontendSe è cambiato solo il frontend, questo è l'unico job che viene eseguito.
Configurazione Auth
Ho optato per Workload Identity Federation invece delle chiavi dell'account di servizio. C'è stata più configurazione iniziale, ma mi è piaciuto non dover gestire la rotazione delle chiavi o preoccuparmi dei file JSON nei segreti. GitHub ottiene token di breve durata tramite OIDC, il che mi è sembrato più pulito.
Dove sono arrivato
Le modifiche solo al frontend ora vengono distribuite in meno di un minuto. Le modifiche al codice del backend richiedono circa 90 secondi. Le modifiche alle dipendenze richiedono ancora circa 4 minuti, ma sono rare per me.

La maggior parte dei giorni sto modificando elementi dell'interfaccia utente e ora vengono distribuiti rapidamente. L'intero workflow è finito per essere circa 130 righe di YAML. L'azione paths-filter fa la maggior parte del lavoro pesante per capire cosa è cambiato.