El 21 de octubre a las 18:31, producción quedó congelada en el commit 04e93f3. Durante las 75 horas siguientes cada push a main disparó el pipeline, cada run de Frontend CD terminó en success, y ninguna versión nueva llegó a Cloudflare Pages. No fue una caída: fue un pipeline que decía que sí con la cabeza mientras negaba con el job.
Síntoma: todo verde, nada desplegado
Lo detecté por un detalle lateral: el sitio en producción seguía sirviendo la sitekey de prueba de Turnstile (1x00000000000000000000AA), la que Cloudflare documenta para tests y que siempre deja pasar. Esa clave solo debería existir en el build de verificación, nunca en el bundle publicado. Si estaba en producción, el artefacto desplegado era viejo.
Abrí la pestaña de Actions y encontré lo esperado: una pila de runs de Frontend CD (Cloudflare Pages) con check verde. El último deploy canónico era de tres días atrás. El commit que yo veía en el repositorio no era el que servía el edge.
$ gh run view 1234567890 --json conclusion,jobs \
--jq '{run: .conclusion, jobs: [.jobs[] | {name, conclusion}]}'
{"run":"success","jobs":[{"name":"Desplegar a Cloudflare Pages","conclusion":"skipped"}]} Ahí está la trampa completa en una línea: el run decía success, el único job decía skipped. Cero pasos de deploy ejecutados, cero errores reportados.
Causa en GitHub Actions: el gate vivía en cada paso, no en el job
El CD se dispara con workflow_run cuando termina Frontend CI (Astro), porque quiero promover exactamente el artefacto que pasó los tests, no reconstruir otro. Para no desplegar un artefacto que no pasó la CI, el workflow tenía un paso llamado Decidir si toca desplegar que escribía un output, y todos los pasos posteriores llevaban la misma condición compuesta:
- name: Decidir si toca desplegar
id: gate
env:
CONCLUSION: ${{ github.event.workflow_run.conclusion }}
BRANCH: ${{ github.event.workflow_run.head_branch }}
run: |
if [ "$CONCLUSION" != "success" ]; then
echo "desplegar=false" >> "$GITHUB_OUTPUT"
echo "La CI terminó en '$CONCLUSION': no se despliega."
exit 0
fi
echo "desplegar=true" >> "$GITHUB_OUTPUT"
- name: Descargar el artefacto verificado
if: steps.gate.outputs.desplegar == 'true'
uses: actions/download-artifact@v4 La lógica de no desplegar era correcta. El problema es dónde vivía. Cuando la CI de origen fallaba, el paso del gate terminaba con exit 0 —un paso exitoso— y todos los demás pasos se saltaban. Para GitHub Actions, un job sin pasos fallidos es un job exitoso. El resultado: un despliegue inexistente se veía igual que un despliegue correcto.
El mismo patrón estaba copiado en los tres CD del repositorio: frontend, Worker y backend. Tres pipelines distintos reportando falsos verdes por la misma razón.
Los seis fallos que el verde tapaba
Al arreglar el gate, el rojo de la CI de origen quedó a la vista. No era un fallo: era una cadena en la que cada error tapaba al siguiente.
- Falso positivo de accesibilidad a 320 px. La regla
RNF-ACC-15medía elh1150 ms después de hacer scroll. Conscroll-behavior: smoothel navegador seguía animando yelementFromPointcaía sobre el banner, no sobre el título. Lo arreglé pidiendoscrollIntoView({behavior: 'instant'})y esperando a que elh1estuviera dentro del viewport antes de medir. Commit5c7e7ac; 3.850 aserciones locales, cero fallos. - Timeout del job. 25 minutos no alcanzaban para la suite completa más la auditoría responsive. Subí el límite a 60 y después recorté trabajo, que era la solución real.
- Tests hablando con el backend real. El bundle de CI apuntaba a
https://app.cuotito.comy enviaba un token Turnstile simulado que el backend rechazaba. Separé los builds: uno de verificación conPUBLIC_API_BASE_URL=/, sitekey dummy yTURNSTILE_ALLOW_TEST_KEY=1; y otro de producción, reconstruido con la sitekey real justo antes de validar y subir el artefacto. Commit0911a0b. - Dependencia circular en la evidencia.
verify-calidadejecutaba dentro de su batería averify-orquestacion, que consume elquality.jsonque calidad escribe al final. El orquestador medía la evidencia de la ejecución anterior. Lo saqué de la batería. - Rutas públicas escritas a mano.
RUTAS_PUBLICASlistaba los diez artículos del blog y el post nuevo no estaba, así que el smoke test fallaba por una ruta que sí existía. Ahora la lista se deriva desrc/content/blog/. Commita87a86d. - Formato. Biome tumbó un run por el formato de un array. Desde entonces
npm run format:checkantes de cada push.
El arreglo: condición de job y verificación del SHA en Cloudflare Pages
Moví la condición del paso al job. Un CD que no despliega ahora queda explícitamente en skipped, no disfrazado de verde, y el resumen del job lo dice:
jobs:
deploy:
name: Desplegar a Cloudflare Pages
runs-on: ubuntu-latest
if: >
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'main')
timeout-minutes: 20 Pero un job skipped no cambia el conclusion global del run: GitHub sigue reportando success si ningún job falló. Así que el gate solo no basta. La comprobación que vale es contra el estado real del deploy, no contra el color del run. La API de Cloudflare Pages expone el commit_hash de cada deployment; yo la consultaba a mano durante el diagnóstico y ahora es la primera cosa que reviso cuando dudo:
curl -s "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT/pages/projects/$PROYECTO/deployments?per_page=1" \
-H "Authorization: Bearer $CF_TOKEN" \
| jq -r '.result[0] | "\(.short_id) \(.commit_hash[0:7]) \(.created_on)"'
# 5eb48f92 706058e 2026-10-25T03:06:12Z Si el SHA del deployment no coincide con el HEAD de main, el pipeline no desplegó, diga lo que diga el dashboard.
Resultados
- 75 horas de producción congelada: del deploy
04e93f3(21 oct, 18:31) al deployment5eb48f92con el commit706058e(24 oct, 22:06). - 6 fallos encadenados corregidos entre
5c7e7ac,0911a0b,a87a86dy77deba9, más el cambio de gate (64426b5) que destapó los demás. - 3 workflows CD con el mismo antipatrón corregidos a la vez: frontend, Worker y backend.
- 3.850 aserciones locales en verde tras el fix del falso positivo de accesibilidad.
- 12 rutas verificadas en producción después del deploy; ruta inexistente devolviendo 404 como control negativo.
- CI del backend de ~35 min a ~6 min al ejecutar solo las etapas necesarias con
VERIFY_STAGES.
Lo que aprendí
- Un job skipped no es un job exitoso, pero GitHub lo cuenta como run exitoso. Nunca leo el check verde del workflow: miro los jobs con
gh run view --json jobsy confirmo que el job de deploy se ejecutó. - Las condiciones de despliegue van en el job, no repetidas en cada paso. Un gate con
exit 0es un silenciador, no un gate. - El color del pipeline no es evidencia de deploy. La evidencia es el SHA del deployment en la API del proveedor, comparado con el HEAD de la rama.
- Los errores encadenados necesitan arreglarse de uno en uno. Cada fix destapaba el siguiente; intentar adivinar todos a la vez solo alarga el rojo.
El pipeline completo y las decisiones de este repositorio están en github.com/daniel-alejandro-t. Si estás depurando un CD que dice verde sin desplegar, empieza por ahí: mira los jobs, no el run.