Módulo 5: Apply On Merge The Cd Half
3. Encadenar jobs con `needs` y pasar el `plan` exacto
Descripción
Esta lección resuelve el problema central del módulo: apply.yml tiene que aplicar el mismo plan que una persona ya revisó en ci.yml —no uno nuevo, recalculado en el momento—. Vas a aprender needs:, el campo que ordena jobs dentro de un mismo workflow, y actions/upload-artifact/download-artifact, el mecanismo real que mueve un archivo de un job a otro —e incluso de un workflow a otro—. En el camino vas a encontrarte con dos hallazgos verificados hoy, corriendo esta guía: uno sobre cómo act simula artefactos en tu máquina, y otro sobre una limitación de red muy específica de Docker Desktop que hay que resolver antes de que nada de esto funcione.
Conexión con el módulo
La lección 2 te dio el disparador de apply.yml. Esta lección construye su estructura interna: cuántos jobs tiene, en qué orden corren, y cómo el segundo obtiene el archivo que el primero (o, más precisamente, ci.yml) generó. La lección 4 junta todo esto en el archivo completo, corrido de punta a punta.
Analogía: firmar el contrato exacto que revisaste, no una reimpresión
Imagina que revisas y firmas un contrato en papel, página por página, e inicialas cada cláusula. Si la otra parte, antes de archivar el contrato, lo reescribe desde cero —aunque jure que el contenido es "el mismo"— ya no tienes ninguna garantía de que lo que se archiva es lo que tú aprobaste: pudo cambiar una coma, un número, una cláusula entera, sin que nadie lo note. La única forma honesta de archivar un contrato es guardar exactamente las páginas que ya firmaste, no una reimpresión. actions/upload-artifact/download-artifact es ese archivo físico: ci.yml "firma" un plan (lo calcula y lo guarda como archivo binario), y apply.yml, en vez de volver a calcular uno nuevo, descarga exactamente ese mismo archivo y lo aplica tal cual.
Paso 1 — needs:, dentro de un mismo workflow
Ya viste, en el Módulo 2 (lección 2), que por defecto todos los jobs de un workflow corren en paralelo, sin ningún orden garantizado entre ellos — el orden solo existe si lo declaras explícitamente con needs:. Antes de construir el archivo real, confírmalo con un ejemplo mínimo:
name: needs-demo
on: workflow_dispatch
jobs:
fetch-reviewed-plan:
runs-on: ubuntu-latest
steps:
- run: echo "Step 1: this job runs first, nothing depends on it"
terraform-apply:
needs: fetch-reviewed-plan
runs-on: ubuntu-latest
steps:
- run: echo "Step 2: this job only starts after fetch-reviewed-plan succeeds"
act workflow_dispatch -j terraform-apply
act -j terraform-apply le pide a act un job específico — pero fíjate qué pasa cuando ese job declara needs::
Qué esperar (salida literal, ejecutada para escribir esta lección — act corre primero el job del que depende, aunque no lo pediste explícitamente):
[needs-demo/fetch-reviewed-plan] ⭐ Run Main echo "Step 1: this job runs first, nothing depends on it"
[needs-demo/fetch-reviewed-plan] | Step 1: this job runs first, nothing depends on it
[needs-demo/fetch-reviewed-plan] ✅ Success - Main echo "Step 1: this job runs first, nothing depends on it" [64.4995ms]
[needs-demo/fetch-reviewed-plan] 🏁 Job succeeded
[needs-demo/terraform-apply ] ⭐ Run Main echo "Step 2: this job only starts after fetch-reviewed-plan succeeds"
[needs-demo/terraform-apply ] | Step 2: this job only starts after fetch-reviewed-plan succeeds
[needs-demo/terraform-apply ] ✅ Success - Main echo "Step 2: this job only starts after fetch-reviewed-plan succeeds" [59.2ms]
[needs-demo/terraform-apply ] 🏁 Job succeeded
act -l también refleja el encadenamiento, con una columna que no habías visto usarse todavía: Stage.
act -l
Qué esperar (literal) — fíjate en la columna Stage: 0 para el job sin dependencias, 1 para el que declara needs::
Stage Job ID Job name Workflow name Workflow file Events
0 fetch-reviewed-plan fetch-reviewed-plan needs-demo needs-demo.yml workflow_dispatch
1 terraform-apply terraform-apply needs-demo needs-demo.yml workflow_dispatch
Un Stage mayor significa "corre después, y solo si el Stage anterior tuvo éxito" — si fetch-reviewed-plan fallara, terraform-apply no correría en absoluto, ni con act ni en GitHub real. Este es exactamente el comportamiento que apply.yml necesita: si algo sale mal descargando el plan revisado, aplicar cualquier cosa sería peor que no aplicar nada.
Un límite importante: needs: no cruza archivos
Aquí es donde vale la pena ser preciso, porque es fácil sacar la conclusión equivocada del Módulo 3 (lección 2). Ese módulo estableció, con una razón de seguridad sólida, que ci.yml y apply.yml son archivos separados, disparados por eventos distintos (pull_request contra push a main). needs:, sin embargo, únicamente ordena jobs dentro del mismo archivo, en la misma corrida de workflow — no existe ninguna sintaxis de GitHub Actions que le permita a un job de apply.yml decir needs: ci.yml/terraform-checks. Son corridas completamente independientes, en momentos distintos, potencialmente separadas por horas o días (el tiempo que un Pull Request tarda en revisarse).
Esto significa que la palabra "encadenar" de esta lección tiene dos capas distintas, y las dos son reales:
- Dentro de
apply.yml,needs:ordena sus propios jobs — el mecanismo que acabas de probar arriba. - Entre
ci.ymlyapply.yml, el encadenamiento no es conneeds:— es con un artefacto compartido, que un workflow sube y el otro, en una corrida completamente distinta, descarga. Esa es la pieza que construyes a continuación.
Paso 2 — Subir el plan desde ci.yml
Para que apply.yml tenga algo que descargar, ci.yml tiene que guardar el plan como un archivo binario aplicable —no el texto legible que ya conoces de plan-output.txt—. terraform plan acepta un flag que no habías usado hasta ahora: -out=, que guarda el plan calculado en un archivo que terraform apply puede tomar y aplicar exactamente tal cual, sin volver a calcular nada.
Extiende el step Terraform plan de ci.yml (Módulo 3, lección 6) con ese flag, manteniendo el archivo de texto que ya usas para el resumen:
- name: Terraform plan
run: tflocal plan -input=false -no-color -out=tfplan | tee plan-output.txt
-out=tfplan no cambia una sola línea de lo que ves en pantalla —el texto que tee captura en plan-output.txt es idéntico al de antes—; solo agrega, en paralelo, un archivo binario (tfplan) con el plan completo, listo para aplicarse.
Ahora agrega el step nuevo, al final de ci.yml, después de publicar el resumen:
- name: Upload the plan for apply.yml to use later
uses: actions/upload-artifact@v4
with:
name: terraform-plan
path: tfplan
retention-days: 5
actions/upload-artifact es una Action oficial de GitHub —de la misma familia que actions/checkout, ya conocida desde el Módulo 2— que empaqueta el archivo indicado en path: y lo guarda como un artefacto, asociado a esta corrida específica del workflow, con el nombre que le des en name: (aquí, terraform-plan — el identificador que apply.yml va a usar para encontrarlo). retention-days: 5 limita cuánto tiempo GitHub conserva el artefacto antes de borrarlo automáticamente — cinco días es más que suficiente para el tiempo típico que un Pull Request tarda en revisarse y fusionarse.
Un hallazgo real: el --artifact-server-addr que no resuelve por defecto
Antes de correr esto con act, hay un obstáculo de red que vale la pena nombrar con la misma honestidad que el Módulo 3 aplicó a skip_requesting_account_id. act simula el servicio de artefactos de GitHub con un servidor HTTP local, activado con el flag --artifact-server-path <carpeta> — sin este flag, act ni siquiera intenta arrancarlo, y cualquier step de upload-artifact/download-artifact falla. Corrido así, sin más:
act pull_request -e .github/act-events/pr-event.json -j terraform-checks --artifact-server-path ./.artifacts
Qué esperar (literal, verificado hoy contra act 0.2.89 corriendo en Docker Desktop):
Attempt 1 of 5 failed with error: Request timeout: /twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact. Retrying request in 3000 ms...
Attempt 2 of 5 failed with error: Request timeout: /twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact. Retrying request in 6263 ms...
❗ ::error::Failed to CreateArtifact: Failed to make request after 5 attempts: Request timeout
La causa, confirmada probando ambas configuraciones: por defecto, el servidor local de artefactos de act intenta escuchar en la dirección 172.16.0.2 (--artifact-server-addr, con ese valor fijo por defecto) — una dirección que, en Docker Desktop para macOS, el contenedor efímero del job no puede alcanzar de vuelta hacia el proceso de act en el host, incluso con network="host" declarado (una limitación de cómo Docker Desktop emula redes de tipo host en macOS, distinta de un Linux real). El resultado es el mismo tipo de fallo de conexión que ya conoces —un intento real, con reintentos reales, que no llega a ningún lado—, solo que esta vez el problema no es LocalStack: es el propio servidor de artefactos de act, inalcanzable desde dentro del contenedor.
La solución, verificada: pasa la dirección IP real de tu máquina en la red local, en vez de confiar en el valor por defecto:
# macOS
export ARTIFACT_ADDR=$(ipconfig getifaddr en0)
# Linux
export ARTIFACT_ADDR=$(hostname -I | awk '{print $1}')
act pull_request -e .github/act-events/pr-event.json -j terraform-checks \
--artifact-server-path ./.artifacts \
--artifact-server-addr "$ARTIFACT_ADDR"
Qué esperar (salida literal, ejecutada para escribir esta lección, extracto del step nuevo):
[ci/terraform-checks] ⭐ Run Main Upload the plan for apply.yml to use later
[ci/terraform-checks] | With the provided path, there will be 1 file uploaded
[ci/terraform-checks] | Artifact name is valid!
[ci/terraform-checks] | Beginning upload of artifact content to blob storage
[ci/terraform-checks] | Uploaded bytes 12484
[ci/terraform-checks] | Finished uploading artifact content to blob storage!
[ci/terraform-checks] | Artifact terraform-plan.zip successfully finalized. Artifact ID 2119430229
[ci/terraform-checks] | Artifact terraform-plan has been successfully uploaded! Final size is 12484 bytes.
[ci/terraform-checks] | Artifact download URL: https://github.com/nektos/act/actions/runs/1/artifacts/2119430229
[ci/terraform-checks] ✅ Success - Main Upload the plan for apply.yml to use later [1.280550167s]
[ci/terraform-checks] 🏁 Job succeeded
No es una dirección arbitraria que esta guía pida "porque sí": es tu propia máquina, en la interfaz de red que ya usa para todo el resto de tu tráfico local — el mismo tipo de dirección que verías con ipconfig/ifconfig, distinta en cada máquina, por eso se calcula con un comando en vez de escribirse fija en .actrc (que sí es portable entre máquinas, y por eso esta guía no agrega ahí una IP que solo tiene sentido en la tuya).
Un segundo hallazgo: por qué download-artifact encuentra el archivo sin pedir un run-id
--artifact-server-path guarda los artefactos subidos en disco, organizados por número de corrida — y aquí aparece una consecuencia directa de algo que ya sabes desde el Módulo 2 (lección 2): github.run_id es fijo en 1 bajo act, siempre, en cada invocación separada. Eso significa que, cuando ci.yml sube el artefacto (corrida con run_id=1) y, más tarde, en una invocación de act completamente distinta, apply.yml lo descarga (también con run_id=1), ambos están escribiendo y leyendo de la misma carpeta dentro de --artifact-server-path — sin que tengas que decirle a download-artifact de qué corrida específica traerlo.
.artifacts/
└── 1/ ← siempre "1" bajo act, sea cual sea el workflow
└── terraform-plan/
└── tfplan.zip ← subido por ci.yml, encontrado por apply.yml
Esto es honesto de nombrar con precisión, para no sacar una conclusión falsa: en GitHub real, cada corrida de workflow tiene un run_id genuinamente único —nunca 1 dos veces—, así que apply.yml, corriendo en un repositorio real, no encontraría el artefacto de ci.yml con un download-artifact simple como el que acabas de ver. Necesitaría el parámetro run-id: de actions/download-artifact@v4, apuntando explícitamente a la corrida de ci.yml que generó el plan aprobado —típicamente obtenido consultando la API de GitHub (gh run list, filtrando por el SHA del commit fusionado) o, en pipelines más elaborados, con el disparador workflow_run, que expone github.event.workflow_run.id automáticamente—. Esta guía no construye ese mecanismo de búsqueda —agregaría una llamada a la API de GitHub que no tiene sentido bajo act, sin una cuenta real—; lo nombra aquí, con precisión, para que sepas exactamente qué esta lección simplifica y por qué esa simplificación es específica de act, no un atajo válido en producción.
Paso 3 — Descargar el plan en apply.yml
Con eso entendido, el job fetch-reviewed-plan de apply.yml es deliberadamente simple: descarga el artefacto y confirma que llegó, sin hacer nada más.
jobs:
fetch-reviewed-plan:
runs-on: ubuntu-latest
steps:
- name: Download the plan reviewed in the pull request
uses: actions/download-artifact@v4
with:
name: terraform-plan
- name: Confirm the plan file arrived intact
run: |
test -s tfplan
echo "tfplan is present: $(wc -c < tfplan) bytes"
actions/download-artifact@v4, con solo name: (sin run-id:), busca en la corrida actual —bajo act, siempre 1, la razón exacta del hallazgo de arriba—. test -s tfplan es una verificación de shell mínima: falla si el archivo no existe o está vacío, una red de seguridad barata antes de que el job siguiente intente aplicar un plan que nunca llegó.
El segundo job, terraform-apply, también necesita el archivo —cada job de un workflow corre en su propio contenedor, sin compartir sistema de archivos con otros jobs, exactamente como ya viste en el Módulo 3 (lección 7) para steps de workflows distintos—, así que descarga el mismo artefacto una segunda vez, dentro de su propio contenedor:
terraform-apply:
needs: fetch-reviewed-plan
runs-on: ubuntu-latest
env:
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
AWS_DEFAULT_REGION: us-east-1
AWS_ENDPOINT_URL: http://host.docker.internal:4566
steps:
- name: Check out andes-cargo-infra
uses: actions/checkout@v4
- name: Set up Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.15.8"
- name: Download the plan reviewed in the pull request
uses: actions/download-artifact@v4
with:
name: terraform-plan
- name: Terraform init
run: terraform init -input=false
Puede parecer redundante descargar el mismo archivo dos veces —una en fetch-reviewed-plan, otra en terraform-apply—, pero tiene un propósito real: el primer job es una verificación barata y rápida que puede fallar temprano, sin gastar tiempo instalando Terraform, si el artefacto nunca llegó a existir. El segundo hace el trabajo real. Es el mismo principio de "barato antes de caro" que ya viste en ci.yml con fmt/validate antes de plan.
Errores comunes
Pensar que needs: puede referenciar un job de otro archivo (el error central de esta lección). Qué pasa: alguien escribe, en apply.yml, algo como needs: ci.yml/terraform-checks, esperando que eso encadene ambos workflows. Cómo detectarlo: GitHub Actions rechaza el YAML —needs: solo acepta IDs de jobs declarados en el mismo archivo—. Cómo corregirlo: recuerda la doble capa de esta lección — needs: ordena jobs dentro de un archivo; el encadenamiento entre ci.yml y apply.yml se hace con un artefacto compartido, nunca con needs: directamente.
Olvidar -out= y preguntarse por qué no hay nada que subir (de flujo). Qué pasa: alguien agrega el step de upload-artifact apuntando a tfplan, pero el step de Terraform plan sigue siendo el del Módulo 3, sin el flag -out=tfplan. Cómo detectarlo: upload-artifact falla con un error de "no such file or directory" — nunca se generó ningún archivo binario, solo el texto de plan-output.txt. Cómo corregirlo: confirma que el run: del step de plan incluye -out=tfplan, no solo la redirección a tee.
Confundir el --artifact-server-addr por defecto con un problema de LocalStack (de diagnóstico). Qué pasa: alguien ve un error de Request timeout en un step de upload-artifact/download-artifact, y asume que es el mismo tipo de fallo de conexión que ya vio con awslocal contra LocalStack —revisa si LocalStack está corriendo, pierde tiempo ahí—. Cómo detectarlo: el mensaje menciona explícitamente ArtifactService, no ningún endpoint de AWS ni host.docker.internal:4566. Cómo corregirlo: este es un problema de red entre el contenedor del job y el propio proceso de act, no entre el job y LocalStack — la solución es pasar --artifact-server-addr con tu IP real, como viste en esta lección, sin que LocalStack tenga nada que ver.
Ejercicios
Ejercicio 1 — Explica por qué needs: no resuelve todo el problema de esta lección. Sin mirar esta lección, explica a un colega por qué, aunque needs: existe y funciona, apply.yml sigue necesitando upload-artifact/download-artifact para obtener el plan de ci.yml.
Ver solución
Una respuesta completa suena, más o menos, así: "needs: solo ordena jobs dentro del mismo archivo de workflow, en la misma corrida — ci.yml y apply.yml son archivos distintos, disparados por eventos distintos, en momentos distintos (uno cuando se abre un Pull Request, el otro cuando se fusiona, potencialmente horas después). No existe ninguna sintaxis de needs: que cruce esa frontera. El único mecanismo real para mover un archivo de una corrida de workflow a otra es un artefacto compartido: ci.yml lo sube, apply.yml lo descarga, cada uno en su propia corrida independiente."
Ejercicio 2 — Diagnostica el hallazgo de --artifact-server-addr. Un colega, en Linux (no macOS), te dice que --artifact-server-path le funcionó sin necesitar pasar --artifact-server-addr explícitamente. ¿Es esto inconsistente con lo que aprendiste en esta lección?
Ver solución
No necesariamente — el problema verificado en esta lección es específico de cómo Docker Desktop emula redes de tipo host en macOS, donde network="host" no le da al contenedor acceso real a la interfaz de red del Mac. En un Linux real, donde network="host" sí comparte la pila de red del host de forma nativa, es plausible que la dirección por defecto (172.16.0.2) sí resuelva correctamente sin ningún ajuste. La lección de fondo —verificar en vez de asumir, y saber diagnosticar el error exacto (ArtifactService, no un endpoint de AWS) si aparece— aplica sin importar el sistema operativo.
Ejercicio 3 — Explica la simplificación del run_id fijo a alguien que va a usar esto en un repositorio real. Un colega quiere copiar el apply.yml de esta lección directamente a un repositorio real de GitHub. ¿Qué le advertirías sobre el step Download the plan reviewed in the pull request?
Ver solución
Le advertirías que, en un repositorio real, actions/download-artifact@v4 con solo name: (sin run-id:) busca en la corrida actual de apply.yml — que nunca tiene un artefacto llamado terraform-plan, porque ese artefacto lo sube una corrida distinta y anterior de ci.yml. Bajo act, esto funciona sin ajustes porque run_id siempre es 1 en ambas corridas — una coincidencia de la simulación, no un mecanismo real. En un repositorio real, necesitaría agregar run-id: apuntando explícitamente a la corrida de ci.yml que generó el plan aprobado, típicamente obtenido con la API de GitHub o el disparador workflow_run.
Resumen y siguiente paso
En esta lección aprendiste needs: como mecanismo de orden dentro de un mismo workflow, confirmado con act -l mostrando dos Stage distintos. Extendiste ci.yml con -out=tfplan y actions/upload-artifact@v4, y construiste el job fetch-reviewed-plan de apply.yml con actions/download-artifact@v4. En el camino, verificaste dos hallazgos reales: el --artifact-server-addr por defecto de act no resuelve de forma confiable bajo Docker Desktop en macOS (solución: tu IP real), y por qué download-artifact sin run-id: funciona bajo act específicamente porque run_id siempre es 1 —una simplificación de la simulación, no algo que valga en un repositorio real sin ajustes—.
Antes de avanzar deberías poder: explicar por qué needs: no puede cruzar archivos de workflow; escribir el par upload-artifact/download-artifact de memoria; y diagnosticar un error de ArtifactService sin confundirlo con un problema de LocalStack.
La lección 4 —manos a la obra— junta todo: el on de la lección 2, la estructura de jobs de esta lección, y el step final que de verdad corre terraform apply.
Recursos
- GitHub Docs —
needscontext — referencia oficial del camponeeds:. - GitHub — actions/upload-artifact y actions/download-artifact — los repositorios oficiales de ambas Actions, incluida la documentación del parámetro
run-id:para descargas entre corridas distintas. - nektosact.com — Artifacts — documentación oficial de
--artifact-server-path/--artifact-server-addr. - Terraform Docs — Command: plan (
-out) — el flag que convierte el plan en un archivo aplicable.