El gate en CI: GitHub Actions
integrarás la suite de evals de Aurora en GitHub Actions para que corra en cada PR y falle el build ante una regresión de calidad, usando el mecanismo correcto (fail-on-threshold, no fail-on-error) y publicando el resultado como comentario en el PR. Sin esto, tu suite es un número que nadie mira antes de mergear.
4.1El PR que pasó porque nadie corrió los evals
Es viernes por la tarde. Un compañero abre un PR contra el repo de Aurora —la tienda online ficticia que te acompaña todo el curso—. El título: "prompt más cálido para el agente de soporte". Cambia el system prompt para que el bot suene menos robótico.
Tú ya hiciste el trabajo duro de los niveles previos. Tienes el dataset etiquetado del C1, el juez validado del C2 y la suite por arquitectura del C3. Esa suite corre verde en tu portátil y detectaría el reembolso fantasma en segundos.
El problema: el compañero no la corrió. No la conoce. Vive en tu carpeta, no en la suya.
El PR se mergea el viernes. El lunes, el reembolso fantasma vuelve —ahora en tres conversaciones distintas—. Tu suite habría cazado la regresión. Pero "habría" no frena un merge.
Aquí está la verdad incómoda del nivel: un eval que corre en tu máquina no gobierna nada. El gate no existe hasta que vive en CI y se dispara solo cuando alguien abre un PR. Hoy montamos esa pieza.
4.2Qué vas a poder hacer
Al terminar esta lección sabrás:
- Montar un workflow de GitHub Actions que corra la suite de evals de Aurora
on: pull_request. - Elegir el mecanismo que falla el build de verdad: el input
fail-on-thresholddel Action de promptfoo, ojq+exit 1desde el CLI. - Diagnosticar por qué
fail-on-errorNO bloquea una regresión de calidad, y corregir un workflow que lo use por error.
Necesitas saber antes:
- N4·L2 — el
promptfooconfig.yamlde Aurora y la ordennpx promptfoo@latest eval -c .... - N4·L3 — los evals como tests con
deepeval test runyassert_test. - Lectura básica de YAML y de exit codes de un proceso (
0= éxito,≠ 0= fallo).
Esta lección no ajusta umbrales todavía —eso es L5—. Aquí montamos el disparador. Un gate con umbrales mal puestos lo arreglamos después; primero necesita existir.
4.3Recupera
Antes de seguir, responde mentalmente. No mires lo de abajo hasta tener una respuesta.
- Del L2 y L3, ¿qué orden corre la suite de Aurora en local? (Hay dos caras de la misma moneda.)
- Cuando un comando de shell termina, deja un exit code. ¿Qué valor significa "todo bien" y qué valor significa "algo falló"?
- ¿Por qué CI marca un job en rojo cuando un comando devuelve exit code
≠ 0?
La respuesta a la 1: npx promptfoo@latest eval -c promptfooconfig.yaml (promptfoo) o deepeval test run test_aurora_evals.py (pytest + DeepEval). La 2: 0 = éxito, cualquier otro valor = fallo. La 3: CI ejecuta cada step como un proceso; si un step devuelve ≠ 0, el runner lo interpreta como fallo y pone el job en rojo. Ese exit code es el único lenguaje que CI entiende. Todo lo que viene ahora consiste en hacer que una regresión de calidad produzca ese ≠ 0.
4.4El concepto: del exit code al check obligatorio
Empecemos por lo concreto —un proceso que falla— y subamos hacia la pieza que de verdad bloquea un merge.
El gate es un check de estado, no un log
Un gate de CI es una comprobación automática que decide si un cambio puede entrar en la rama principal. En GitHub, esa comprobación aparece como un check de estado (status check) en el PR: un círculo verde o una cruz roja.
La analogía útil: el gate es un torno de metro. Si no validas el billete, no pasas. El límite de la analogía: el torno físico es infalible; el check de CI solo bloquea de verdad si activas branch protection que lo haga obligatorio. Volveremos a eso al final.
La cadena completa, de abajo arriba:
- La suite corre y mide la calidad del agente sobre el dataset versionado.
- Si hay regresión, el comando devuelve exit code
≠ 0. - El step de Actions falla → el job falla → el check del PR se pone rojo.
- Con branch protection, el check rojo bloquea el botón de merge.
La pieza que "gobierna" es el paso 4, no el log que escupe el paso 1. Un log rojo que nadie hace obligatorio es decoración.
El disparador: on: pull_request
Un workflow de GitHub Actions es un fichero YAML en .github/workflows/. Su clave on: define cuándo se dispara. Para un gate de calidad, el disparador es pull_request: cada vez que alguien abre o actualiza un PR, el workflow corre.
Es el cambio de raíz frente al L2/L3. Allí tú corrías la suite a mano. Aquí GitHub la corre por todos, sin que nadie tenga que acordarse. El PR del viernes ya no depende de la disciplina del compañero.
El error clásico: fail-on-threshold, NO fail-on-error
Esta parte es la que más gente equivoca, y el corpus la marca como corrección crítica. Léela despacio.
El Action oficial de promptfoo, promptfoo/promptfoo-action@v1, tiene un input que decide si el build falla: se llama fail-on-threshold (un número de 0 a 100). El Action falla si el pass rate —el porcentaje de tests que pasan— cae por debajo de ese número (corpus E.1).
No existe un input fail-on-error en este Action. Si lo escribes, GitHub lo ignora como input desconocido y tu gate no bloquea nada.
La confusión nace de mezclar dos cosas distintas:
- Un error de ejecución: la API key caducó, la red se cayó, la config tiene un typo. El eval ni siquiera llegó a medir.
- Una regresión de calidad: el eval corrió perfectamente y midió que la faithfulness del agente bajó del umbral. Esto es justo lo que quieres bloquear.
fail-on-threshold ataca el segundo caso —la regresión—. Un workflow que solo reacciona a errores de ejecución dejaría pasar el PR "prompt más cálido": ese PR no peta, baja la calidad sin más. Y bajar la calidad sin petar es exactamente el modo de fallo que montamos el gate para frenar.
Tres formas de producir el exit code ≠ 0
Tienes tres caminos equivalentes. Todos terminan en el mismo exit code que CI necesita:
- El Action de promptfoo con
fail-on-threshold: 80. Corre la suite y, además, publica la comparativa como comentario en el PR (corpus E.1). Es la vía con menos YAML. - El CLI de promptfoo +
jq: corresnpx promptfoo@latest eval -c ... -o results.json, parseas el JSON conjqy hacesexit 1si el pass rate cae bajo tu umbral. Patrón portable, no depende del Action. - pytest + DeepEval: corres
deepeval test run test_aurora_evals.py. Un test rojo (porqueassert_testlanzóAssertionError) produce exit code≠ 0por sí solo. El runner de pytest ya hace el trabajo (corpus E.2).
Un aviso antes del código. Las assertions model-graded —el juez del C2— gastan tokens cada vez que CI corre. Y la API key va por un secret de Actions, nunca escrita en el YAML. Quien lea el repo no debe ver tu clave.
4.5Míralo funcionar: el workflow de Aurora
Vamos a montar el gate de las tres formas. Lee primero cada bloque entero; después analizamos por qué cada línea está donde está.
Esta sección tiene YAML denso. La estrategia: fíjate primero en la clave on: (cuándo se dispara) y en la línea que produce el fallo (dónde está el gate). El resto es andamiaje de instalación.
Variante A — el Action de promptfoo con comentario en el PR
1# .github/workflows/evals.yml
2name: "Evals de Aurora — gate pre-deploy"
3
4on:
5 pull_request:
6 paths:
7 - "evals/**"
8 - "prompts/**"
9
10jobs:
11 evaluate:
12 runs-on: ubuntu-latest
13 permissions:
14 pull-requests: write # necesario para que el Action comente en el PR
15 steps:
16 - uses: actions/checkout@v4
17
18 - name: Cache promptfoo
19 uses: actions/cache@v4
20 with:
21 path: ~/.cache/promptfoo
22 key: ${{ runner.os }}-promptfoo-v1
23 restore-keys: |
24 ${{ runner.os }}-promptfoo-
25
26 - name: Run eval gate
27 uses: promptfoo/promptfoo-action@v1
28 with:
29 anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
30 github-token: ${{ secrets.GITHUB_TOKEN }}
31 config: "evals/promptfooconfig.yaml"
32 fail-on-threshold: "90" # falla si pass rate < 90%
33 cache-path: ~/.cache/promptfooLee el bloque y responde antes de seguir: ¿qué línea es el gate?
Es fail-on-threshold: "90". Sin ella, el Action correría la suite, dejaría un comentario bonito en el PR… y el check seguiría verde aunque la mitad de los tests fallaran. Esa línea es la que convierte el informe en una puerta.
El resto: on: pull_request dispara el workflow al abrir un PR. permissions: pull-requests: write autoriza al Action a escribir el comentario. anthropic-api-key lee la clave del juez desde un secret, no del fichero. El paso de caché evita re-pagar evals idénticos entre ejecuciones.
Auto-explicación: ¿por qué fail-on-threshold y no fail-on-error? Porque el PR "prompt más cálido" no produce ningún error de ejecución —corre perfecto—. Solo baja la calidad. fail-on-error (que ni existe como input) miraría el sitio equivocado; fail-on-threshold mira el pass rate, que es donde la regresión se nota.
Variante B — el CLI + jq (patrón portable)
A veces no quieres el Action: quieres control total o usas otra plataforma de CI. Entonces corres el CLI y construyes el gate a mano con jq, una herramienta de línea de comandos para parsear JSON.
1 - name: Run eval gate (CLI + jq)
2 env:
3 ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
4 run: |
5 npx promptfoo@latest eval -c evals/promptfooconfig.yaml -o results.json
6
7 PASS_RATE=$(jq '.results.stats.successes / (.results.stats.successes + .results.stats.failures) * 100' results.json)
8
9 if (( $(echo "$PASS_RATE < 90" | bc -l) )); then
10 echo "Gate fallido: pass rate ${PASS_RATE}% < 90%"
11 exit 1
12 fiAuto-explicación: ¿qué línea produce el exit code ≠ 0? El exit 1 dentro del if. Es explícito: tú decides el umbral (< 90) y tú lanzas el fallo. El JSON expone .results.stats.successes y .results.stats.failures —los paths verificados— y con ellos calculas el pass rate (corpus E.1).
Un matiz que evita un bug silencioso: el CLI de promptfoo devuelve exit code 100 cuando fallan tests, no 1. Si tu CI comprobara if [ $? -eq 1 ], no detectaría el fallo de tests. Por eso el patrón portable calcula el pass rate y hace su propio exit 1 —así no dependes del valor exacto que devuelva la herramienta—.
El campo .results.stats.failures cuenta assertions fallidas, no errores de ejecución. Es la misma distinción de antes, ahora en el JSON: el gate por jq mide regresión de calidad, igual que fail-on-threshold.
Variante C — pytest + DeepEval
Si tu equipo ya tiene pytest bloqueando merges, los evals encajan en esa infraestructura. El test del L3 corre como un step más.
1 - name: Run eval gate (pytest + DeepEval)
2 env:
3 ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
4 run: |
5 pip install deepeval
6 deepeval test run test_aurora_evals.pyAuto-explicación: ¿dónde está el gate aquí? No hay línea de exit 1 a mano. El gate vive dentro del test: assert_test(...) lanza AssertionError cuando una métrica cae bajo su threshold, pytest traduce esa excepción a exit code ≠ 0, y el step falla (corpus E.2). El runner de pytest ya es el mecanismo de gate; no necesitas añadir nada.
Las tres variantes terminan igual: una regresión produce exit code ≠ 0, el job se pone rojo, el check del PR se pone rojo. Cambia la herramienta, no el principio.
4.6Hazlo tú
Ejercicio 1 — andamiaje parcial: corrige el gate roto
Un compañero escribió este step para el gate de Aurora. Corre en cada PR, deja un comentario… y nunca ha bloqueado un merge, aunque la calidad bajó dos veces.
1 - name: Run eval gate
2 uses: promptfoo/promptfoo-action@v1
3 with:
4 anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
5 github-token: ${{ secrets.GITHUB_TOKEN }}
6 config: "evals/promptfooconfig.yaml"
7 fail-on-error: "true"Hay dos problemas. Identifícalos y escribe la línea corregida:
- Problema 1: el input
fail-on-error__________ (pista: ¿existe en este Action?). - Problema 2: aunque existiera, miraría __________ en vez de la regresión de calidad.
- Línea corregida:
__________
Ejercicio 2 — autónomo: el gate por CLI desde cero
Tu equipo no usa el Action. Corres el CLI y guardas el resultado en results.json. Escribe desde cero el bloque de shell que:
- parsea
results.jsonconjqpara contar las assertions fallidas (.results.stats.failures), - hace
exit 1si hay alguna fallida.
No mires la solución antes de intentarlo. Pista: esta vez no necesitas calcular un porcentaje; te alcanza con comprobar si failures > 0.
Antes de seguir, una pregunta de interrogación elaborativa. Respóndela tú primero: ¿por qué fail-on-error dejaría pasar el PR "prompt más cálido", aunque el gate "funcione"?
Porque ese PR no produce ningún error de ejecución. El eval corre limpio: la API responde, la red va bien, la config es válida. Lo único que cambia es que la calidad medida baja. Un gate que solo reacciona a errores de ejecución mira el lugar equivocado —la regresión vive en el pass rate, no en una excepción—.
4.7Comprueba
Sin pistas. Aquí tienes tres workflows para el gate de Aurora. Decide cuál bloquea un merge ante una regresión de calidad y, para los otros dos, di qué línea hay que cambiar.
Workflow 1
1on: pull_request
2jobs:
3 eval:
4 runs-on: ubuntu-latest
5 steps:
6 - uses: actions/checkout@v4
7 - uses: promptfoo/promptfoo-action@v1
8 with:
9 anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
10 github-token: ${{ secrets.GITHUB_TOKEN }}
11 config: "evals/promptfooconfig.yaml"
12 fail-on-threshold: "85"Workflow 2
1on: pull_request
2jobs:
3 eval:
4 runs-on: ubuntu-latest
5 steps:
6 - uses: actions/checkout@v4
7 - run: npx promptfoo@latest eval -c evals/promptfooconfig.yaml -o results.json
8 - run: jq '.results.stats.failures' results.jsonWorkflow 3
1on: push
2jobs:
3 eval:
4 runs-on: ubuntu-latest
5 steps:
6 - uses: actions/checkout@v4
7 - run: deepeval test run test_aurora_evals.pyVer la respuesta razonada
El que bloquea es el Workflow 1. Tiene fail-on-threshold: "85": el Action falla el build si el pass rate cae bajo el 85%, y corre on: pull_request. Una regresión de calidad pone el check en rojo.
El Workflow 2 no bloquea. El step de jq imprime el número de fallos, pero no lo convierte en exit code. jq devuelve 0 (éxito) aunque imprima "3". Falta el gate: hay que comparar y hacer exit 1. La línea a añadir, por ejemplo:
1 - run: |
2 FAILS=$(jq '.results.stats.failures' results.json)
3 if [ "$FAILS" -gt 0 ]; then exit 1; fiEl Workflow 3 sí falla el build ante regresión (porque deepeval test run devuelve exit code ≠ 0 cuando un test cae) pero no es un gate de PR. Se dispara on: push, no on: pull_request: corre después de mergear, no antes. La línea a cambiar: on: push → on: pull_request.
Feedback formativo:
- Si acertaste el 1 y supiste explicar el 2 y el 3: dominas la distinción que sostiene el nivel —el gate vive en el exit code y en el disparador, no en el log—. La reutilizarás en L6 cuando demuestres un PR rojo y uno verde.
- Si marcaste el 2 como bloqueante: confundiste "imprime el dato" con "actúa sobre el dato".
jqmostrando "3" no es un fallo; un proceso que imprime y termina bien devuelve0. El siguiente paso: vuelve a la Variante B del §4.5 y localiza dónde está elexit 1que falta aquí. - Si marcaste el 3 como gate de PR válido: el mecanismo de fallo es correcto, pero el disparador no.
on: pushcorre tras el merge —el caballo ya escapó—. Un gate frena antes; relee §4.4, "El disparador:on: pull_request".
4.8Conecta
Vuelve al PR del viernes, "prompt más cálido".
Con el workflow del §4.5 montado, ese PR ya no depende de que el compañero se acuerde de correr nada. Al abrirlo, GitHub corre la suite del C3 sobre el dataset del C1. La faithfulness cae bajo el umbral. El check se pone rojo. El compañero lo ve en el PR en pocos minutos, no el lunes en un tuit.
Ya tienes el gate disparándose en cada PR. Pero un gate con umbrales mal puestos tiene dos formas de fallar: cría falsos rojos (bloquea PRs buenos por ruido del juez) o deja pasar regresiones reales. Ese es el trabajo del L5: fijar umbrales defendibles. Y en L6 lo demuestras de verdad —un PR rojo por una regresión inyectada y un PR verde por una mejora—, que es el checkpoint C4.
¿Dónde lo aplicarías en tu trabajo? Mira tu repo real. Si hoy alguien abriera un PR que degrada la calidad de tu sistema LLM, ¿algo lo frenaría antes del merge? Y la pregunta que separa un gate real de un adorno: ¿tienes branch protection que haga obligatorio el check? Un check rojo que se salta con "merge anyway" no gobierna —es decorativo—.
4.9Reflexiona
Tómate dos minutos. Estas preguntas consolidan más que releer.
- Con tus palabras: ¿por qué
fail-on-errorno frenaría una regresión de calidad, aunque el workflow "funcione"? - ¿Qué dos piezas tiene que tener un workflow para ser un gate de PR de verdad (no solo un informe)?
- ¿Qué sigue sin estar claro? Si es "¿qué número exacto pongo en el umbral?", es la pregunta correcta —la responde L5—.
Referencia rápida
- Gate de CI: comprobación automática que decide si un cambio entra en la rama principal. Aparece como un check de estado (rojo/verde) en el PR. Solo bloquea de verdad con branch protection.
- Disparador:
on: pull_requesten el workflow → la suite corre en cada PR, antes del merge. (on: pushcorre después, ya no es un gate.) - Action de promptfoo:
promptfoo/promptfoo-action@v1; inputsgithub-token,config,anthropic-api-key; publica la comparativa como comentario en el PR. El input que falla el build esfail-on-threshold(0–100), NOfail-on-error(este último no existe en el Action) — corpus E.1. - Gate por CLI +
jq:npx promptfoo@latest eval -c ... -o results.json→jq '.results.stats.failures' results.json→exit 1si> 0. El CLI devuelve exit code100(no1) cuando fallan tests; por eso conviene tu propioexit 1. - Gate por pytest/DeepEval:
deepeval test run test_aurora_evals.py;assert_testlanzaAssertionErrorbajo el umbral → exit code≠ 0→ job rojo — corpus E.2. - Error de ejecución ≠ regresión de calidad: la API caída es un error; la faithfulness que baja es una regresión. El gate vigila lo segundo.
- Secretos: la API key va por secret de Actions (
${{ secrets.ANTHROPIC_API_KEY }}), nunca escrita en el YAML.