SEXTANTEcursos técnicos de IA
métodobackward-design
árbitroel dato
Entrar
N4 · El gate del deploy/L4

El gate en CI: GitHub Actions

Objetivo de maestría

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-threshold del Action de promptfoo, o jq + exit 1 desde el CLI.
  • Diagnosticar por qué fail-on-error NO bloquea una regresión de calidad, y corregir un workflow que lo use por error.

Necesitas saber antes:

  • N4·L2 — el promptfooconfig.yaml de Aurora y la orden npx promptfoo@latest eval -c ....
  • N4·L3 — los evals como tests con deepeval test run y assert_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.

  1. Del L2 y L3, ¿qué orden corre la suite de Aurora en local? (Hay dos caras de la misma moneda.)
  2. Cuando un comando de shell termina, deja un exit code. ¿Qué valor significa "todo bien" y qué valor significa "algo falló"?
  3. ¿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:

  1. La suite corre y mide la calidad del agente sobre el dataset versionado.
  2. Si hay regresión, el comando devuelve exit code ≠ 0.
  3. El step de Actions falla → el job falla → el check del PR se pone rojo.
  4. 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í 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: corres npx promptfoo@latest eval -c ... -o results.json, parseas el JSON con jq y haces exit 1 si 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 (porque assert_test lanzó AssertionError) produce exit code ≠ 0 por 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

yaml
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/promptfoo

Lee 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.

yaml
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          fi

Auto-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.

yaml
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.py

Auto-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.

yaml
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:

  1. parsea results.json con jq para contar las assertions fallidas (.results.stats.failures),
  2. hace exit 1 si 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

yaml
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

yaml
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.json

Workflow 3

yaml
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.py
Ver 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:

yaml
1      - run: |
2          FAILS=$(jq '.results.stats.failures' results.json)
3          if [ "$FAILS" -gt 0 ]; then exit 1; fi

El 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: pushon: 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". jq mostrando "3" no es un fallo; un proceso que imprime y termina bien devuelve 0. El siguiente paso: vuelve a la Variante B del §4.5 y localiza dónde está el exit 1 que falta aquí.
  • Si marcaste el 3 como gate de PR válido: el mecanismo de fallo es correcto, pero el disparador no. on: push corre 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-error no 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_request en el workflow → la suite corre en cada PR, antes del merge. (on: push corre después, ya no es un gate.)
  • Action de promptfoo: promptfoo/promptfoo-action@v1; inputs github-token, config, anthropic-api-key; publica la comparativa como comentario en el PR. El input que falla el build es fail-on-threshold (0–100), NO fail-on-error (este último no existe en el Action) — corpus E.1.
  • Gate por CLI + jq: npx promptfoo@latest eval -c ... -o results.jsonjq '.results.stats.failures' results.jsonexit 1 si > 0. El CLI devuelve exit code 100 (no 1) cuando fallan tests; por eso conviene tu propio exit 1.
  • Gate por pytest/DeepEval: deepeval test run test_aurora_evals.py; assert_test lanza AssertionError bajo 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.