Saltar a contenido

CI/CD y despliegue

Esta página acompaña a la guía de estudio; allí están el mapa general y la ruta recomendada. Aquí no se estudia Angular: se explica cómo llega todo lo que se escribe hasta internet (las cuatro aplicaciones y esta misma wiki), cómo está montado y cómo se protegen las claves de API. Sirve para repasar y para contarlo en una entrevista o en una charla técnica.


Módulo P1: El panorama completo

El problema que resuelve

Hay dos repositorios, cuatro aplicaciones, una wiki privada, otra pública y un sitio web de la wiki. Si no ves el conjunto, parece magia: editas un archivo y minutos después cambia una página en internet. Este módulo pone todas las piezas en un solo dibujo.

Las tres herramientas, en una frase cada una

  • GitHub Action: un script que GitHub ejecuta solo cuando pasa algo en el repositorio (aquí, cuando editas la wiki). No hay que lanzarlo a mano.
  • Coolify: un panel instalado en el servidor. Toma el código de un repositorio, lo compila y lo deja publicado en un dominio.
  • MkDocs: un programa que convierte páginas Markdown (las de la wiki) en un sitio web.

Los dos repositorios

Repositorio Visibilidad Qué contiene
Reskyon/angular-ufh Privado El código de los proyectos (01-… a 05-…), la GitHub Action mirror-wiki.yml y su wiki privada, que es la copia que manda (la source of truth): si las dos wikis difieren, vale esta
Reskyon/angular-wiki Público Una copia de sólo lectura de la wiki, más lo necesario para construir el sitio web: Dockerfile, wiki-ref y la carpeta site/
Reskyon/angular-ufh  (privado)
├── 01-typescript-intro/          no se despliega (se ve en la consola)
├── 02-bases/                     ──► angular-basics.reskyon.com
├── 03-gifs-app/                  ──► angular-gifs.reskyon.com
├── 04-country-app/               ──► angular-country.reskyon.com
├── 05-pipes-app/                 ──► angular-pipes.reskyon.com
├── 06-reactive-forms-app/        ──► angular-forms.reskyon.com
├── .github/workflows/
│   └── mirror-wiki.yml           copia la wiki y avisa al sitio
└── (wiki privada)                Reskyon/angular-ufh.wiki   ← se edita aquí

Reskyon/angular-wiki  (público)
├── (wiki pública)                copia de sólo lectura
├── Dockerfile                    lo usa Coolify
├── wiki-ref                      commit de la wiki que hay que publicar
└── site/
    ├── mkdocs.yml                configuración del sitio y del tema
    ├── nginx.conf                cómo se sirve
    └── scripts/prepare.py        convierte la wiki al formato de MkDocs
                                  ──► angular-wiki.reskyon.com

El mapa de todo

                         ┌──────────────────────────────┐
   tú (VS Code, git) ──► │  Reskyon/angular-ufh (main)  │
                         └───────┬──────────────┬───────┘
                                 │              │
                  push a main    │              │  editas la wiki privada
                                 ▼              ▼
                         ┌─────────────┐   ┌──────────────────────┐
                         │   Coolify   │   │  GitHub Action       │
                         │ (4 apps)    │   │  mirror-wiki.yml     │
                         └──────┬──────┘   └─────┬──────────┬─────┘
                                │                │          │
                                │                ▼          ▼
                                │       wiki pública   wiki-ref en
                                │      (copia, sólo    Reskyon/angular-wiki
                                │       lectura)       (commit a main)
                                │                           │
                                ▼                           ▼
                   angular-basics / -gifs /          ┌─────────────┐
                   -country / -pipes .reskyon.com    │   Coolify   │
                                                     │ (sitio wiki)│
                                                     └──────┬──────┘
                                                            ▼
                                                 angular-wiki.reskyon.com

Dos flujos independientes, que sólo comparten dónde viven los archivos:

  1. El código (push a main) lo despliega Coolify directamente.
  2. El contenido (editar la wiki) lo copia una GitHub Action y, al cambiar wiki-ref, Coolify reconstruye el sitio.

Qué es CI y qué es CD aquí

Haces push y, sin que toques nada, algo compila tu cambio y te avisa si está roto. Eso es integración continua (continuous integration, CI). Si además lo publica solo en internet, eso es despliegue continuo (continuous deployment, CD). En este proyecto pasan los dos, pero no completos: la tabla dice dónde.

Término Qué es Dónde ocurre en este proyecto
CI (integración continua) Verificar cada cambio automáticamente (compilar, probar, revisar estilo) El build en Coolify compila la app y la wiki (--strict). No hay todavía una etapa de pruebas ni de lint automática
CD (despliegue continuo) Publicar solo, sin pasos manuales, lo que está en la rama principal Coolify reconstruye y publica en cada push a main
Automatización de contenido Mover y transformar archivos entre repositorios La GitHub Action mirror-wiki.yml y el script prepare.py

Ser honesto con esta tabla es lo que más se valora en una entrevista: el CD existe y funciona; la parte de CI se apoya en que el build falla si algo está mal, y todavía no corre pruebas.

Preguntas de entrevista

1. ¿Por qué hay dos repositorios en vez de uno?

Respuesta:

Por visibilidad y por responsabilidad. El de estudio es privado y contiene el código y la fuente de la wiki. El público existe para exponer el contenido (copia de sólo lectura) y para alojar lo que se necesita para construir el sitio web. Así se publica la teoría sin abrir el código de los ejercicios.

2. ¿Dónde está la fuente de verdad del contenido?

Respuesta:

En la wiki privada de Reskyon/angular-ufh. La pública se sobrescribe con cada copia (push --force), por lo que cualquier edición hecha directamente en ella se pierde. Una sola fuente de verdad evita que dos copias diverjan.


Módulo P2: De la edición de la wiki al sitio web

El problema que resuelve

Quieres escribir en la wiki de siempre (Markdown, desde el editor) y que el sitio web se actualice solo, sin entrar a ningún panel. Además, sin que la copia pública pueda quedar desactualizada ni que un enlace roto llegue a producción.

El camino de un cambio, paso a paso

 1  Editas una página de la wiki privada
         │   (git push a Reskyon/angular-ufh.wiki, o desde la web de GitHub)
         ▼
 2  GitHub dispara la Action  "Mirror wiki"
         │   por: evento gollum | ejecución manual | cron diario 06:17 UTC
         ▼
 3  Job "mirror" (ubuntu-latest)
    ├─ a) clona la wiki privada            (token GITHUB_TOKEN)
    ├─ b) push --force a la wiki pública   (secreto WIKI_MIRROR_TOKEN)
    └─ c) escribe el commit en wiki-ref de Reskyon/angular-wiki
            ├─ si wiki-ref ya tiene ese commit → no hace nada
            └─ si es distinto → commit "chore: publish wiki <sha7>" a main
         ▼
 4  Coolify detecta el push a main de Reskyon/angular-wiki
         ▼
 5  docker build   (Dockerfile del repositorio público)
    ├─ etapa 1: python:3.13-slim + git + mkdocs 1.6.1 + mkdocs-material 9.7.7
    │     git clone de la wiki pública ──► git checkout <wiki-ref>
    │     python scripts/prepare.py /wiki docs
    │     mkdocs build --strict
    └─ etapa 2: nginx:alpine  ◄── copia sólo el HTML generado
         ▼
 6  Contenedor nuevo ──► angular-wiki.reskyon.com
        (si el build falla, queda publicada la versión anterior)

Tres palabras del paso 5 que conviene tener claras. Una imagen (Docker image) es una caja cerrada con todo lo que un programa necesita para correr: el sistema base, las herramientas y los archivos. Un contenedor es esa caja en marcha. Construir en dos etapas significa usar una caja grande con herramientas (Python, git, MkDocs) para fabricar el sitio, y después copiar sólo el resultado a una caja pequeña que sólo sabe servirlo (Nginx).

Piezas clave

Pieza Para qué sirve
on: gollum Evento de GitHub cuando se crea o edita una página de la wiki
workflow_dispatch Permite lanzar la Action a mano desde la pestaña Actions
schedule (cron) Red de seguridad diaria: por si una edición por git push no disparó gollum
concurrency con cancel-in-progress: false La ejecución en curso no se cancela: la nueva espera a que termine
WIKI_MIRROR_TOKEN Secreto con permiso de escritura sobre el repositorio público; GITHUB_TOKEN sólo alcanza para leer el privado
wiki-ref Un archivo con un solo commit: la versión exacta de la wiki que debe tener el sitio

El archivo del workflow, explicado línea por línea (qué es un YAML de Actions, dónde viven los secretos, qué hace cada comando), está en el Módulo P6.

Por qué existe wiki-ref

El sitio se construye a partir de la wiki, pero Coolify sólo sabe reaccionar a cambios en el repositorio (no en su wiki). wiki-ref es el puente: la Action escribe ahí el commit de la wiki, eso es un push a main del repositorio, y Coolify lo ve y reconstruye.

Dos ventajas:

  • Es idempotente: si el archivo ya contiene ese commit, la Action no crea ningún commit nuevo y no hay un despliegue de más.
  • Es reproducible: el sitio siempre se construye desde un commit concreto de la wiki (git checkout <wiki-ref>), no desde "lo último que haya".

Se puede ver funcionando: el wiki-ref actual de Reskyon/angular-wiki coincide con el último commit de la wiki privada.

Qué hace prepare.py y cómo se actualiza el menú

La wiki de GitHub y MkDocs no hablan el mismo idioma. El script los traduce, y sólo corre en la etapa de construcción:

/wiki  (Markdown de la wiki)               docs/  (lo que lee MkDocs)
──────────────────────────                 ─────────────────────────
Home.md                          ──►       index.md
study-guide.md                   ──►       study-guide.md
01-typescript-intro.md           ──►       01-typescript-intro.md
...
_Sidebar.md, _Footer.md          ──►       (no se publican)

(02-bases#ancla)                 ──►       (02-bases.md#ancla)

Además, reescribe el bloque nav: de mkdocs.yml:

nav:
  - Inicio                          ← fijo (index.md)
  - Guía de estudio                 ← fijo
  - CI/CD y despliegue              ┐
  - Formato del código              │ páginas extra declaradas en el script (EXTRA),
  - Plantilla de un proyecto nuevo  ┘ en el orden de esa lista
  - <H1 de 01-…>                    ┐
  - <H1 de 02-…>                    │ una entrada por cada página NN-nombre.md,
  - <H1 de 03-…>                    │ ordenada por número, con su primer "# "
  - ...                             ┘ como título
  - Hoja de atajos                  ← fijo (al final)

Consecuencias prácticas:

  • Una página nueva con nombre NN-nombre.md aparece sola en el menú, ordenada por su número.
  • Las páginas que no son una parte del curso (hoy son tres: esta, formato-del-codigo y nuevo-proyecto) se declaran en una lista del script (EXTRA) y salen entre la Guía de estudio y la Parte 1, en el orden de esa lista. Si una de ellas no existe en la wiki, simplemente no entra.
  • Una página con otro nombre, que no está en esa lista, hace fallar el build (el script lo detiene a propósito para que nadie publique una página que no sale en el menú).
  • Una página sin título # también falla: no tendría texto para el menú.
  • El contenido de las páginas no se modifica; sólo cambian los enlaces entre páginas.

Por qué --strict

mkdocs build --strict convierte cualquier aviso en error: un enlace a una página que no existe, o a un ancla que no existe, rompe el build. El resultado práctico es que un error en la wiki nunca llega a producción: el despliegue falla y Coolify deja la versión anterior.

Esto ya ocurrió: un enlace con un ancla mal escrita hizo fallar el despliegue, y el sitio siguió sirviendo la versión previa hasta corregirlo.

Errores comunes

  • Editar la wiki pública a mano: se pierde en la siguiente copia (push --force).
  • Crear una página con un nombre fuera del patrón (notas.md): el build falla con la lista de páginas que no están en el menú.
  • Un ancla mal escrita: las anclas siguen las reglas de GitHub (minúsculas, sin signos de puntuación, espacios a guiones). input.required pasa a inputrequired.
  • Cambiar la versión de MkDocs sin revisar Material: MkDocs 2 todavía no es compatible con el tema, por eso el Dockerfile fija mkdocs==1.6.1.
  • Esperar un cambio al instante: son varios pasos (copia, commit, build); tarda unos minutos.

Resumen

Editar la wiki privada dispara una Action que la copia a la pública y escribe el commit en wiki-ref. Ese cambio en main hace que Coolify construya una imagen: descarga la wiki en ese commit, prepare.py la adapta y regenera el menú, mkdocs build --strict la convierte en HTML y Nginx la sirve. Si algo no cuadra, el build falla y el sitio anterior sigue en pie.

Preguntas de entrevista

1. ¿Cómo se actualiza la wiki pública y el sitio web cuando se edita una página?

Respuesta:

Solo, con una GitHub Action. Al editar una página, GitHub lanza la Action (también se puede lanzar a mano, y un cron diario la repite por si acaso). Ella copia la wiki privada a la pública con push --force y anota el commit en wiki-ref. Ese archivo cambia, eso es un push a main del repositorio público, y Coolify reconstruye el sitio desde ese commit exacto.

2. ¿Para qué sirve wiki-ref?

Respuesta:

Es el puente entre dos mundos: Coolify sólo reacciona a cambios en el repositorio, no en su wiki. Al guardar el commit de la wiki en un archivo del repositorio, un cambio en el contenido se convierte en un push que Coolify entiende. Además hace el build reproducible (siempre sale de un commit concreto) e idempotente (si no cambió, no hay commit nuevo).

3. ¿Qué hace prepare.py y por qué hace falta?

Respuesta:

Convierte la wiki de GitHub al formato que necesita MkDocs: renombra Home.md a index.md, reescribe los enlaces entre páginas para que incluyan .md y regenera el bloque nav: de mkdocs.yml a partir de las páginas NN-nombre.md. Hace falta porque la wiki enlaza sin extensión y MkDocs necesita el archivo para resolver el enlace; y para que el menú no se mantenga a mano.

4. ¿Qué pasa si una edición deja un enlace roto?

Respuesta:

mkdocs build --strict falla, el despliegue no se completa y Coolify mantiene la versión anterior del sitio. El error aparece en el log de Coolify con el enlace o el ancla concreta, y se arregla editando la wiki, lo que dispara un nuevo intento.

5. ¿Por qué cancel-in-progress está en false?

Respuesta:

Porque la ejecución en curso puede estar a medias (copiada la wiki pero sin escribir wiki-ref). Cancelarla dejaría la copia pública y el sitio inconsistentes. Con false, la ejecución en curso termina y la nueva espera su turno.


Módulo P3: Cómo se configura todo en Coolify

El problema que resuelve

Coolify es la plataforma que construye y publica. Aunque se configura con formularios, cada campo tiene una razón. Entenderlas es lo que permite reproducir el montaje o arreglarlo cuando falla.

Seis aplicaciones, un mismo patrón

Aplicación Repositorio Base directory Dominio
02-bases Reskyon/angular-ufh /02-bases angular-basics.reskyon.com
03-gifs-app Reskyon/angular-ufh /03-gifs-app angular-gifs.reskyon.com
04-country-app Reskyon/angular-ufh /04-country-app angular-country.reskyon.com
05-pipes-app Reskyon/angular-ufh /05-pipes-app angular-pipes.reskyon.com
06-reactive-forms-app Reskyon/angular-ufh /06-reactive-forms-app angular-forms.reskyon.com
Sitio de la wiki Reskyon/angular-wiki raíz del repositorio angular-wiki.reskyon.com

Campos comunes de cada aplicación:

Campo en Coolify Valor Para qué
Repositorio / rama Reskyon/angular-ufh, main (el sitio de la wiki clona Reskyon/angular-wiki) De dónde se clona
Build pack Dockerfile Usa el Dockerfile del proyecto en vez de adivinar
Base directory La carpeta del proyecto Hace que COPY y npm ci trabajen dentro de esa carpeta
Dockerfile location /Dockerfile Relativa al Base directory
Puerto expuesto 80 El de Nginx dentro del contenedor
Dominio https://angular-….reskyon.com Coolify configura el proxy y el certificado
Watch paths Vacío Cualquier push a main redespliega

Por qué Base directory y no un Dockerfile en la raíz

El repositorio tiene varios proyectos independientes, cada uno con su package.json. Un solo Dockerfile en la raíz tendría que decidir cuál compilar. Con un Dockerfile por carpeta y Base directory, un mismo repositorio publica varias aplicaciones, cada una con su propia configuración en Coolify.

El Dockerfile de las aplicaciones Angular

etapa 1  node:24-alpine
         ├─ npm ci            instala exactamente lo del lockfile
         └─ npm run build     genera dist/<proyecto>/browser
etapa 2  nginx:alpine
         ├─ nginx.conf        fallback de SPA (try_files … /index.html)
         └─ copia sólo dist/<proyecto>/browser
  • Dos etapas: la imagen final no tiene Node, ni node_modules, ni el código fuente: sólo archivos estáticos y Nginx. Es como un restaurante: en la cocina (etapa 1) hay hornos, cuchillos e ingredientes; al comedor (etapa 2) llega sólo el plato. Dónde falla la comparación: la cocina de un restaurante sigue ahí; aquí la etapa 1 se descarta y sólo queda la imagen pequeña.
  • nginx.conf con try_files … /index.html: sin él, recargar una ruta de Angular (/country) daría 404, porque en el servidor esa ruta no es un archivo.
  • Por qué node:24-alpine y no el modo por defecto de Coolify: el generador automático (Nixpacks) instala un Node más viejo que el mínimo que pide Angular 22.

Variables de entorno y claves (el caso de 03-gifs-app)

La clave de Giphy no se sube a git ni llega al navegador. Se crea en Coolify como variable de ejecución (GIPHY_API_KEY), y Nginx, que hace de proxy dentro del contenedor, la agrega a cada petición que reenvía a Giphy. La receta completa está en el Módulo P5 y el despliegue de esa app en el Módulo G8.

04-country-app repite el mismo patrón con REST_COUNTRIES_API_KEY. La única diferencia es que REST Countries pide la clave en un encabezado y no en la URL, así que Nginx la agrega con proxy_set_header (Módulo C10). 06-reactive-forms-app usa la misma API y el mismo nginx.conf.template, con su propia variable REST_COUNTRIES_API_KEY en Coolify. 05-pipes-app no llama a ninguna API, así que no tiene variables.

Del dominio al contenedor

navegador ──► Cloudflare (DNS + proxy, HTTPS)
                    │  SSL Full (strict)
                    ▼
              servidor (VPS)
                    │
                    ▼
              Traefik  (proxy de Coolify, certificado Let's Encrypt)
                    │  según el dominio
                    ▼
              contenedor de la app (Nginx, puerto 80)
  • Cloudflare: registro A con proxy hacia el servidor.
  • Traefik: lo gestiona Coolify; enruta cada dominio a su contenedor.
  • Un error 526 de Cloudflare casi siempre significa que el despliegue falló y no hay contenedor: el certificado que ve Cloudflare es el de Traefik por defecto. Se revisa el log de Coolify, no Cloudflare.

Qué ocurre en cada despliegue

push a main
    │
    ▼
Coolify clona el repositorio en esa rama
    │
    ▼
docker build  (Dockerfile del Base directory)
    │ falla ──► se descarta el contenedor nuevo, queda el anterior
    ▼
arranca el contenedor nuevo ──► el proxy cambia a él

Cosas que se pueden mejorar (para una entrevista)

Conocer los límites del montaje es parte de la respuesta:

Hoy Mejora posible
Cualquier push a main redespliega las cuatro apps (Watch paths vacíos) Poner Watch paths por carpeta (03-gifs-app/**)
No hay pruebas ni lint automáticos antes de publicar Una GitHub Action en cada pull request con ng test, npm run lint y npm run format:check
Se publica directo desde main Ramas de preview o un entorno de staging
Sin health check Activarlo, para que Coolify sólo cambie al contenedor nuevo cuando responda
La clave de Giphy va por un proxy en Nginx, sin autenticación Para una API que cobre, un backend con usuarios autenticados (Módulo P5)

Errores comunes

  • Poner la URL con tree/main/... como repositorio: es el repositorio completo; la subcarpeta se indica en Base directory.
  • Olvidar el puerto: Coolify no sabe a qué puerto del contenedor enviar el tráfico.
  • Marcar mal el momento de una variable: una variable de build sólo existe mientras se compila; una de ejecución, sólo cuando el contenedor corre. Un valor que usa npm run build va en build; una clave que usa Nginx va en ejecución, nunca en build (Módulo P5).
  • Cambiar la carpeta de salida de ng build (el nombre del proyecto de Angular) y no el COPY del Dockerfile: el build se corta porque no encuentra la carpeta.

Resumen

Un repositorio con varios proyectos publica varias aplicaciones gracias a un Dockerfile por carpeta y a Base directory en Coolify. Cada push a main construye una imagen en dos etapas (Node compila, Nginx sirve), y si el build falla, queda la versión anterior. El dominio pasa por Cloudflare y por Traefik hasta el contenedor.

Preguntas de entrevista

1. ¿Cómo publicas varias aplicaciones desde un único repositorio?

Respuesta:

Con un Dockerfile dentro de cada carpeta de proyecto y una aplicación de Coolify por cada una, indicando en cada caso el Base directory. Coolify clona el mismo repositorio, entra en esa subcarpeta y construye con su Dockerfile. Cada aplicación tiene su dominio, su puerto y sus variables.

2. ¿Por qué un Dockerfile en dos etapas?

Respuesta:

Para que lo que se publica sea pequeño y no lleve nada de más. La primera etapa tiene todo lo necesario para compilar (Node, dependencias, código fuente). La segunda sólo lleva el resultado (archivos estáticos) y un servidor ligero. La imagen final es mucho más pequeña, arranca antes y, como no incluye herramientas de desarrollo, tiene menos cosas que un atacante pueda aprovechar (lo que en seguridad se llama superficie de ataque).

3. Una SPA de Angular da 404 al recargar /country. ¿Qué pasa y cómo se arregla?

Respuesta:

La ruta /country existe sólo en el navegador (la define el enrutador de Angular); en el servidor no hay un archivo con ese nombre. Hay que decirle a Nginx que, si no encuentra el archivo, entregue index.html y deje que Angular resuelva la ruta: try_files $uri $uri/ /index.html.

4. El dominio muestra un error de certificado de Cloudflare tras desplegar. ¿Qué miras primero?

Respuesta:

El log de despliegue en Coolify, no Cloudflare. Ese error casi siempre indica que el despliegue falló y no hay contenedor corriendo: el proxy responde con su certificado por defecto, que Cloudflare (en modo Full strict) rechaza. Se arregla el build, no el DNS.

5. ¿Qué mejorarías de este flujo de despliegue?

Respuesta:

Añadiría una etapa de CI antes de publicar (pruebas, lint y comprobación de formato en cada pull request), Watch paths por proyecto para no redesplegar todo con cada cambio, un health check para que sólo se cambie de contenedor cuando el nuevo responda, y un entorno de preview o staging. Y para una API que cobre, pondría un backend con usuarios autenticados delante del proxy (Módulo P5).


Módulo P4: Cómo contarlo en una entrevista o charla

El problema que resuelve

Saber que algo funciona no es lo mismo que poder explicarlo en pocos minutos. Este módulo es un guion para contar el montaje completo, a distintos niveles de detalle.

La versión de 30 segundos

Tengo un repositorio privado con cuatro aplicaciones Angular y su teoría en una wiki. Cada push a main lo despliega Coolify con un Dockerfile por proyecto. La wiki se publica sola: una GitHub Action la copia a un repositorio público y deja anotado el commit en un archivo, lo que hace que Coolify reconstruya el sitio con MkDocs. Las claves de las APIs nunca llegan al navegador: las agrega un proxy en el servidor.

La versión de 2 minutos (con el dibujo)

  1. Qué hay: dos repositorios (privado con el código y la wiki fuente; público con la copia y el sitio) y seis sitios publicados.
  2. El código: un push a main → Coolify clona, usa el Dockerfile de la carpeta (Base directory), construye en dos etapas y publica detrás de Traefik y Cloudflare.
  3. El contenido: editar la wiki → GitHub Action → copia a la wiki pública y wiki-ref → Coolify → prepare.py + mkdocs --strict → Nginx.
  4. Qué protege: el build estricto (nada roto llega a producción), que el sitio sale de un commit concreto de la wiki, y que la clave de la API vive sólo en el servidor (Módulo P5).
  5. Qué mejoraría: pruebas en CI, Watch paths por proyecto y health check.

Los tres dibujos que conviene saber de memoria

Código            push a main ──► Coolify ──► docker build ──► app publicada

Contenido         editar wiki ──► Action ──► copia + wiki-ref ──► Coolify ──► sitio

Dentro del build  wiki @ wiki-ref ──► prepare.py ──► docs/ ──► mkdocs --strict ──► HTML ──► Nginx

Preguntas que suelen hacer

Pregunta Idea de la respuesta
"¿Qué es CI/CD en tu proyecto?" CD: Coolify publica en cada push. CI: el build falla si algo está mal; falta añadir pruebas automáticas
"¿Cómo manejas los secretos?" La app no conoce la clave: llama a /api/... y un proxy en el servidor (Nginx) la agrega. En local la lee de .env; en producción, de una variable de ejecución en Coolify. Una clave que cueste dinero necesita además un backend con usuarios (Módulo P5)
"¿Qué pasa si falla un despliegue?" Coolify descarta el contenedor nuevo y sigue sirviendo el anterior; el log dice qué falló
"¿Por qué Docker y no Nixpacks?" Para fijar la versión de Node que exige Angular 22 y controlar cómo se construye
"¿Cómo evitas que la copia de la wiki quede vieja?" La Action se dispara con cada edición, y hay un cron diario como red de seguridad
"¿Cómo escalaría esto?" Watch paths por proyecto, pruebas en pull requests, entornos de preview y health checks

Para mostrarlo en una charla técnica

  1. Empieza por el problema, no por las herramientas: «quería escribir la teoría en Markdown y que se publicara sola».
  2. Enseña el dibujo del panorama (Módulo P1) y deja que el público lo mire unos segundos.
  3. Haz una demostración corta: editar una frase en la wiki y mostrar la Action y el despliegue en Coolify.
  4. Cuenta un error real, como el del ancla rota que detuvo un despliegue: muestra que el build estricto cumple su función.
  5. Cierra con lo que mejorarías. Demuestra criterio.

Resumen

El montaje se explica con dos flujos (código y contenido), un archivo puente (wiki-ref) y un build estricto que protege producción. Lo que más suma en una entrevista es contarlo con un dibujo, reconocer las limitaciones (todavía no hay pruebas automáticas) y saber qué se mejoraría. Queda una pieza que en una entrevista preguntan casi siempre: dónde viven las claves. Es el Módulo P5.

Preguntas de entrevista

1. Resume en un minuto cómo se publica tu proyecto.

Respuesta:

Hay dos flujos. El código vive en un repositorio privado y cada push a main lo despliega Coolify con un Dockerfile por proyecto: Node compila y Nginx sirve. El contenido vive en una wiki; una GitHub Action la copia a un repositorio público y anota el commit en un archivo, lo que hace que Coolify reconstruya el sitio con MkDocs. El build es estricto: si hay un enlace roto, no se publica y queda la versión anterior.

2. ¿Qué parte te parece más frágil y cómo la mejorarías?

Respuesta:

La falta de pruebas automáticas antes de publicar: ahora el único control es que el build compile. Añadiría una Action en cada pull request que ejecute pruebas, lint y la comprobación de formato, y activaría los health checks en Coolify. También acotaría los Watch paths para que un cambio en un proyecto no redespliegue los demás.


Módulo P5: Proteger las claves: .env, variables y proxy

El problema que resuelve

Casi toda aplicación habla con una API que pide una clave. La clave es un texto, y un texto puede terminar en tres lugares donde no debería: en git (cualquiera con acceso al repositorio la ve, para siempre, en el historial), en el navegador (cualquiera que abra las herramientas de desarrollo la ve) y en una imagen de Docker (cualquiera que descargue la imagen la ve). Este módulo explica cómo evitas los tres, con lo que cambiaste en 03-gifs-app de ejemplo, y en qué casos hace falta cada parte.

Este es el módulo de referencia para reproducir la configuración. Los detalles de cómo quedó el código de la app están en el Módulo G15.

Definición simple

Una clave puede vivir en tres sitios, de menos a más seguro:

1. En el código                  -> está en git y en el navegador        (nunca)
2. En .env + bundle              -> fuera de git, pero en el navegador   (sólo claves públicas)
3. En el servidor, con un proxy  -> fuera de git y fuera del navegador   (claves secretas, y buena práctica para todas)
  • .env: un archivo con NOMBRE=valor, ignorado por git, que vive sólo en tu computadora. Es el sustituto local de las variables de entorno.
  • Variable de entorno: un valor que el sistema le entrega a un programa al arrancar, sin que esté escrito en ningún archivo del proyecto. En Coolify se crean en el panel; en Docker se pasan con -e.
  • Proxy: un intermediario. El navegador le pide los datos a tu servidor, y tu servidor, que es el único que conoce la clave, se los pide a la API.

Lo que cambiaste en 03-gifs-app

03-gifs-app pasó del nivel 2 al nivel 3:

Antes (Módulo G8 original) Después (Módulo G15)
Dónde está la clave en local .env → script → secrets.ts → dentro del JavaScript .env, leída por el proxy de ng serve
Dónde está en producción Variable de build en Coolify → dentro del JavaScript Variable de ejecución en Coolify → Nginx, dentro del contenedor
¿Está en git? No No
¿Está en el navegador? Sí, en cada petición a Giphy No: el navegador llama a /api/giphy
¿Quién puede usarla? Cualquiera que la copie de la pestaña Network Sólo páginas de tu dominio, con un límite por IP
Piezas script set-env.mjs, secrets.ts, prefijo NG_APP_ proxy.conf.mjs y nginx.conf.template

En qué caso aplicarlo

Para este proyecto, el nivel 2 alcanzaba: una clave de Giphy sólo gasta cuota. El nivel 3 se hizo para aprender la práctica correcta antes de necesitarla. Las dos reglas que conviene fijar para cuando la clave sí importe:

  • Clave pública de cliente (Giphy, Google Maps, Firebase): puede ir al navegador. Protégela con lo que ofrece el proveedor: restricción por dominio (HTTP referrer) y cuotas. Giphy, en el plan gratuito, no ofrece restricción por dominio, y por eso la protección se hizo de tu lado.
  • Clave secreta (OpenAI, Stripe, bases de datos, cualquier cosa que cobre o lea datos privados): nunca al front, ni siquiera por .env. Va en un backend pequeño que recibe la petición del navegador y la reenvía con la clave; el navegador sólo conoce tu backend. El proxy de este módulo es la versión mínima de ese backend.

La prueba mental: «si esta clave aparece en Twitter mañana, ¿qué pierdo?». Si la respuesta es «cuota», es pública. Si es «dinero o datos», es secreta y necesita backend.

Cuando el próximo proyecto del curso use una API con cobro, ese es el momento de montar el backend de verdad, con la misma idea.

Cómo reproducirlo, paso a paso

Receta para una aplicación Angular servida con Nginx en Docker, como las de este repositorio. NOMBRE_API es la API; cámbialo por la tuya.

1. La clave en .env, fuera de git.

# .env (ignorado)
NOMBRE_API_KEY=<NOMBRE_API_KEY>
  • .gitignore incluye .env; .dockerignore también, para que la clave local no entre a la imagen.
  • .env.template va en git con el mismo nombre y un marcador en lugar del valor: es la documentación de qué variables existen.

2. La aplicación llama a una URL relativa, sin clave.

// environment.ts y environment.development.ts
nombreApiUrl: '/api/nombre/v1'

El servicio pide ${nombreApiUrl}/... con sus parámetros normales y sin api_key. Como la URL es relativa, la petición va al mismo servidor que sirvió la página, en local y en producción.

3. El proxy de desarrollo (ng serve). Crea proxy.conf.mjs en la raíz del proyecto y regístralo en angular.json:

"serve": {
  "options": { "proxyConfig": "proxy.conf.mjs" }
}
// proxy.conf.mjs
import { existsSync, readFileSync } from 'node:fs'
import { parseEnv } from 'node:util'

const env = existsSync('.env') ? parseEnv(readFileSync('.env', 'utf8')) : {}
const apiKey = process.env.NOMBRE_API_KEY ?? env.NOMBRE_API_KEY

if (!apiKey) {
  console.error('[proxy] NOMBRE_API_KEY is missing. Copy .env.template to .env and fill it in.')
  process.exit(1)
}

export default {
  '/api/nombre': {
    target: 'https://api.nombre.com',
    changeOrigin: true,
    rewrite: (path) => {
      const url = new URL(path.replace(/^\/api\/nombre/, ''), 'https://api.nombre.com')
      url.searchParams.set('api_key', apiKey)
      return url.pathname + url.search
    },
  },
}

Si la API pide la clave en un encabezado (Authorization: Bearer ...) en lugar de en la URL, como REST Countries en 04-country-app, el rewrite sólo cambia el prefijo por la ruta real de la API y la clave va en headers:

export default {
  '/api/nombre': {
    target: 'https://api.nombre.com',
    changeOrigin: true,
    // Replaces the /api/nombre prefix with the real path of the API.
    rewrite: (path) => path.replace(/^\/api\/nombre/, '/v1'),
    headers: { Authorization: `Bearer ${apiKey}` },
  },
}

El archivo real es 04-country-app/proxy.conf.mjs; el Módulo C10 lo explica paso a paso.

4. El proxy de producción (Nginx). Una pieza nueva: el navegador, al pedir algo, dice desde qué página lo pide en un encabezado llamado Referer. El bloque de abajo rechaza las peticiones que no vengan de tu propia página (valid_referers) y limita cuántas acepta por segundo desde una misma IP (limit_req). Renombra nginx.conf a nginx.conf.template y agrega el bloque del proxy antes de las location que ya tenía:

limit_req_zone $binary_remote_addr zone=nombre:1m rate=5r/s;

server {
    listen 80;
    server_name app.ejemplo.com localhost;
    root /usr/share/nginx/html;
    index index.html;

    location ^~ /api/nombre/ {
        valid_referers server_names;
        if ($invalid_referer) {
            return 403;
        }

        limit_req zone=nombre burst=20 nodelay;

        set $args $args&api_key=${NOMBRE_API_KEY};
        proxy_pass https://api.nombre.com/;
        proxy_set_header Host api.nombre.com;
        proxy_ssl_server_name on;
    }

    # ... las location de siempre (caché de estáticos, fallback a index.html)
}
  • server_name lleva el dominio real y localhost, porque valid_referers server_names compara contra esa lista.

Para una clave en encabezado (REST Countries, en 04-country-app), reemplaza las cuatro líneas desde set $args por estas cinco:

        rewrite ^/api/nombre(.*)$ /v1$1 break;
        proxy_pass https://api.nombre.com;
        proxy_set_header Host api.nombre.com;
        proxy_set_header Authorization "Bearer ${NOMBRE_API_KEY}";
        proxy_ssl_server_name on;

El rewrite cambia el prefijo /api/nombre por la ruta real de la API y break le dice a Nginx que siga en esta misma location con la ruta nueva. Como la ruta ya está armada, proxy_pass va sin barra final: reenvía la petición tal cual quedó. El archivo real es 04-country-app/nginx.conf.template (Módulo C10).

5. El Dockerfile copia la plantilla, no la configuración.

COPY nginx.conf.template /etc/nginx/templates/default.conf.template

La imagen oficial de Nginx rellena al arrancar las variables de entorno de todo lo que haya en /etc/nginx/templates/ y deja el resultado en /etc/nginx/conf.d/. Sólo sustituye variables de entorno que existan, así que $args, $uri y las demás de Nginx no se tocan. El build ya no necesita ninguna clave: se quitan los ARG y los scripts que la metían.

6. La variable en Coolify, de ejecución. En Environment Variables, crea NOMBRE_API_KEY con el mismo nombre que en .env y sin marcar Available at Buildtime. La usa Nginx cuando el contenedor arranca, no el build. Cambiar variables en Coolify dispara un redespliegue por sí solo.

7. Probar. Siempre las mismas tres peticiones, en local y en producción:

# Imagen local
docker build -t app .
docker run --rm -p 8080:80 -e NOMBRE_API_KEY=<NOMBRE_API_KEY> app

# 1. Sin Referer: debe dar 403 (la protección funciona)
curl -i http://localhost:8080/api/nombre/v1/recurso?limit=1
# 2. Con Referer propio: debe dar 200 y el JSON de la API
curl -i -H "Referer: http://localhost:8080/" http://localhost:8080/api/nombre/v1/recurso?limit=1
# 3. Una ruta de la app: debe dar 200 con el index.html
curl -i http://localhost:8080/dashboard

Y una cuarta, de una sola vez: buscar la clave en dist/ después de ng build. No tiene que aparecer.

Qué protege cada pieza

Pieza Protege de... No protege de...
.env + .gitignore + .dockerignore Que la clave quede en git o en la imagen Que se vea en el navegador
Proxy (dev y Nginx) Que la clave se vea en el navegador Nada: es la base
valid_referers Que otro sitio web use el proxy desde un navegador Un script o curl que falsifique el encabezado
limit_req Que un script gaste la cuota a toda velocidad Un abuso lento repartido entre muchas IPs
Variable de ejecución en Coolify Que la clave quede grabada en la imagen Que alguien con acceso al panel la lea

Errores comunes

  • Creer que «fuera de git» es «protegida». Si la clave va en el JavaScript, está en el navegador.
  • Marcar la variable como Available at Buildtime en Coolify: Nginx no la ve en ejecución y la API responde 401 a todo.
  • Dejar en Coolify la variable vieja (NG_APP_…) y la nueva a la vez: no rompe nada, pero confunde. Se borra la que ya no se usa.
  • Olvidar el / final en proxy_pass https://api.nombre.com/: Nginx manda la ruta completa con /api/nombre/ y la API responde 404.
  • Probar con curl sin Referer y pensar que el proxy está roto: el 403 es la protección.
  • Cambiar las variables en Coolify antes de hacer push: el redespliegue automático usa el código viejo con las variables nuevas y falla. No pasa nada (queda el contenedor anterior), pero el orden correcto es crear la variable nueva, hacer push, y recién después borrar la vieja.

Resumen

Una clave tiene que vivir en el servidor: en .env en tu computadora y como variable de ejecución en Coolify. La aplicación llama a una URL relativa y un proxy agrega la clave: ng serve con proxy.conf.mjs en local, Nginx con una plantilla en producción. Para una clave pública es una buena práctica; para una secreta es la única opción.

Preguntas de entrevista

1. ¿Qué diferencia hay entre una variable de build y una de ejecución, y cuál usarías para una clave de API?

Respuesta:

La de ejecución, siempre. La diferencia es cuándo existe cada una. Una variable de build existe mientras se construye la imagen: sirve para decidir qué se compila, y todo lo que se use en ese momento queda grabado en la imagen o en el JavaScript. Una variable de ejecución se le entrega al contenedor al arrancar: no está en la imagen, y se puede cambiar sin reconstruir. Una clave de API va siempre en ejecución: la lee el proceso que hace la petición (Nginx o un backend), nunca el build. Si una clave se usa en el build de una SPA, termina en el navegador por definición.

2. Explica cómo evitas que una clave de API llegue al navegador en una SPA, en local y en producción.

Respuesta:

La SPA no conoce la clave: llama a una URL relativa de su propio servidor (/api/nombre). Ese servidor hace de proxy: agrega la clave y reenvía la petición a la API. En local el servidor es ng serve, y su proxy se configura con un archivo (proxy.conf.mjs) que lee la clave de .env. En producción el servidor es Nginx, con una plantilla que la imagen rellena al arrancar con la variable de entorno del contenedor. La aplicación es la misma en los dos casos; sólo cambia quién hace de proxy.

3. ¿Por qué un proxy con revisión de Referer no equivale a autenticación?

Respuesta:

Porque el Referer lo pone el cliente. Un navegador lo escribe solo y no deja cambiarlo desde JavaScript, así que sirve para que otra página web no use tu proxy. Pero curl o un script envían el Referer que quieran. La revisión reduce el abuso casual, y el límite de peticiones por IP lo hace lento, pero si el recurso cuesta dinero hace falta autenticar a quien pide (usuarios con sesión o tokens) y, mejor aún, que el backend decida qué puede pedir cada uno.


Módulo P6: La Action Mirror wiki, línea por línea

El problema que resuelve

El Módulo P2 cuenta qué hace la Action. Este módulo abre el archivo y explica cómo lo hace, línea por línea: qué es un workflow, quién le da permiso para tocar cada repositorio, dónde viven los secretos (y por qué no están en el archivo) y qué hace cada comando de shell. Sirve para poder modificarlo sin miedo y para explicar en una entrevista cómo funciona una GitHub Action por dentro.

Definición simple

Una GitHub Action no es un script. Es un archivo YAML en .github/workflows/ que GitHub lee y ejecuta por ti. Adentro sí hay trozos de shell (los bloques run:), pero el YAML es quien dice cuándo correrlos, dónde y con qué permisos.

La analogía: una receta pegada en la heladera de una cocina ajena.

  • La cocina es una máquina virtual de GitHub (runs-on: ubuntu-latest). Nace vacía, hace lo que dice la receta y se destruye. No queda nada guardado.
  • La receta es el YAML, con tres partes: cuándo cocinar (on:), qué puede tocar (permissions: y los secretos) y los pasos (steps:).
  • Cada paso es una línea de la receta. Si un paso falla, los siguientes no corren.

Tú no ejecutas nada. GitHub mira el archivo, espera a que ocurra uno de los disparadores, y cocina solo.

El archivo completo

Vive en .github/workflows/mirror-wiki.yml del repositorio privado github.com/Reskyon/angular-ufh. Se puede mostrar entero porque no contiene ningún secreto: donde dice ${{ secrets.WIKI_MIRROR_TOKEN }} hay sólo el nombre de un secreto; el valor lo guarda GitHub aparte (más abajo se explica dónde).

name: Mirror wiki

on:
  gollum: # a wiki page was created or edited
  workflow_dispatch: # manual run from the Actions tab
  schedule:
    - cron: '17 6 * * *' # daily fallback in case a push didn't trigger gollum

permissions:
  contents: read

concurrency:
  group: mirror-wiki
  cancel-in-progress: false

jobs:
  mirror:
    runs-on: ubuntu-latest
    env:
      SOURCE: ${{ github.repository }}.wiki.git
      TARGET: Reskyon/angular-wiki.wiki.git
    steps:
      - name: Clonar la wiki privada
        env:
          TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: git clone --quiet "https://x-access-token:${TOKEN}@github.com/${SOURCE}" wiki

      - name: Publicar en la wiki pública
        working-directory: wiki
        env:
          TOKEN: ${{ secrets.WIKI_MIRROR_TOKEN }}
        run: |
          branch=$(git rev-parse --abbrev-ref HEAD)
          git remote add public "https://x-access-token:${TOKEN}@github.com/${TARGET}"
          git push --force public "HEAD:${branch}"
          echo "Copiado ${SOURCE} (${branch}) -> ${TARGET} en $(git rev-parse --short HEAD)" >> "$GITHUB_STEP_SUMMARY"

      - name: Actualizar wiki-ref del sitio
        working-directory: wiki
        env:
          TOKEN: ${{ secrets.WIKI_MIRROR_TOKEN }}
        run: |
          sha=$(git rev-parse HEAD)
          git clone --quiet --depth 1 "https://x-access-token:${TOKEN}@github.com/Reskyon/angular-wiki.git" ../site
          cd ../site
          if [ "$(cat wiki-ref 2>/dev/null)" = "$sha" ]; then
            echo "wiki-ref ya apunta a ${sha:0:7}" >> "$GITHUB_STEP_SUMMARY"
            exit 0
          fi
          echo "$sha" > wiki-ref
          git config user.name 'github-actions[bot]'
          git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
          git commit --quiet -am "chore: publish wiki ${sha:0:7}"
          git push --quiet origin HEAD:main
          echo "wiki-ref actualizado a ${sha:0:7}: Coolify redespliega el sitio" >> "$GITHUB_STEP_SUMMARY"

Un dato que hace falta para entender todo lo demás: una wiki de GitHub es un repositorio git aparte. Si el repositorio es Reskyon/angular-ufh, su wiki vive en Reskyon/angular-ufh.wiki.git, y se puede clonar, hacer commits y push como cualquier otro. La Action aprovecha exactamente eso.

Cuándo corre: on:

Tres disparadores, cada uno con un propósito:

Disparador Cuándo Para qué está
gollum Alguien crea o edita una página de la wiki El caso normal. Gollum es el nombre del motor de wikis de GitHub; por eso el evento se llama así
workflow_dispatch Pulsas Run workflow en la pestaña Actions Probar o forzar una copia sin tocar la wiki
schedule con cron: '17 6 * * *' Todos los días a las 06:17 UTC Red de seguridad: si un git push directo a la wiki no disparó gollum, se copia igual al día siguiente

¿Por qué 06:17 y no 06:00? A las horas en punto miles de repositorios tienen crons agendados, y GitHub a veces los atrasa o los salta. Un minuto raro evita la cola.

Quién puede tocar qué: permissions: y los secretos

permissions:
  contents: read

Cada ejecución recibe gratis un token automático, GITHUB_TOKEN, que sólo sirve para el repositorio donde vive el workflow. Esta línea lo deja en sólo lectura: alcanza para clonar la wiki privada y nada más. Si alguien lograra meter un comando malicioso en el workflow, no podría escribir en angular-ufh.

Pero ese token no sirve para otro repositorio. Para escribir en Reskyon/angular-wiki hace falta una segunda llave: WIKI_MIRROR_TOKEN, un personal access token creado a mano con permiso de escritura sobre Reskyon/angular-wiki y guardado como secreto del repositorio.

Dónde vive un secreto, y por qué no sale en el archivo:

  • Se crea en el repositorio, en Settings → Secrets and variables → Actions. Se escribe el valor una vez; después GitHub sólo muestra el nombre. Ni siquiera quien lo creó puede volver a leerlo: sólo reemplazarlo o borrarlo.
  • El workflow lo pide por nombre con ${{ secrets.NOMBRE }}. GitHub lo inyecta en la máquina virtual sólo mientras corre ese paso, y se borra con la máquina al terminar.
  • En los logs sale como ***. Si un comando intenta imprimirlo, GitHub lo tapa.

Con la analogía: el cocinero tiene llave de lectura de su propia despensa (automática) y una llave de escritura de la despensa del vecino que tú le dejaste en un sobre cerrado (el secreto). La receta sólo dice «usa el sobre que dice WIKI_MIRROR_TOKEN».

Que no se pisen dos ejecuciones: concurrency:

concurrency:
  group: mirror-wiki
  cancel-in-progress: false

Si editas tres páginas en un minuto, gollum dispara tres veces. Sin esto, tres copias correrían a la vez y harían push --force en desorden. Con concurrency, GitHub pone en fila a todas las ejecuciones con el mismo nombre de grupo. cancel-in-progress: false significa: la que está corriendo termina, la siguiente espera.

Las variables del job: env:

env:
  SOURCE: ${{ github.repository }}.wiki.git
  TARGET: Reskyon/angular-wiki.wiki.git

${{ github.repository }} lo rellena GitHub con Reskyon/angular-ufh, así el archivo no lleva el nombre del repositorio escrito a mano. SOURCE queda en Reskyon/angular-ufh.wiki.git (la wiki privada) y TARGET es la wiki pública. Los pasos usan estos dos nombres en vez de repetir las URLs.

Paso 1: clonar la wiki privada

git clone --quiet "https://x-access-token:${TOKEN}@github.com/${SOURCE}" wiki

Expandido, es git clone https://x-access-token:<token>@github.com/Reskyon/angular-ufh.wiki.git wiki.

La forma x-access-token:<token>@ es la manera de autenticarse por HTTPS sin que git pida usuario y contraseña: el usuario es literalmente el texto x-access-token, y la contraseña es el token. Aquí TOKEN es el GITHUB_TOKEN automático, que alcanza porque sólo lee. Queda una carpeta wiki con toda la wiki y su historial.

Paso 2: publicar en la wiki pública

branch=$(git rev-parse --abbrev-ref HEAD)
git remote add public "https://x-access-token:${TOKEN}@github.com/${TARGET}"
git push --force public "HEAD:${branch}"
echo "Copiado ..." >> "$GITHUB_STEP_SUMMARY"

working-directory: wiki hace que todo esto corra dentro de la carpeta recién clonada. Línea por línea:

  1. branch=$(...) averigua cómo se llama la rama de la wiki. Las wikis antiguas usan master, las nuevas main. Preguntar en vez de suponer evita que se rompa por eso.
  2. git remote add public ... agrega un segundo destino al clon. El clon ya conoce a origin (la wiki privada); ahora también conoce a public (la wiki de angular-wiki). Fíjate que aquí TOKEN ya es WIKI_MIRROR_TOKEN, la llave con permiso de escritura.
  3. git push --force public HEAD:${branch} manda lo que tengo (HEAD) a la rama del mismo nombre en public. El --force es lo que hace que sea un espejo y no una fusión: la wiki pública queda idéntica a la privada, aunque alguien haya editado la pública a mano. Es intencional: la privada manda.
  4. echo ... >> "$GITHUB_STEP_SUMMARY" escribe en un archivo especial. Lo que se escribe ahí aparece como texto en la página de la ejecución, en la pestaña Actions. Es como dejar una nota pegada diciendo «copié tal commit».

Paso 3: avisar al sitio que hay wiki nueva

Es el paso menos obvio. El sitio angular-wiki.reskyon.com no lee la wiki en vivo: se construye desde el repositorio Reskyon/angular-wiki (el de código, no su wiki), y ese repositorio tiene un archivo wiki-ref con un solo hash de commit: la versión de la wiki con la que se tiene que construir el sitio (el porqué está en el Módulo P2).

sha=$(git rev-parse HEAD)

Guarda el hash del commit de la wiki que acabamos de copiar. Algo como 6639e9e6925e....

git clone --quiet --depth 1 ".../Reskyon/angular-wiki.git" ../site
cd ../site

Clona el repositorio del sitio al lado de la carpeta wiki. --depth 1 trae sólo el último commit, sin historial, porque no hace falta y es más rápido.

if [ "$(cat wiki-ref 2>/dev/null)" = "$sha" ]; then
  echo "wiki-ref ya apunta a ..." >> "$GITHUB_STEP_SUMMARY"
  exit 0
fi

Si wiki-ref ya tiene ese hash, no hay nada nuevo: sale con éxito sin hacer commit. Esto es lo que vuelve inofensivo al cron diario: si la wiki no cambió, no genera un commit vacío ni un despliegue por nada. El 2>/dev/null tapa el error por si el archivo no existiera todavía.

echo "$sha" > wiki-ref
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git commit --quiet -am "chore: publish wiki ${sha:0:7}"
git push --quiet origin HEAD:main

Si el hash es distinto: escribe el nuevo en el archivo, configura un autor (git se niega a hacer commit sin nombre y correo; ese correo es la cuenta pública del bot de GitHub Actions, no un secreto), hace el commit con un mensaje como chore: publish wiki 6639e9e (${sha:0:7} son los primeros siete caracteres del hash, para que se lea) y lo envía a main.

Ese push a main es lo que Coolify está mirando. Ve un commit nuevo, reconstruye el sitio, y el Dockerfile lee wiki-ref para saber qué versión de la wiki mostrar.

Dónde ver que funcionó

En la pestaña Actions de github.com/Reskyon/angular-ufh, dentro de la última ejecución de Mirror wiki, el resumen al pie muestra las frases de los echo ... >> "$GITHUB_STEP_SUMMARY" tal cual. Si dice «wiki-ref ya apunta a …», no había nada nuevo; si dice «wiki-ref actualizado a …», Coolify está reconstruyendo el sitio.

Errores comunes

  • Editar la wiki pública a mano: el --force lo borra en la siguiente copia. Siempre se edita la privada.
  • Dejar vencer WIKI_MIRROR_TOKEN: un personal access token puede tener fecha de caducidad. Cuando vence, los pasos 2 y 3 fallan con un error de permisos y la wiki pública deja de actualizarse. Nada avisa salvo el correo de GitHub «workflow failed»: conviene anotar la fecha.
  • Escribir el valor de un secreto en el YAML: queda en git para siempre, aunque se borre después. Si pasa, se revoca el token y se crea otro.
  • Dar contents: write «por si acaso»: el principio es dar el permiso mínimo que el trabajo necesita. Este workflow no escribe en angular-ufh, así que no tiene por qué poder.

Resumen

Una Action es un YAML que GitHub ejecuta solo: on: dice cuándo, permissions: y los secretos dicen qué puede tocar, y los steps: son comandos de shell en una máquina que nace y muere con cada ejecución. Esta clona la wiki privada con el token automático de sólo lectura, la empuja con --force a la pública usando un secreto con permiso de escritura, y anota el commit en wiki-ref del repositorio del sitio para que Coolify reconstruya. Los secretos nunca están en el archivo: sólo sus nombres.

Preguntas de entrevista

1. ¿Qué es una GitHub Action y en qué se diferencia de un script?

Respuesta:

Un script es una lista de comandos que alguien ejecuta. Una Action es un archivo YAML que GitHub ejecuta por su cuenta cuando ocurre un evento: un push, una edición de la wiki, un horario. El YAML declara el disparador, los permisos y los pasos; los comandos de shell van dentro de los pasos. La diferencia práctica es que nadie tiene que acordarse de correrlo, y que corre en una máquina limpia de GitHub, no en la tuya.

2. ¿Por qué el workflow usa dos tokens distintos?

Respuesta:

Porque el token automático GITHUB_TOKEN sólo tiene alcance sobre el repositorio donde corre el workflow, y además se limitó a lectura con permissions: contents: read. Con él se clona la wiki privada. Para escribir en otro repositorio, Reskyon/angular-wiki, hace falta un personal access token con ese permiso, guardado como secreto (WIKI_MIRROR_TOKEN). Separar los dos aplica el principio de permiso mínimo: cada paso tiene sólo la llave que necesita.

3. ¿Cómo evitas que un secreto termine en git o en los logs?

Respuesta:

El valor se guarda en Settings → Secrets del repositorio, nunca en el archivo; el YAML sólo lo nombra con ${{ secrets.NOMBRE }}. GitHub lo inyecta como variable de entorno sólo en el paso que lo declara y lo enmascara en los logs como ***. Si un valor llegara a escribirse en el YAML o en un commit, lo correcto es revocar ese token y crear otro, porque el historial de git lo conserva aunque se borre.

4. ¿Para qué sirve $GITHUB_STEP_SUMMARY?

Respuesta:

Es un archivo que GitHub crea en cada paso; lo que se escribe ahí se muestra como Markdown en la página de la ejecución, sin tener que buscar en los logs. Este workflow lo usa para decir qué commit copió y si tocó wiki-ref o no. Es la forma más barata de que una Action explique lo que hizo.