learning-front

Nivel 9 · El ciclo completo: del commit al deploy

CI/CD con GitHub Actions

Automatizar tests, lint, build y despliegues en cada push y cada pull request: la anatomía de un workflow de GitHub Actions —jobs, steps, runners— y las piezas que lo hacen serio: caché de dependencias, secrets, matrix y artifacts.

En el Nivel 8 viste qué es la integración continua: la portería automática que corre tus tests en cada PR y bloquea el merge si algo falla. También viste de pasada un ci.yml mínimo. Lo que no viste es el cómo: qué hay dentro de ese fichero, qué significa cada línea y cómo se convierte en algo útil de verdad.

En el capítulo 1 de este nivel abriste Pull Requests con gh pr create. GitHub puede configurarse para que esos PRs no se puedan mergear hasta que el CI pase. El workflow que vas a aprender a escribir aquí es exactamente lo que bloquea ese merge. Vamos pieza a pieza.

La anatomía de un workflow#

Un workflow es un fichero YAML que vive en .github/workflows/ dentro de tu repositorio. GitHub lo lee automáticamente y lo ejecuta cuando ocurre el evento que le indiques. No necesitas instalar nada ni configurar ningún servidor: GitHub provisiona la máquina, ejecuta el workflow y te muestra el resultado en la pestaña “Actions” del repositorio.

Un workflow tiene cuatro piezas principales:

yaml
# Nombre visible en la pestaña Actions de GitHub
name: CI

# Cuándo se ejecuta este workflow
on: [push, pull_request]

# Permisos mínimos: este workflow solo lee el código, no publica nada.
# El principio de mínimo privilegio dicta que un workflow declara solo los permisos que necesita.
permissions:
  contents: read

# Lista de jobs que se ejecutan
jobs:
  # Nombre del job; se ve en el log de Actions
  test:
    # Runner: máquina virtual Ubuntu que GitHub provisiona
    runs-on: ubuntu-latest

    # Lista de pasos que ejecuta el job, en orden
    steps:
      # Cada step tiene un nombre y una acción o comando
      - name: Obtener el código
        uses: actions/checkout@v6

      - name: Ejecutar tests
        run: pnpm test

El flujo cuando haces push es el siguiente:

texto
push a GitHub


GitHub detecta el evento "push"


aprovisiona una máquina virtual Ubuntu limpia (el runner)


ejecuta los steps del job en orden

      ├── checkout@v6 → clona el repositorio
      ├── pnpm install → instala dependencias
      └── pnpm test → corre los tests


verde (pasa) o rojo (falla) junto al commit en GitHub

Cada elemento tiene un papel claro. name es el nombre que ves en la interfaz. on decide cuándo corre. jobs agrupa el trabajo. Los steps dentro de cada job son la secuencia exacta de comandos que se ejecuta en la máquina.

Cuándo se ejecuta: los triggers (on)#

El campo on define qué evento de GitHub dispara el workflow. Los más usados:

yaml
# Dispara en cada push a cualquier rama y en cada PR
on: [push, pull_request]

# Solo en pushes a main (ignora otras ramas)
on:
  push:
    branches: [main]

# Solo en PRs que apuntan a main
on:
  pull_request:
    branches: [main]

# Permite ejecutarlo a mano desde la interfaz de GitHub
on:
  workflow_dispatch:

push dispara el workflow cada vez que alguien sube commits al repositorio, sea la rama que sea. pull_request lo dispara cuando se abre un PR, cuando se actualiza con nuevos commits, o cuando se sincroniza con la rama base.

workflow_dispatch es útil para workflows de deploy manual: aparece como un botón “Run workflow” en la interfaz de GitHub Actions y te permite lanzar el workflow sin necesidad de hacer un push.

En el Team Builder, la combinación [push, pull_request] cubre el caso habitual: el CI corre en tu rama mientras trabajas (para que sepas si algo está roto antes de pedir revisión) y de nuevo cuando abres el PR (para que el revisor sepa que el código que revisa pasa las comprobaciones).

Dónde corre: runners y jobs#

Un runner es la máquina virtual que GitHub aprovisiona para ejecutar tu workflow. Cada vez que el workflow se dispara, GitHub arranca una máquina nueva, limpia, sin nada instalado salvo el sistema operativo y unas herramientas básicas. Tu workflow parte de cero cada vez.

yaml
jobs:
  # El job "lint" corre en su propio runner, independiente del resto
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - run: pnpm lint

  # El job "test" corre en otro runner, en paralelo con "lint"
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - run: pnpm test

  # El job "deploy" espera a que "test" y "lint" pasen antes de correr
  deploy:
    runs-on: ubuntu-latest
    # needs encadena jobs: deploy no empieza hasta que lint y test terminan en verde
    needs: [lint, test]
    steps:
      - run: echo "desplegando..."

Por defecto, todos los jobs de un workflow corren en paralelo en runners separados. Si necesitas que un job espere a otro, usas needs:. En el ejemplo anterior, deploy no empieza hasta que lint y test terminan en verde. Si cualquiera de los dos falla, deploy no corre.

ubuntu-latest es la opción más común y la más económica en GitHub Actions. También existen windows-latest y macos-latest para proyectos que necesitan probarse en esos sistemas.

Steps: uses frente a run#

Dentro de un job, los steps son los pasos concretos. Un step o usa una action reutilizable (con uses) o ejecuta un comando de shell (con run). No puede hacer las dos cosas a la vez.

yaml
steps:
  # uses: invoca una action publicada en GitHub Marketplace o en un repositorio
  # @v6 fija la versión; sin versión, tomaría la última, lo que puede romper el workflow
  - name: Descargar el código
    uses: actions/checkout@v6

  # uses con parámetros: la clave "with" pasa opciones a la action
  - name: Instalar pnpm
    uses: pnpm/action-setup@v4
    with:
      # Versión de pnpm que se instala
      version: 9

  # run: ejecuta cualquier comando de shell disponible en el runner
  - name: Instalar dependencias
    run: pnpm install --frozen-lockfile

  # run con varias líneas: el separador | ejecuta cada línea como un comando
  - name: Verificar calidad
    run: |
      pnpm lint
      pnpm test

Una action es código empaquetado para resolver un problema de infraestructura: clonar el repositorio, configurar una versión de Node, autenticarse con un registro de Docker, subir un fichero a S3. En vez de escribir esos comandos a mano cada vez, invocas la action y le pasas los parámetros que necesita.

actions/checkout@v6 es casi obligatoria en cualquier workflow: sin ella, el runner no tiene el código del repositorio. Es siempre el primer step.

Caché de dependencias: que el CI vuele#

Sin caché, cada run de CI arranca una máquina limpia y descarga todas las dependencias desde npm. En un proyecto con cien dependencias, eso son fácilmente sesenta segundos o más solo en el paso de instalación. Multiplicado por veinte PRs al día, son veinte minutos de espera que no aportan nada.

La caché de pnpm soluciona esto: GitHub guarda el store de pnpm entre runs y lo restaura si el lockfile no ha cambiado. Solo se descargan los paquetes nuevos o modificados.

yaml
- name: Instalar pnpm
  uses: pnpm/action-setup@v4
  with:
    version: 9

# cache: 'pnpm' activa la integración de caché automática para pnpm.
# actions/setup-node calcula un hash del pnpm-lock.yaml.
# Si ese hash coincide con un run anterior, restaura el store antes de instalar.
- name: Configurar Node.js con caché de pnpm
  uses: actions/setup-node@v6
  with:
    node-version: 24
    cache: 'pnpm'

- name: Instalar dependencias
  run: pnpm install --frozen-lockfile

El “¿y qué?” de ignorar esto: un CI que tarda un minuto y medio en instalar dependencias es un CI que el equipo deja de esperar. Cuando el CI ya no frena el flujo de trabajo, la gente empuja sin esperar el verde. La portería deja de funcionar en la práctica aunque técnicamente esté activa.

Con caché activa, el segundo run y los siguientes pasan la instalación en tres o cinco segundos. El CI vuelve a ser algo que el equipo espera porque no cuesta nada esperarlo.

Secrets: lo que nunca va en el YAML#

Muchos workflows necesitan credenciales: tokens de autenticación para publicar en un registro de npm, claves de API para notificar a un servicio externo, contraseñas de bases de datos de prueba. Nunca deben ir escritas en el YAML.

yaml
# MAL: el token queda en el historial de Git para siempre
- name: Publicar paquete
  run: npm publish --token ghp_MiTokenSecreto123

# BIEN: el token vive en la configuración cifrada del repositorio
- name: Publicar paquete
  run: npm publish --token ${{ secrets.NPM_TOKEN }}
  env:
    NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

Los secrets se crean en la configuración del repositorio (Settings → Secrets and variables → Actions). GitHub los cifra y los inyecta en el workflow en tiempo de ejecución. En los logs, cualquier valor que coincida con un secret aparece enmascarado como ***.

El “¿y qué?” de hardcodear un token: un repositorio que hoy es privado puede hacerse público mañana. Aunque no se haga público, cualquier persona con acceso al historial puede recuperar ese commit con el token expuesto. GitHub escanea los repositorios públicos en busca de tokens reconocibles y avisa a los proveedores correspondientes para revocarlos, así que en cuestión de minutos el token está comprometido e inutilizado. Es la misma regla del .env que aprendiste en el Nivel 0, aplicada al entorno de CI.

Nunca hardcodees un secret. Ni en un commit “de prueba que luego borro”.

Cada workflow debería declarar además los permisos mínimos que necesita con la clave permissions a nivel del workflow: un workflow de CI que solo lee código no necesita más que contents: read. Es el principio de mínimo privilegio aplicado al pipeline.

Matrix: probar en varias versiones a la vez#

Tu proyecto puede funcionar perfectamente en Node 22 y romperse en Node 24 por un cambio en alguna API del runtime. Si tu CI solo prueba en una versión, no lo sabrás hasta que el problema llegue a producción.

La matrix de estrategia resuelve esto: define un conjunto de valores y GitHub lanza un job por cada combinación, todos en paralelo.

yaml
jobs:
  ci:
    runs-on: ubuntu-latest

    # strategy.matrix define las combinaciones que se ejecutan en paralelo
    strategy:
      matrix:
        # Dos valores → dos jobs en paralelo, uno con Node 22 y otro con Node 24.
        # Node 22 es la LTS de mantenimiento y Node 24 la LTS activa
        # (Node 20 quedó fuera de soporte desde abril de 2026).
        node-version: [22, 24]

    steps:
      - uses: actions/checkout@v6

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      # ${{ matrix.node-version }} inyecta el valor de este job concreto:
      # en el job con Node 22 vale "22"; en el job con Node 24 vale "24"
      - uses: actions/setup-node@v6
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile
      - run: pnpm test

En la pestaña Actions verás dos jobs corriendo en paralelo: ci (22) y ci (24). Si uno falla, el check del commit queda en rojo aunque el otro haya pasado. Lo sabes antes de que llegue a producción.

Puedes añadir más ejes a la matrix (sistema operativo, versión de pnpm…) pero el número de jobs crece multiplicativamente: dos versiones de Node por dos sistemas operativos son cuatro jobs. Usa la matrix con criterio; para la mayoría de proyectos, dos versiones de Node es más que suficiente.

Artifacts: guardar lo que produce el build#

El build de producción (el dist/ que genera pnpm build) existe dentro del runner mientras el workflow corre. Cuando el run termina, el runner se destruye y todo lo que había dentro desaparece. Si un compañero necesita revisar ese build, tiene que hacer checkout y construirlo en local.

Los artifacts son la solución: un step al final del job sube el contenido que quieras al almacenamiento de GitHub, donde queda disponible para descarga durante el tiempo que configures.

yaml
# El build de producción se genera en el step anterior
- name: Construir bundle de producción
  run: pnpm build

# actions/upload-artifact sube el contenido de dist/ al almacenamiento de GitHub
- name: Subir dist/ como artifact
  uses: actions/upload-artifact@v4
  with:
    # Nombre del artifact; se ve en la pestaña Actions
    name: dist-produccion
    # Qué carpeta o fichero se sube
    path: dist/
    # Cuántos días se conserva antes de borrarse automáticamente
    retention-days: 7

En la pestaña Actions, al final del run, aparece el artifact como un fichero descargable. Cualquier miembro del equipo puede descargarlo sin necesidad de tener el código en local.

Los artifacts son también la base del CD: el paso siguiente no es descargarlo a mano, sino configurar otro job que lo publique automáticamente en un servidor. Pero eso llega en el capítulo siguiente.

De CI a CD#

CI (integración continua) responde a: ¿este código es correcto? Corre lint, tests y build en cada push y bloquea el merge si algo falla. Es la portería.

CD (despliegue continuo) responde a: ¿cuándo y cómo se publica? Una vez el CI pasa en main, CD automatiza el envío a producción. Puede ser GitHub Pages, Vercel, un servidor propio o cualquier plataforma de hosting.

En términos de workflow, el CD es otro job que usa needs: para esperar al CI:

yaml
# Permisos mínimos para el job de CI; el job de deploy necesitará permisos adicionales
permissions:
  contents: read

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - run: pnpm test
      - run: pnpm build

  # deploy no corre hasta que ci pase en verde
  deploy:
    needs: ci
    # Solo se despliega cuando el evento es un push a main,
    # no en cada PR ni en ramas de feature
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - run: echo "publicando en producción..."

El status check que bloquea el merge en los PRs del capítulo 1 es el job de CI. Cuando configuras la rama protegida en GitHub (Settings → Branches → Branch protection rules) y marcas el CI como check obligatorio, el botón de merge queda desactivado hasta que el workflow pasa en verde.

El cómo concreto del deployVercel, GitHub Pages, variables de entorno en producción— es el capítulo siguiente.

Buenas prácticas de seguridad del pipeline#

Gestionar secrets correctamente es el primer paso, pero hay dos prácticas adicionales que un pipeline serio debería incorporar. La primera es fijar las actions por el SHA del commit en vez de por tag: uses: actions/checkout@<sha-del-commit> en lugar de @v6. Un tag puede reasignarse a un commit malicioso sin que lo veas venir —como ocurrió en varios incidentes de cadena de suministro en los últimos años—; el SHA es inmutable. La segunda es añadir un paso de pnpm audit en el CI o activar Dependabot en el repositorio para que GitHub avise automáticamente cuando una dependencia tenga una vulnerabilidad conocida. El CI verifica que tu código funciona; estas dos prácticas verifican que el entorno desde el que corre y las dependencias que usa son de confianza.

Comprueba lo que sabes#

Pregunta 1 de 7

Evalúa el siguiente código y responde:

on: [push, pull_request]
/* PREGUNTA: ¿Cuándo se ejecuta un workflow con este trigger? */

Tu turno#

El ejercicio es un workflow de CI real: lo creas en tu repositorio local del Team Builder, haces commit y compruebas que GitHub lo detecta y ejecuta. Los ficheros de solución muestran el workflow completo de cada tier con comentarios que explican cada decisión. Haz el tuyo primero y luego compara.

Ejercicio · hazlo en local

CI con GitHub Actions para el Team Builder

Sobre el repositorio de tu Team Builder de React (el que vienes construyendo desde el Nivel 7), crea el fichero `.github/workflows/ci.yml` y comprueba que GitHub lo detecta y ejecuta automáticamente. Las soluciones muestran el workflow completo de cada tier con comentarios que explican cada decisión. Haz el tuyo primero y luego compara.

Paso 1: Portería mínima: tests en cada push y PR

  • Creas `.github/workflows/ci.yml` con un job que instala las dependencias con `pnpm install --frozen-lockfile` y ejecuta `pnpm test`.
  • El workflow se dispara en `push` y en `pull_request` (cualquier rama).
  • Usas `actions/checkout@v6`, `pnpm/action-setup@v4` y `actions/setup-node@v6` en ese orden.
  • Haces un commit y subes la rama a GitHub; compruebas en la pestaña Actions que el workflow se ejecuta y pasa.
  • Si los tests fallan, el commit aparece con el semáforo en rojo y el merge del PR queda bloqueado.

Cómo hacerlo en local

Clona el repositorio del curso, entra en la carpeta del ejercicio y abre el index.html en tu navegador. Toda tu solución va en solucion.js.

git clone <repo>
cd exercises/nivel-9/ci-cd-github-actions
# abre index.html en el navegador y edita solucion.js
Ver soluciones
# Tier ok — portería mínima
# Un solo job que instala dependencias y corre los tests.
# Si los tests fallan, GitHub marca el commit en rojo y bloquea el merge del PR.
# Límite de este tier: no lintea ni construye el bundle, así que un fallo de
# ESLint o un error de build pasan desapercibidos hasta que alguien los ve en local.

# Nombre visible en la pestaña "Actions" de GitHub
name: CI

# Cuándo se ejecuta este workflow:
# push — en cada commit que sube a cualquier rama
# pull_request — cuando se abre o actualiza un PR
on: [push, pull_request]

# Permisos mínimos: este workflow solo lee el código, no publica nada.
permissions:
  contents: read

jobs:
  # Nombre del job; se ve en el log de Actions
  test:
    # Runner: una máquina virtual Ubuntu que GitHub provisiona gratis
    runs-on: ubuntu-latest

    steps:
      # Paso 1: descarga el código del repositorio en la máquina virtual
      - name: Obtener el código
        uses: actions/checkout@v6

      # Paso 2: instala pnpm en la máquina virtual antes de usarlo
      - name: Instalar pnpm
        uses: pnpm/action-setup@v4
        with:
          # Versión de pnpm que se instala; debe coincidir con la de tu proyecto
          version: 9

      # Paso 3: instala Node.js con la versión especificada
      - name: Configurar Node.js
        uses: actions/setup-node@v6
        with:
          # Node 24 es la LTS activa en 2026
          node-version: 24

      # Paso 4: instala las dependencias exactas del lockfile
      # --frozen-lockfile falla si el lockfile no está actualizado,
      # lo que evita que entre código con dependencias inconsistentes
      - name: Instalar dependencias
        run: pnpm install --frozen-lockfile

      # Paso 5: ejecuta los tests con Vitest
      # Si algún test falla, este paso devuelve código de salida distinto de 0
      # y GitHub marca el run completo como fallido
      - name: Ejecutar tests
        run: pnpm test

Por qué este nivel

  • La portería mínima que cualquier proyecto profesional debería tener: cada push y cada PR activan el CI y, si los tests fallan, el merge queda bloqueado. Es simple y eficaz.
  • Su límite: no lintea ni construye el bundle. Un fallo de ESLint o un error de TypeScript en el build pasan desapercibidos hasta que alguien los descubre en local. El tier "mejor" cierra esos huecos.