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
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 .
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 vermesa-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.ymlname: 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 verok 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
staging → prod 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.
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.