Python para m-b-core/Prácticas/8 · Docker, pipeline y primer PR
Práctica 8 de 8

Docker, pipeline y tu primer PR en m-b-core

La imagen que corre sus propios tests antes de existir, la CI que dice que no, el cloudbuild.yaml que no pierde flags por el camino, y el ritual completo de un PR real al core: de git clone a "aprobado".

concepto · nada llega a producción sin que una máquina lo haya ejecutado antes2 sesionesdocker · GitHub Actions · Cloud Build · gcloud run · git
ConstruyesDockerfile builder/test/runtime, ci.yml, scripts/check_coverage.py, cloudbuild.yaml
AprendesPor qué los tests corren dentro del build, qué es un deploy declarativo, y cómo se ve un PR que el core aprueba
VerificadoLos tres docker build, el contenedor corriendo y el build que falla con un test roto
Terminas conTu primer PR abierto contra staging de m-b-core

Objetivos

  • Escribir un Dockerfile multi-stage cuya etapa test corre ruff, mypy y pytest: si algo falla, no hay imagen.
  • Dejar la imagen de runtime mínima: sin tests, sin env/, sin .git, corriendo como usuario sin privilegios.
  • Montar la CI de GitHub con los tres jobs de m-b-core (lint, typecheck, test con umbral) y el gate de cobertura por módulo.
  • Escribir un cloudbuild.yaml completo y declarativo: variables validadas, imagen por SHA, secretos por clave, servicio privado.
  • Entender por qué existe make deploy-force en m-b-core y por qué no debería.
  • Hacer el primer PR real: leer las reglas de la casa, elegir un test que falta, escribir el commit, pasar la revisión.

Antes de empezar

Necesitas el mini-repo con lo de las prácticas 1 y 3 al menos: pyproject.toml con la config de ruff/mypy, pytest.ini, requirements/base.txt y local.txt, Makefile, shared/{settings,errors}, el módulo availability_engine/ mínimo y el módulo locales/ con su main.py, router.py y tests. Docker Desktop corriendo, gh autenticado, y acceso de lectura al repo Mesa247/m-b-core para el último paso.

cd mesa-core-practicas
make tests && make lint && make typecheck     # todo verde antes de tocar Docker
docker run --rm hello-world | head -2
Por qué esta práctica va al final

Todo lo anterior (funciones puras, async, el módulo, el SQL con parámetros, las fechas, los errores) se puede hacer bien en tu laptop y aun así llegar mal a producción. Aquí está la parte que convierte "en mi máquina funciona" en "corre igual en Cloud Run": una imagen reproducible, un pipeline que puede fallar, y una configuración de servicio que está entera en el repo. Es también donde m-b-core tiene sus deudas más caras (vicios 29 y 39 de Criterio Mesa247), así que aquí aprendes con lo bueno y con lo que falta.

Construcción paso a paso

Paso 1 Decide qué NO entra al contexto de build

Docker manda al daemon todo lo que hay en la carpeta salvo lo que diga .dockerignore. Si no existe (o se llama .docker-ignore, como en l-home-restaurantes), tu env/, tu .git y tu .venv viajan a la imagen. Primero la lista negra, después el Dockerfile.

.dockerignore
# Nada de esto entra al contexto de build: ni secretos, ni entornos, ni basura.
.git
.venv
env/
*.db
.pytest_cache
.ruff_cache
.mypy_cache
.coverage
coverage.json
__pycache__
docs/
*.md

Y la plantilla de entorno que sí se versiona (sin valores), para que env/local.env exista en tu máquina y nunca en git:

env/local.env.template
# Plantilla: copiar a env/local.env (ignorado por git) y rellenar. NUNCA valores reales aquí.
ENV=dev
APP_VERSION=dev
DB_URL=postgresql+asyncpg://USER:PASSWORD@localhost:5440/mesa_core
printf 'env/*.env\n!env/*.env.template\n' >> .gitignore
git status --short
Por qué importa tanto. En m-b-core el .gitignore tiene una excepción a propósito (!/env/local.env) y por eso hay dos .env con valores reales en el repo, y la etapa test del Dockerfile hace COPY . /app: esos secretos entran a la imagen de test. Aquí lo hacemos al revés desde el primer día: la plantilla se versiona, el archivo real no, y el contexto de build no lo contiene.

Paso 2 El Dockerfile en tres etapas

La misma forma que services/api/Dockerfile de m-b-core: builder instala dependencias en un venv, test hereda ese venv, agrega las de test y corre los gates, runtime copia solo el venv y el código que se ejecuta. Dos diferencias a propósito respecto al de m-b-core: aquí la etapa test corre también ruff y mypy (no solo pytest), y la de runtime tiene USER app.

Dockerfile
# syntax=docker/dockerfile:1
# ---------------------------
# Stage 1: builder
# ---------------------------
FROM python:3.12-slim AS builder
ENV PYTHONUNBUFFERED=1
WORKDIR /build
COPY requirements /build/requirements
RUN python -m venv /opt/venv \
 && /opt/venv/bin/pip install --no-cache-dir --upgrade pip \
 && /opt/venv/bin/pip install --no-cache-dir -r /build/requirements/base.txt

# ---------------------------
# Stage 2: test (reusa el venv del builder, solo agrega deps de test)
# ---------------------------
FROM builder AS test
RUN /opt/venv/bin/pip install --no-cache-dir -r /build/requirements/local.txt
WORKDIR /app
COPY . /app
ENV PYTHONPATH=/app \
    ENV=test \
    PATH="/opt/venv/bin:$PATH"
# Los tres gates dentro del build. Un fallo aquí corta el pipeline antes del push.
RUN ruff check . && ruff format --check . \
 && mypy services/api/app/availability_engine/ shared/ \
 && pytest --cov=services/api/app --cov=shared --cov-fail-under=70

# ---------------------------
# Stage 3: runtime
# ---------------------------
FROM python:3.12-slim AS runtime
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION} \
    PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PATH="/opt/venv/bin:$PATH" \
    PYTHONPATH=/app
WORKDIR /app
COPY --from=builder /opt/venv /opt/venv
# Solo lo que se ejecuta: nada de tests, env/, docs ni .git
COPY shared /app/shared
COPY services/api /app/services/api
# Usuario sin privilegios (m-b-core aún corre como root: es un pendiente conocido)
RUN useradd --system --uid 10001 --no-create-home app \
 && rm -rf /app/services/api/app/*/tests /app/services/api/app/conftest.py
USER app
EXPOSE 8080
CMD ["uvicorn", "services.api.app.main:app", "--host", "0.0.0.0", "--port", "8080", "--workers", "1"]

Agrega dos targets al Makefile para no memorizar flags:

Makefile (agregar)
# La imagen corre los tests dentro del build: si fallan, no hay imagen.
docker-test:
	docker build --target test -t mesa-core-practicas:test .

docker-build:
	docker build --target runtime --build-arg APP_VERSION=$$(git rev-parse --short HEAD) -t mesa-core-practicas:local .
make docker-test
Deberías ver (recortado)
#11 [test 1/4] RUN /opt/venv/bin/pip install --no-cache-dir -r /build/requirements/local.txt
#13 [test 3/4] COPY . /app
#14 [test 4/4] RUN ruff check . && ruff format --check .  && mypy services/api/app/availability_engine/ shared/  && pytest --cov=services/api/app --cov=shared --cov-fail-under=70
#14 0.100 All checks passed!
#14 0.109 23 files already formatted
#14 2.395 Success: no issues found in 10 source files
#14 3.070 Required test coverage of 70% reached. Total coverage: 97.59%
#14 3.070 9 passed in 0.41s
#14 DONE 3.1s

Ahora la imagen de verdad, etiquetada con el commit, y un contenedor corriendo como si fuera producción (ENV=prod):

make docker-build
docker images mesa-core-practicas --format '{{.Repository}}:{{.Tag}} {{.Size}}'
docker run -d --rm --name mcp -p 8087:8080 -e ENV=prod mesa-core-practicas:local
curl -s localhost:8087/health; echo
curl -s -o /dev/null -w "docs=%{http_code}\n" localhost:8087/docs
curl -s localhost:8087/v1/locales/11; echo
docker exec mcp id
docker exec mcp sh -c 'ls /app; ls /app/services/api/app/locales; ls /app/env'
docker rm -f mcp
Deberías ver
mesa-core-practicas:local 325MB
mesa-core-practicas:test 513MB
{"status":"ok","version":"5ff3a49"}
docs=404
{"id":11,"nombre":"Central Lima","pais":"PE","zona_horaria":"America/Lima"}
uid=10001(app) gid=999(app) groups=999(app)
services
shared
__init__.py  __pycache__  queries.py  routes.py  schemas.py  utils.py
ls: cannot access '/app/env': No such file or directory
Lee esas líneas como un revisor. La versión que corre es el SHA que construiste (/health te lo dice: así se sabe qué está desplegado). /docs da 404 porque ENV=prod (m-b-core lo apaga en worker y bridge, pero la API de prod lo deja público). El proceso corre como app, no root. Dentro de locales/ no hay carpeta tests/, y /app/env no existe: la imagen de runtime lleva 190 MB menos que la de test porque no lleva nada que no ejecute.

Paso 3 Un test roto corta el build (compruébalo)

Que los tests estén "dentro del build" es una frase hasta que lo ves fallar. Rompe un test a propósito y construye:

sed -i '' 's/assert r.status_code == 404/assert r.status_code == 200  # roto a propósito/' services/api/app/locales/tests/test_routes.py
make docker-test; echo "exit=$?"
git checkout services/api/app/locales/tests/test_routes.py
Deberías ver (rojo, y es lo que queremos)
#14 3.903 E   assert 404 == 200
#14 3.903 FAILED services/api/app/locales/tests/test_routes.py::test_404 - assert 404 =...
#14 3.903 1 failed, 8 passed in 0.54s
#14 ERROR: process "/bin/sh -c ruff check . && ruff format --check .  && mypy … && pytest …" did not complete successfully: exit code: 1
exit=1

No hay imagen :test nueva, y por tanto en el pipeline no habría push ni deploy. Esto es lo que en los repos PHP no existe: allí cloudbuild.yaml hace build → push → deploy y un dd() llega a producción.

Paso 4 La CI en GitHub: tres jobs y actions por SHA

Calcado de .github/workflows/ci.yml de m-b-core: lint, typecheck, test. Fíjate en que las actions se referencian por SHA con el tag en comentario: un tag lo pueden mover; un SHA, no.

.github/workflows/ci.yml
name: CI

on:
  push:
    branches: [prod, staging]
  pull_request:
    branches: [prod, staging]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      # Actions pineadas por SHA (no por tag): un tag se puede mover, un SHA no.
      - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd  # v5
      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6
        with:
          python-version: "3.12"
      - run: pip install ruff==0.6.9
      - run: ruff check .
      - run: ruff format --check .

  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd  # v5
      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6
        with:
          python-version: "3.12"
      - run: pip install -r requirements/local.txt
      - run: mypy services/api/app/availability_engine/ shared/ --explicit-package-bases

  test:
    runs-on: ubuntu-latest
    env:
      PYTHONPATH: ${{ github.workspace }}
    steps:
      - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd  # v5
      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6
        with:
          python-version: "3.12"
      - run: pip install -r requirements/local.txt
      - name: Tests con umbral de cobertura
        run: |
          python -m pytest services/api/app shared \
            --cov=services/api/app --cov=shared \
            --cov-report=term-missing --cov-report=json:coverage.json \
            --cov-fail-under=70 -q
      - name: Gate por módulo (motor ≥ 90 %)
        run: python3 scripts/check_coverage.py coverage.json
Lo que la CI de m-b-core hace igual y lo que no. Igual: los tres jobs, --cov-fail-under=70, el gate por módulo, Sonar como job no bloqueante. Distinto: allí mypy corre solo sobre availability_engine/ (1 de 32 módulos) y bridge/worker no tienen tests en CI. Cuando amplíes la CI en un repo real, se hace un módulo por mes: agregar todo de golpe da 400 errores y nadie los arregla.

Paso 5 El gate de cobertura por módulo

Un umbral global (70 %) deja pasar un motor sin tests si el resto compensa. m-b-core tiene scripts/check_coverage.py con umbrales por carpeta (motor ≥ 90 %, módulos de lectura ≥ 70 %). Escribe la versión mínima:

scripts/check_coverage.py
#!/usr/bin/env python3
"""Gate de cobertura por módulo (versión mínima del scripts/check_coverage.py de m-b-core).

Uso: python3 scripts/check_coverage.py coverage.json
Salida 0 si todos los módulos cumplen; 1 si alguno no; 2 si el reporte falta.
"""

from __future__ import annotations

import json
import sys
from pathlib import Path

GATES: list[tuple[str, float]] = [
    ("services/api/app/availability_engine/", 90.0),
    ("services/api/app/locales/", 70.0),
]


def main(path: str) -> int:
    report = Path(path)
    if not report.exists():
        print(f"reporte no encontrado: {path}", file=sys.stderr)
        return 2
    files = json.loads(report.read_text())["files"]
    ok = True
    for prefix, minimum in GATES:
        covered = total = 0
        for name, data in files.items():
            if prefix in name and "/tests/" not in name:
                covered += data["summary"]["covered_lines"]
                total += data["summary"]["num_statements"]
        pct = 100.0 * covered / total if total else 100.0
        status = "ok " if pct >= minimum else "BAJO"
        print(f"{status} {prefix:<45} {pct:5.1f}% (mínimo {minimum:.0f}%)")
        ok = ok and pct >= minimum
    return 0 if ok else 1


if __name__ == "__main__":
    sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "coverage.json"))
PYTHONPATH=. .venv/bin/pytest --cov=services/api/app --cov=shared --cov-report=json:coverage.json -q
.venv/bin/python scripts/check_coverage.py coverage.json; echo "exit=$?"
Deberías ver
ok  services/api/app/availability_engine/         100.0% (mínimo 90%)
ok  services/api/app/locales/                      96.9% (mínimo 70%)
exit=0

Este mismo comando es el que vas a usar en el paso 8 sobre m-b-core para encontrar dónde falta un test: es el mapa de tu primer PR.

Paso 6 El cloudbuild.yaml completo y declarativo

Este archivo no lo vas a ejecutar aquí (necesita un proyecto GCP, un trigger y secretos): lo escribes y lo entiendes línea por línea, porque el de m-b-core (services/api/cloudbuild.yaml) es el que despliega el core y tiene tres cosas que la auditoría pidió cambiar. La forma es la misma; las diferencias están comentadas.

cloudbuild.yaml
# Pipeline de despliegue (mismo esqueleto que services/api/cloudbuild.yaml de m-b-core, con
# tres correcciones que la auditoría del 16-ago pidió: variables obligatorias validadas,
# secretos por --set-secrets y sin --allow-unauthenticated/ingress abierto).
#
# Se dispara desde un trigger de Cloud Build en la rama `staging` o `prod`.
# El trigger define las variables _ENV, _PROJECT_ID, _REGION, _IMAGE, _SERVICE,
# _LEGACY_SQL_INSTANCE, _DB_SECRET, _JWT_SECRET. Ninguna tiene default aquí.

steps:
  # 0) Validar variables del trigger ANTES de gastar minutos de build.
  #    Incluye _LEGACY_SQL_INSTANCE: si falta, `gcloud run deploy` desplegaría SIN
  #    --add-cloudsql-instances y, como el comando es declarativo, la nueva revisión
  #    perdería el conector de Cloud SQL (causa raíz del incidente del 16-ago-2026).
  - id: validate-vars
    name: gcr.io/google.com/cloudsdktool/cloud-sdk:slim
    entrypoint: bash
    args:
      - -c
      - |
        set -euo pipefail
        for v in _ENV _PROJECT_ID _REGION _IMAGE _SERVICE _LEGACY_SQL_INSTANCE _DB_SECRET _JWT_SECRET; do
          eval "val=\$$v"
          if [ -z "$$val" ]; then echo "ERROR: $$v must be set in the trigger."; exit 1; fi
        done

  # 1) Etapa test: corre ruff + mypy + pytest DENTRO del build. Si falla, el pipeline muere aquí.
  - id: test
    name: gcr.io/cloud-builders/docker
    args: ["build", "--target", "test", "-t", "${_IMAGE}:test", "."]

  # 2) Imagen de runtime, etiquetada por SHA (inmutable). Nunca :latest para desplegar.
  - id: build
    name: gcr.io/cloud-builders/docker
    args:
      - build
      - --target=runtime
      - --build-arg=APP_VERSION=$SHORT_SHA
      - -t
      - ${_IMAGE}:$SHORT_SHA
      - .

  - id: push
    name: gcr.io/cloud-builders/docker
    args: ["push", "${_IMAGE}:$SHORT_SHA"]

  # 3) Migraciones antes del deploy, con la MISMA imagen que se va a desplegar.
  #    La URL de la BD viene de un secreto; alembic la enmascara con
  #    make_url(...).render_as_string(hide_password=True), nunca con split('@')[0].
  - id: migrate
    name: gcr.io/google.com/cloudsdktool/cloud-sdk:slim
    entrypoint: bash
    args:
      - -c
      - |
        set -euo pipefail
        DB_URL="$$(gcloud secrets versions access latest --secret=${_DB_SECRET})"
        docker run --rm -e DB_URL="$$DB_URL" -e PYTHONPATH=/app "${_IMAGE}:$SHORT_SHA" alembic upgrade head

  # 4) Deploy declarativo y COMPLETO: cada flag que omitas se pierde en la nueva revisión.
  #    Secretos por clave (--set-secrets), no un .env volcado a --env-vars-file.
  #    Servicio privado detrás del balanceador: sin --allow-unauthenticated, ingress restringido.
  - id: deploy
    name: gcr.io/google.com/cloudsdktool/cloud-sdk:slim
    entrypoint: gcloud
    args:
      - run
      - deploy
      - ${_SERVICE}
      - --image=${_IMAGE}:$SHORT_SHA
      - --region=${_REGION}
      - --platform=managed
      - --no-allow-unauthenticated
      - --ingress=internal-and-cloud-load-balancing
      - --add-cloudsql-instances=${_LEGACY_SQL_INSTANCE}
      - --set-env-vars=ENV=${_ENV},APP_VERSION=$SHORT_SHA
      - --set-secrets=DB_URL=${_DB_SECRET}:latest,JWT_SECRET=${_JWT_SECRET}:latest
      - --min-instances=0
      - --max-instances=10
      - --concurrency=30

options:
  logging: CLOUD_LOGGING_ONLY
substitutions:
  _ENV: ""            # sin defaults: la validación del paso 0 exige que el trigger los defina
  _PROJECT_ID: ""
  _REGION: ""
  _IMAGE: ""
  _SERVICE: ""
  _LEGACY_SQL_INSTANCE: ""
  _DB_SECRET: ""
  _JWT_SECRET: ""
Las tres diferencias con m-b-core, y por qué. (1) _LEGACY_SQL_INSTANCE está en la lista de variables obligatorias: en services/api/cloudbuild.yaml:286-290 el flag solo se agrega "si el trigger define _LEGACY_SQL_INSTANCE; en prod no está definida", y como gcloud run deploy es declarativo, omitirlo no "deja como estaba": borra el conector de la nueva revisión (la revisión 00436 se quedó sin acceso a la BD legacy por eso). (2) Los secretos van por --set-secrets, clave por clave, en lugar de bajar el .env entero de Secret Manager a un archivo del workspace, hacerle head -5 a los logs y pasarlo con --env-vars-file (que los convierte en variables de entorno planas legibles por cualquiera con run.viewer). (3) El servicio nace privado y detrás del balanceador; el ingress abierto y el allUsers por defecto son exactamente lo que el incidente enseñó a no repetir. Sobre la máscara de alembic: en alembic/env.py:47 de m-b-core, DB_URL.split('@')[0] conserva justo usuario:contraseña y lo imprime en Cloud Build en cada migración; en la práctica 5 ya escribiste render_as_string(hide_password=True).

Paso 7 Ramas: lo que no debe existir en un repo de producción

m-b-core lo tiene escrito en CONTRIBUTING.md: prod solo recibe merges desde staging por PR, nunca push directo; CI en verde y una aprobación de un CODEOWNER antes de mergear. Y también dice, con honestidad: "estas reglas son convención del equipo, no enforced por GitHub… GitHub permitirá saltárselas, no lo hagas". Mientras tanto existe esto en el Makefile:

m-b-core/Makefile:205-217 (para leer, no para copiar)
deploy-force:
	# Force deploy by creating an empty commit and pushing to trigger Cloud Build
	@git checkout $$ENV 2>/dev/null || (echo "Branch $$ENV no existe"; exit 1)
	@git commit --allow-empty -m "force deploy to $$ENV [skip ci]" || true
	@git push origin $$ENV

Un target del repo que hace push directo a prod con [skip ci]: es literalmente el atajo que las reglas de arriba prohíben. Y el resultado medible: origin/prod y origin/staging divergen 1.072 commits. En tu mini-repo, y en cualquier repo nuevo, la regla es simple:

  • La rama de producción está protegida por la herramienta (una revisión + CI verde + sin push directo), no por el README. Si el plan de GitHub no lo permite, se paga el plan: es más barato que un deploy sin gate.
  • Nunca hay un comando en el repo que salte la CI. Un redeploy es un trigger manual de Cloud Build sobre el mismo SHA que ya pasó los tests, no un commit vacío.
  • La promoción stagingprod es frecuente y pequeña. Mil commits de diferencia no se revisan; se rezan.
git add -A
git commit -m "feat(infra): Dockerfile multi-stage con tests en el build, CI y cloudbuild"
gh repo create mesa-core-practicas --private --source . --push
gh api -X PUT repos/{owner}/mesa-core-practicas/branches/main/protection \
  -f required_status_checks.strict=true -f 'required_status_checks.contexts[]=test' \
  -f 'required_status_checks.contexts[]=lint' -f enforce_admins=true \
  -f required_pull_request_reviews.required_approving_review_count=1 -F restrictions=null 2>/dev/null \
  || echo "protección de rama: requiere plan Team en repos privados (verifícalo en Settings → Branches)"
Sobre la última línea. En un repo privado del plan gratuito la API devuelve 403; en uno público o con plan Team, aplica. Lo importante no es el comando: es que sepas dónde se activa y que lo compruebes en Settings → Branches del repo real antes de asumir que existe.

Paso 8 Tu primer PR en m-b-core

Todo lo anterior era el gimnasio. Esto es el partido. El primer PR al core tiene que ser pequeño, útil y aburrido: un test que falta. No un endpoint, no un refactor.

8.1 · Llegar

git clone git@github.com:Mesa247/m-b-core.git && cd m-b-core
git switch staging && git pull
cat CLAUDE.md                     # las 10 guidelines: léelas enteras, son dos pantallas
cat services/api/TESTING.md       # árbol de decisión y mock boundary: sin esto no escribas un test
cat CONTRIBUTING.md               # ramas, checks, aprobación
docker compose up -d postgres redis
docker compose run --rm api alembic upgrade head
docker compose up -d api

8.2 · Encontrar el hueco

docker compose exec -T api python3 -m pytest /app/services/api/ \
  --cov=/app/services/api/app --cov=/app/shared \
  --cov-report=term-missing --cov-report=json:coverage.json -q 2>&1 | tail -40
python3 scripts/check_coverage.py coverage.json

Busca en la salida un módulo de lectura (cities, listings, feed, search, collections) con líneas sin cubrir en un utils.py (función pura) o en un queries.py (una query sin test). Ese es tu PR. Antes de escribirlo, responde la pregunta de TESTING.md §0: "¿qué bug de producción atraparía este test si se rompiera?". Si la respuesta es "ninguno, solo ejecuta la línea", elige otro hueco.

8.3 · Escribir el test según el árbol

  • Función pura en utils.py@pytest.mark.parametrize con los bordes (vacío, None, valor límite), sin mocks.
  • Query en queries.py → fixture legacy_read_mock + monkeypatch.setattr(q, "legacy_db_read", legacy_read_mock) en el módulo (un nivel abajo, no la ruta), y asserts sobre la cláusula que lleva contrato y sobre last_params. Nunca assert sql == "SELECT …".
  • Ruta → TestClient + patch("services.api.app.<módulo>.routes.<fn>", new=AsyncMock(...)).
git switch -c test/cities-to-city-dto-sin-coords
# … escribes el test en services/api/app/cities/tests/test_utils.py …
docker compose exec -T api python3 -m pytest /app/services/api/app/cities -q
ruff check services/api/app/cities && ruff format --check services/api/app/cities
git add -A
git commit -m "test(cities): cubrir to_city_dto sin coordenadas y con Foto nula

Casos que faltaban en el gate de lectura (70 %): la ciudad sin lat/lng
debe salir sin distance_km y el placeholder de foto no debe romper el DTO."
git push -u origin test/cities-to-city-dto-sin-coords
gh pr create --base staging --fill

8.4 · Cómo se ve un PR que el core aprueba

Descripción del PR (ejemplo redactado)
## Qué
Dos tests de `cities/utils.py::to_city_dto` que faltaban: ciudad sin coordenadas
y ciudad con `Foto` nula (placeholder).

## Por qué
`check_coverage.py` marca `cities/` en 68 % (mínimo 70 %). Las dos ramas sin cubrir
son justo las que producen filas raras en prod (Colombia sin lat/lng; ciudades nuevas
sin foto). Si alguien cambia el fallback del placeholder, estos tests lo atrapan.

## Cómo probar
    docker compose exec -T api python3 -m pytest /app/services/api/app/cities -q

## Checklist
- [x] Función pura → parametrize sin mocks (TESTING.md §1)
- [x] Sin cambios de comportamiento; solo tests
- [x] ruff check / format limpios
- [x] Commit `test(cities): …` con el porqué
- [x] Puedo explicar cada línea

8.5 · Qué te van a revisar (y qué no)

  • Mock boundary: si mockeaste un nivel arriba, el test pasa con la query rota. Es lo primero que mira el core.
  • Asserts con contrato: cláusulas y params, no igualdad del SQL entero.
  • Que no toques comportamiento "de paso": un PR de tests es de tests. Lo otro, en otro PR.
  • Commit y descripción: tipo(ámbito), el porqué, cómo probarlo. Nada de "add tests".
  • No te van a revisar el formato: eso lo hace ruff. Si la CI está roja por formato, es un ruff format y push, no una discusión.

Cuando esté aprobado, mergea con squash desde GitHub, borra la rama, y en la siguiente sesión con el core cuenta qué te confundió: eso vuelve a esta práctica.

Entiende el código (y dónde está lo mismo en m-b-core)

Lo que escribisteEn m-b-coreQué mirar
Dockerfile builder / test / runtimeservices/api/Dockerfile (y bridge/worker)Misma forma. Allí la etapa test corre solo pytest -q y hace COPY . /app; la de runtime no tiene USER. Son dos mejoras pendientes, no un estilo distinto.
.dockerignore con env/.dockerignore (excluye site/, docs/, mkdocs.yml)Allí env/*.env sí entra al contexto y hay valores reales en git: por eso esta práctica empieza por el ignore.
ci.yml con lint / typecheck / test.github/workflows/ci.ymlActions por SHA, --cov-fail-under=70, scripts/check_coverage.py, Sonar no bloqueante. mypy solo del motor.
scripts/check_coverage.pyscripts/check_coverage.pyLos GATES reales: motor 90 %, feed/listings/search/home/cities/collections/profiling 70 %; health y admin fuera del gate.
cloudbuild.yaml validate → test → build → push → migrate → deployservices/api/cloudbuild.yamlMismo esqueleto (validación de variables en un bucle, migrate con la imagen recién construida, deploy con gcloud). Diferencias: _LEGACY_SQL_INSTANCE condicional (:286-290), fetch-env a archivo + --env-vars-file, comando de deploy construido por concatenación y eval.
Rama protegida, sin deploy-forceCONTRIBUTING.md, Makefile:205-217Las reglas están escritas y bien; falta que la herramienta las haga cumplir y que el atajo desaparezca.
/health con APP_VERSION = SHAservices/api/app/main.py (APP_VERSION desde env), cloudbuild.yaml --build-arg APP_VERSIONEs lo que te deja responder "¿qué está desplegado?" sin abrir la consola.

Tres ideas para llevarse

  • Un build verde no es un despliegue. Verifica siempre después: /health devuelve el SHA que esperas, los logs están limpios, el conector está (el post-mortem de parallevar del 24-jul lo dice con otras palabras: tres revisiones se desplegaron en verde sin recibir un solo request porque el tráfico estaba fijado a otra).
  • Declarativo significa completo. Con gcloud run deploy, lo que no dices se pierde. O el pipeline lo dice todo, o el servicio se describe en un YAML versionado (gcloud run services replace).
  • La imagen es lo único que viaja. Si el test corrió sobre otra imagen, sobre otro Python o sobre tu laptop, no probó lo que va a correr.

Con Claude

Para infra, la IA es muy buena escribiendo YAML plausible y muy mala sabiendo qué flags pierde tu servicio si los omite. Pídele explicaciones y diffs, y decide tú.

Pedidos que sí

Explícame el services/api/cloudbuild.yaml de m-b-core paso a paso: qué hace cada id, de dónde sale cada variable, y qué pasaría si _LEGACY_SQL_INSTANCE no estuviera definida en el trigger. Compara mi Dockerfile con el de m-b-core y dime las diferencias en una tabla; no cambies nada. Este es el coverage.json de m-b-core [pegar la parte de cities/]. ¿Qué líneas de utils.py están sin cubrir y qué caso de negocio representa cada una? No escribas los tests todavía. Revisa mi PR como lo haría el core según TESTING.md: ¿mockeé en el nivel correcto? ¿los asserts pinean contrato o el string entero?
Así noHazme el cloudbuild para desplegar a Cloud Run.Va a inventar flags, poner --allow-unauthenticated "para que funcione" y bajar el .env entero.
Así síToma el cloudbuild.yaml de esta práctica como base. Necesito agregar el servicio worker: mismo pipeline, distinto _SERVICE e imagen. Muéstrame solo el diff y dime qué variables nuevas tendría que definir el trigger.Base conocida, alcance acotado, tú controlas las variables.

Si algo falla

ModuleNotFoundError: No module named 'services' dentro de la etapa test

Falta ENV PYTHONPATH=/app (o WORKDIR no es /app). El paquete raíz no tiene __init__.py a propósito, igual que en m-b-core: se resuelve por PYTHONPATH, y por eso mypy lleva --explicit-package-bases.

La etapa test pasa en local pero coverage es más baja en Docker (o al revés)

Estás midiendo archivos distintos. Revisa [tool.coverage.run] omit en pyproject.toml y que .dockerignore no esté dejando fuera un módulo con tests. En m-b-core la cobertura se mide dentro del contenedor (docker compose exec -T api …) justamente para que sea la misma que la del pipeline.

docker run … mesa-core-practicas:local arranca pero /health devuelve "version":"dev"

Construiste sin --build-arg APP_VERSION=… (o con docker build a mano en vez de make docker-build). El ARG tiene default dev; en el pipeline lo inyecta $SHORT_SHA. Si en producción ves dev, el pipeline no lo está pasando.

PermissionError al escribir un archivo dentro del contenedor de runtime

Correcto: USER app no puede escribir en /app. Una API no debería escribir en disco (logs a stdout, archivos a un bucket). Si de verdad necesitas un directorio de escritura, créalo en el Dockerfile con chown app antes del USER, y que sea uno solo y explícito.

Rompí el test a propósito y el build siguió pasando

Docker usó la caché de la capa RUN pytest porque el COPY . /app no cambió… lo cual no puede ser si editaste el test. Verifica que editaste el archivo dentro del contexto (no en otra copia) y que .dockerignore no excluye tests/. Si dudas, docker build --no-cache --target test.

gh api … branches/main/protection devuelve 403 Upgrade to GitHub Team

Es exactamente la situación de m-b-core (CONTRIBUTING.md lo admite). En un repo privado del plan gratuito no hay protección de ramas por API ni por UI. Las opciones son pagar el plan o hacer el repo público; "confiar en que nadie haga push a prod" no es una opción para un repo de producción.

Mi PR a m-b-core tiene la CI roja en typecheck aunque no toqué el motor

mypy corre sobre services/api/app/availability_engine/; si tu test importa algo del motor con tipos incompletos puede aparecer. Corre en local exactamente el comando de ci.yml (mypy services/api/app/availability_engine/ --ignore-missing-imports --explicit-package-bases) antes de pushear.

Listo cuando

  • make docker-test pasa, y con un test roto a propósito falla y no produce imagen.
  • La imagen de runtime corre como app, no contiene tests/ ni env/, y /health devuelve el SHA del commit.
  • La CI del mini-repo tiene lint, typecheck y test con umbral, y scripts/check_coverage.py falla si bajas el motor de 90 %.
  • Puedes explicar, línea por línea, qué pierde un servicio de Cloud Run si el gcloud run deploy omite un flag, y por qué _LEGACY_SQL_INSTANCE es obligatoria.
  • Sabes por qué make deploy-force no debería existir y qué lo reemplaza.
  • Tienes un PR abierto contra staging de m-b-core con un test que falta, escrito según TESTING.md, con descripción y checklist.

Y después

Se acabaron las prácticas; empieza el trabajo. Las siguientes cosas que valen la pena en el core, por orden: (1) un segundo PR con una query sin cubrir en un módulo de lectura, mockeando en el binding local; (2) tests para un módulo del worker, que hoy no corre en CI; (3) proponer, con evidencia, uno de los cambios de esta práctica en el repo real (mypy en un módulo más, USER app en el Dockerfile, o la variable obligatoria en el pipeline), como PR pequeño y con el porqué escrito.