Esta lección cierra una promesa que el curso lleva arrastrando desde el módulo 7. En la lección 07-06 trazamos una frontera explícita: allí hablábamos de integración continua —comprobar automáticamente que lo que se integra funciona— y dejamos para aquí cómo ese código llega a los usuarios. En la 07-04 aplazamos la mecánica de los entornos y la promoción. Y en la 05-05, al crear la etiqueta anotada v1.4.0, dijimos que las etiquetas serían la pieza que dispara un despliegue.

Todo converge aquí.

El cambio de perspectiva es grande. Hasta ahora, Git ha sido una herramienta que usan personas: Ana escribe código, Bruno revisa, Carla integra. A partir de este punto, Git es también —y sobre todo— una herramienta que usan máquinas. Tuberías que clonan, leen etiquetas, comparan hashes, construyen artefactos y publican en producción sin que nadie pulse nada. En una organización con despliegue continuo, la inmensa mayoría de los git clone del día los ejecuta un servidor, no una persona.

Y eso cambia el peso de cada operación. Cuando git push origin main significa "esto estará en producción dentro de cuatro minutos", el rigor deja de ser una virtud profesional y pasa a ser un requisito operativo.

Contenido

  1. Git como fuente única de verdad
  2. Integración, entrega y despliegue continuos
  3. Qué dispara un despliegue y cómo se ata a Git
  4. El artefacto se identifica por el hash del commit
  5. Infraestructura como código
  6. GitOps: el repositorio como estado deseado
  7. Secretos en un mundo automatizado
  8. Versionado y changelog automáticos
  9. Reversión en producción
  10. Que la tubería no dependa de un humano

  1. Git como fuente única de verdad

La idea de fondo de DevOps, en lo que respecta a Git, es una ampliación de lo que ya sabes: el repositorio deja de contener solo código.

Históricamente, un repositorio contenía la aplicación. Todo lo demás —cómo se configura el servidor, qué versión de la base de datos hay, qué variables de entorno existen, cómo se despliega— vivía en la cabeza de alguien, en un documento, en una consola web o en un guion en el escritorio de un administrador.

El cambio consiste en meter todo eso también en Git:

gestor-tareas/
├── index.html
├── estilos.css
├── app.js
├── README.md
├── .github/workflows/          <- definición de las tuberías
│   ├── ci.yml
│   ├── entrega.yml
│   └── despliegue.yml
├── infra/                      <- infraestructura como código
│   ├── produccion.tf
│   ├── preproduccion.tf
│   └── modulos/
├── entornos/                   <- configuración por entorno
│   ├── produccion.yaml
│   ├── preproduccion.yaml
│   └── pruebas.yaml
├── Containerfile               <- cómo se construye el artefacto
└── migraciones/                <- cambios de esquema versionados
    ├── 001-crear-tareas.sql
    └── 002-anadir-oculta.sql

Qué se gana con esto

Propiedad Qué significa en la práctica
Historial "¿Cuándo cambió el límite de memoria del servicio?" es un git log sobre infra/produccion.tf
Revisión Un cambio de infraestructura pasa por el mismo proceso de propuesta y revisión que el código (07-02)
Reversión Volver a una configuración anterior es git revert
Reproducibilidad Un entorno nuevo se levanta desde el repositorio, sin conocimiento tácito
Trazabilidad Cada cambio en producción tiene autor, fecha, mensaje y ticket
Atomicidad Un commit puede cambiar el código y la configuración que necesita, a la vez

Esa última fila es la más valiosa y la que más se subestima. Si app.js empieza a necesitar una variable de entorno nueva, el commit que introduce el código y el que declara la variable son el mismo commit. Nunca existe una revisión del repositorio donde el código pida algo que la configuración no da.

La regla

Si algo puede romper producción, debe estar en Git.

Configuración, infraestructura, migraciones de base de datos, definición de las tuberías, políticas de acceso. Todo lo que sea un fichero de texto y afecte al comportamiento del sistema.

Y el corolario, que es igual de importante:

Si algo está en Git, no debe cambiarse fuera de Git.

Modificar la configuración de producción desde una consola web crea una deriva: el repositorio dice una cosa y la realidad dice otra. En cuanto la deriva existe, el repositorio deja de ser la fuente de verdad y todo el edificio se cae. Es el problema que GitOps ataca directamente (apartado 6).

Lo único que no va en Git

Con una excepción absoluta, ya establecida en la lección 08-05: los secretos, nunca. Contraseñas, claves de API, certificados privados, tokens. Van en un gestor de secretos y se inyectan en el momento del despliegue. Volveremos sobre ello en el apartado 7.

  1. Integración, entrega y despliegue continuos

Tres términos que se confunden constantemente, y cuya diferencia es exactamente dónde está el botón humano.

flowchart LR
    A["Commit<br/>en una rama"] --> B["Compilar<br/>y probar"]
    B --> C["Integrar<br/>en main"]
    C --> D["Construir<br/>el artefacto"]
    D --> E["Publicar en<br/>el registro"]
    E --> F{"Aprobación<br/>humana"}
    F --> G["Desplegar en<br/>producción"]

    subgraph CI["Integración continua (07-06)"]
        A
        B
        C
    end
    subgraph CD1["Entrega continua"]
        D
        E
        F
    end
    subgraph CD2["Despliegue continuo"]
        G
    end
Integración continua Entrega continua Despliegue continuo
Qué garantiza Lo que se integra compila y pasa las pruebas Lo que está en main puede desplegarse en cualquier momento Lo que está en main está desplegado
Dónde acaba En main, verde En un artefacto publicado y listo En producción
Botón humano No hay : alguien aprueba el despliegue No hay
Disparador push a una rama, propuesta Integración en main Integración en main
Frecuencia típica Decenas al día Decenas al día Decenas al día
Qué hace falta Pruebas rápidas y fiables Artefactos reproducibles, entornos de preproducción Todo lo anterior + confianza total en las pruebas + reversión rápida
Riesgo si falla Se bloquea la propuesta Se bloquea la publicación Producción rota

La diferencia entre entrega y despliegue es exactamente un clic. Y la decisión de quitar ese clic no es técnica sino de confianza: se quita cuando el conjunto de pruebas es tan bueno y la reversión tan rápida que un humano mirando la pantalla no aporta seguridad, solo latencia.

Qué se necesita para llegar al despliegue continuo

  1. Pruebas en las que se confía de verdad. Si el equipo ejecuta pruebas manuales "por si acaso" antes de desplegar, no está listo.
  2. Reversión en minutos. El seguro no es "no fallar nunca", sino "arreglarlo antes de que importe".
  3. Despliegue progresivo. Sacar la versión nueva a un porcentaje del tráfico y observar antes de completar.
  4. Observabilidad. Métricas y alertas que detecten un problema antes que los usuarios.
  5. Cambios pequeños. Es la conexión con Trunk Based Development (lección 07-05): cuanto más pequeño es el cambio, menor el riesgo y más fácil identificar la causa.

Ese último punto merece énfasis, porque es donde Git y DevOps se tocan directamente. El despliegue continuo obliga a commits pequeños y bien acotados. Si integras un cambio de 2.000 líneas y producción se rompe, no sabes cuál de las 2.000 fue. Toda la disciplina del módulo 8 —commits atómicos, mensajes que explican el porqué, historial legible— pasa de ser una buena práctica a ser una herramienta de operación.

  1. Qué dispara un despliegue y cómo se ata a Git

Hay tres mecanismos, y conviene entender qué modelo mental implica cada uno.

Mecanismo 1: por rama

name: Desplegar a preproducción

on:
  push:
    branches: [main]

jobs:
  desplegar:
    runs-on: ubuntu-latest
    environment: preproduccion
    steps:
      - uses: actions/checkout@v4

      - name: Construir el artefacto
        run: |
          docker build -t registro.ejemplo.es/gestor-tareas:${{ github.sha }} .
          docker push registro.ejemplo.es/gestor-tareas:${{ github.sha }}

      - name: Desplegar
        run: ./infra/desplegar.sh preproduccion ${{ github.sha }}

Modelo mental: "la rama main es lo que hay en preproducción". La rama deja de ser solo una línea de desarrollo y pasa a ser el puntero a un entorno.

Es el mecanismo natural para entornos no productivos. Cada integración en main actualiza preproducción, sin ceremonia.

Mecanismo 2: por etiqueta anotada

Aquí se cierra lo que prometimos en la lección 05-05.

name: Desplegar a producción

on:
  push:
    tags: ['v*.*.*']

jobs:
  desplegar:
    runs-on: ubuntu-latest
    environment: produccion
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Verificar que la etiqueta es anotada y está firmada
        run: |
          TIPO=$(git cat-file -t "${{ github.ref_name }}")
          if [ "$TIPO" != "tag" ]; then
            echo "ERROR: ${{ github.ref_name }} es una etiqueta ligera."
            echo "Producción solo acepta etiquetas anotadas."
            exit 1
          fi
          git tag --verify "${{ github.ref_name }}"

      - name: Verificar que la etiqueta está en main
        run: |
          git merge-base --is-ancestor "${{ github.ref_name }}" origin/main \
            || { echo "ERROR: la etiqueta no está en main"; exit 1; }

      - name: Desplegar el artefacto ya construido
        run: |
          SHA=$(git rev-list -n1 "${{ github.ref_name }}")
          ./infra/desplegar.sh produccion "$SHA"

Modelo mental: "la etiqueta v1.4.0 es una versión publicable, y publicarla es desplegarla".

Tres detalles que importan mucho:

1. Se exige etiqueta anotada, no ligera. Recuerda de la lección 05-05 que una etiqueta ligera es solo una referencia a un commit, mientras que una anotada es un objeto propio con autor, fecha, mensaje y posibilidad de firma. Para producción, eso es exactamente lo que quieres: un registro de quién declaró esa versión y cuándo. La comprobación git cat-file -t devuelve tag para las anotadas y commit para las ligeras.

2. Se verifica la firma. git tag --verify comprueba la firma GPG (lección 08-05). Así, crear una etiqueta de producción requiere una clave privada, no solo permisos de escritura.

3. Se comprueba que la etiqueta está en main. git merge-base --is-ancestor impide desplegar una etiqueta puesta sobre una rama que nunca se integró. Es una salvaguarda contra el error de etiquetar desde la rama equivocada, que ocurre más de lo que parece.

Muy importante: el paso de despliegue no reconstruye nada. Recupera el artefacto que ya se construyó cuando ese commit entró en main. Volveremos sobre esto en el apartado siguiente, porque es una de las reglas de oro de la entrega.

Mecanismo 3: aprobación manual sobre un commit concreto

name: Desplegar a producción (manual)

on:
  workflow_dispatch:
    inputs:
      commit:
        description: 'Hash completo del commit a desplegar'
        required: true

jobs:
  desplegar:
    runs-on: ubuntu-latest
    environment: produccion    # con revisores obligatorios configurados
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ inputs.commit }}
          fetch-depth: 0

      - name: Comprobar que el commit está en main y tiene artefacto
        run: |
          git merge-base --is-ancestor "${{ inputs.commit }}" origin/main \
            || { echo "ERROR: ese commit no está en main"; exit 1; }
          docker manifest inspect \
            "registro.ejemplo.es/gestor-tareas:${{ inputs.commit }}" > /dev/null \
            || { echo "ERROR: no hay artefacto para ese commit"; exit 1; }

      - name: Desplegar
        run: ./infra/desplegar.sh produccion "${{ inputs.commit }}"

Modelo mental: "cualquier commit de main es candidato; alguien decide cuál y cuándo".

Es el mecanismo más flexible y el que hace falta para volver atrás rápido, como veremos en el apartado 9.

Tabla comparativa

Por rama Por etiqueta Manual sobre commit
Uso típico Preproducción, entornos de prueba Producción con versiones Producción con despliegue continuo; reversiones
Quién decide El proceso de integración Quien crea la etiqueta Quien lanza la ejecución
Trazabilidad Buena Excelente: objeto con autor, fecha, mensaje y firma Buena, si se registra la ejecución
Reversión Difícil: hay que revertir el commit Fácil: desplegar la etiqueta anterior Muy fácil: desplegar el commit anterior
Riesgo de despliegue accidental Alto: cualquier integración despliega Bajo: hay que crear la etiqueta a propósito Muy bajo
Encaja con GitHub Flow, Trunk Based Git Flow, productos con versiones Trunk Based con despliegue continuo

El montaje habitual de gestor-tareas

Los tres mecanismos combinados, cada uno en su sitio:

flowchart TD
    A["Propuesta GT-231"] -->|"CI: pruebas"| B["Integración en main"]
    B -->|"automático<br/>por rama"| C["Preproducción"]
    C -->|"validación"| D["git tag -a -s v1.4.0"]
    D -->|"automático<br/>por etiqueta"| E["Producción"]
    E -.->|"si algo falla:<br/>despliegue manual<br/>de v1.3.0"| F["Reversión"]

  1. El artefacto se identifica por el hash del commit

Esta es probablemente la práctica más importante de toda la lección, y la que más equipos hacen mal.

La regla

Cada artefacto construido —imagen de contenedor, paquete, binario— se etiqueta con el hash completo del commit del que salió.

SHA=$(git rev-parse HEAD)

docker build -t "registro.ejemplo.es/gestor-tareas:$SHA" .
docker push "registro.ejemplo.es/gestor-tareas:$SHA"

# Y además, etiquetas legibles que APUNTAN al mismo artefacto
docker tag "registro.ejemplo.es/gestor-tareas:$SHA" \
           "registro.ejemplo.es/gestor-tareas:v1.4.0"
docker tag "registro.ejemplo.es/gestor-tareas:$SHA" \
           "registro.ejemplo.es/gestor-tareas:main"

El hash es el identificador canónico; v1.4.0 y main son alias legibles que apuntan a él. Exactamente la misma relación que en Git entre un hash de commit y las referencias que lo apuntan (lección 03-01): las etiquetas se mueven, el hash no.

Por qué

1. Elimina la ambigüedad. gestor-tareas:latest no significa nada: es una etiqueta móvil que apunta a lo último que alguien construyó. gestor-tareas:4f8a2e6c9d3b... identifica un contenido exacto y reproducible.

2. Da trazabilidad completa. Ante un error en producción, la cadena se recorre entera y sin preguntar a nadie:

# 1. ¿Qué está desplegado?
kubectl get deployment gestor-tareas -o jsonpath='{.spec.template.spec.containers[0].image}'
# registro.ejemplo.es/gestor-tareas:4f8a2e6c9d3b1a5e7f2c8b0d4a6e9f1c3b5d7a0e

# 2. ¿Qué commit es?
git show 4f8a2e6c

# 3. ¿Qué cambió respecto a la versión anterior?
git log --oneline 9e2f7a4c..4f8a2e6c

# 4. ¿Quién y por qué?
git log -1 --format='%an <%ae>%n%n%B' 4f8a2e6c

# 5. ¿Qué ticket?
git log -1 --format=%B 4f8a2e6c | grep -oE 'GT-[0-9]+'

Del contenedor en producción al ticket, en cinco comandos y sin abrir ninguna interfaz.

3. Permite verificar la coherencia. ¿Está desplegado lo que creemos?

DESPLEGADO=$(kubectl get deployment gestor-tareas \
  -o jsonpath='{.spec.template.spec.containers[0].image}' | cut -d: -f2)

git merge-base --is-ancestor "$DESPLEGADO" origin/main \
  && echo "OK: lo desplegado está en main" \
  || echo "ALERTA: se ha desplegado algo que no está en main"

# ¿Cuántos commits van por delante de producción?
git rev-list --count "$DESPLEGADO"..origin/main

4. Hace posible el despliegue idempotente. Desplegar el mismo hash dos veces produce exactamente el mismo resultado. Con latest, no hay forma de saberlo.

El corolario: construir una vez, desplegar muchas

El artefacto se construye UNA sola vez y ese mismo artefacto recorre todos los entornos.

flowchart LR
    C["commit 4f8a2e6"] --> B["Construcción<br/>ÚNICA"]
    B --> A["Artefacto<br/>:4f8a2e6"]
    A --> E1["Pruebas"]
    A --> E2["Preproducción"]
    A --> E3["Producción"]

Si cada entorno reconstruye el artefacto, lo que has probado no es lo que despliegas. Entre una construcción y otra pueden cambiar las versiones de las dependencias, la imagen base o la versión del compilador. El artefacto que pasó las pruebas y el que llega a producción serían dos cosas distintas, y toda la validación previa deja de significar nada.

Lo que sí cambia entre entornos es la configuración, que se inyecta en el momento del despliegue: variables de entorno, secretos, direcciones de servicios. El artefacto es el mismo binario; el entorno lo parametriza.

Incrustar la procedencia en el artefacto

Conviene que el propio artefacto sepa de dónde vino:

FROM node:22-alpine
ARG COMMIT_SHA
ARG VERSION
ARG FECHA_CONSTRUCCION
LABEL org.opencontainers.image.revision="${COMMIT_SHA}"
LABEL org.opencontainers.image.version="${VERSION}"
LABEL org.opencontainers.image.created="${FECHA_CONSTRUCCION}"
ENV COMMIT_SHA=${COMMIT_SHA}
COPY . /app
WORKDIR /app
RUN npm ci --omit=dev
CMD ["node", "app.js"]
docker build \
  --build-arg COMMIT_SHA="$(git rev-parse HEAD)" \
  --build-arg VERSION="$(git describe --tags --always --dirty)" \
  --build-arg FECHA_CONSTRUCCION="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  -t "registro.ejemplo.es/gestor-tareas:$(git rev-parse HEAD)" .

Ese git describe --tags --always --dirty es una joya para esto. Produce cadenas como:

Salida Qué significa
v1.4.0 Exactamente la etiqueta v1.4.0
v1.4.0-7-g4f8a2e6 7 commits después de v1.4.0, en el commit 4f8a2e6
v1.4.0-7-g4f8a2e6-dirty Lo mismo, pero con cambios sin confirmar: no debería llegar a producción

Ese sufijo -dirty es una alarma valiosa. Un artefacto marcado como dirty se construyó desde una copia de trabajo sucia, lo que significa que no es reproducible desde el repositorio. La tubería debería rechazarlo:

if git describe --always --dirty | grep -q -- '-dirty$'; then
  echo "ERROR: la copia de trabajo tiene cambios sin confirmar."
  exit 1
fi

Y en la aplicación, exponer esa información:

// app.js
app.get('/version', (req, res) => {
  res.json({
    commit: process.env.COMMIT_SHA,
    version: process.env.VERSION,
    construido: process.env.FECHA_CONSTRUCCION
  });
});

Un punto de acceso /version que dice el hash exacto que se está ejecutando resuelve por sí solo la pregunta más frecuente durante una incidencia: "¿qué hay realmente ahí?".

  1. Infraestructura como código

La infraestructura —servidores, redes, bases de datos, balanceadores, permisos— se declara en ficheros de texto versionados, y una herramienta se encarga de que la realidad coincida con lo declarado.

# infra/produccion.tf
resource "servicio_web" "gestor_tareas" {
  nombre     = "gestor-tareas"
  imagen     = "registro.ejemplo.es/gestor-tareas:${var.commit_sha}"
  replicas   = 4
  memoria_mb = 512
  cpu        = "500m"

  variables = {
    ENTORNO   = "produccion"
    NIVEL_LOG = "warn"
  }

  # El secreto NO está aquí: es una referencia al gestor de secretos
  secretos = {
    BD_PASSWORD = "gestor-secretos://produccion/bd/password"
  }
}

Qué gana Git con esto

Todo lo que Git sabe hacer con el código se aplica ahora a la infraestructura:

# ¿Cuándo y por qué subimos a 4 réplicas?
git log -p --follow infra/produccion.tf | grep -B 20 'replicas'

# ¿Quién cambió el límite de memoria?
git blame infra/produccion.tf

# ¿Qué cambia esta propuesta respecto a producción?
git diff origin/main...HEAD -- infra/

# Volver a la configuración de antes del incidente
git revert 4f8a2e6c

Ese git blame sobre un fichero de infraestructura es una capacidad que hace diez años no existía. La pregunta "¿por qué este servicio tiene 512 MB y no 256?" pasa de ser arqueología a ser un comando.

El patrón de plan y aplicación

La práctica estándar consiste en calcular el plan de cambios en la propuesta —para que se pueda revisar— y aplicarlo solo tras la integración:

name: Infraestructura

on:
  pull_request:
    paths: ['infra/**']
  push:
    branches: [main]
    paths: ['infra/**']

jobs:
  plan:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Calcular el plan
        run: |
          terraform init
          terraform plan -no-color -out=plan.bin | tee plan.txt
      - name: Publicar el plan como comentario en la propuesta
        run: ./herramientas/comentar-plan.sh plan.txt

  aplicar:
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: produccion
    steps:
      - uses: actions/checkout@v4
      - name: Aplicar
        run: |
          terraform init
          terraform apply -auto-approve

Publicar el plan en la propuesta es lo que convierte la revisión de infraestructura en algo real: quien revisa no lee un fichero declarativo e imagina el efecto, lee el efecto: "se van a destruir 2 recursos, crear 1 y modificar 3".

Las migraciones de base de datos

Mención aparte, porque es donde el modelo se rompe si no se tiene cuidado. Los ficheros de migración se versionan como todo lo demás:

migraciones/
├── 001-crear-tareas.sql
├── 002-anadir-oculta.sql
└── 003-indice-por-prioridad.sql

Pero hay una asimetría fundamental: el código se revierte fácilmente; los datos, no. Un git revert del commit que añadió una columna no devuelve los datos que esa columna contenía.

De ahí la regla de oro de las migraciones, que condiciona cómo se escriben los commits:

Toda migración debe ser compatible hacia atrás durante al menos un despliegue. La versión antigua del código debe seguir funcionando con el esquema nuevo.

En la práctica, eso convierte "renombrar una columna" en una secuencia de tres despliegues:

  1. Añadir la columna nueva; el código escribe en ambas y lee de la vieja.
  2. Migrar los datos; el código lee de la nueva.
  3. Eliminar la columna vieja.

Tres commits, tres despliegues, y en ningún momento un estado en el que revertir el código rompa la aplicación. Es un ejemplo perfecto de cómo las restricciones de operación se traducen en cómo se parten los commits.

  1. GitOps: el repositorio como estado deseado

GitOps es un modelo operativo con una premisa muy concreta:

El estado deseado del sistema está declarado en un repositorio Git. Un agente que corre dentro del sistema compara continuamente el estado real con el declarado y reconcilia las diferencias.

La diferencia con lo anterior es sutil pero importante: no es la tubería la que empuja los cambios al sistema, sino un agente dentro del sistema que tira de ellos.

flowchart TD
    subgraph git["Repositorio de estado"]
        A["entornos/produccion/<br/>servicio.yaml<br/>imagen: :4f8a2e6"]
    end
    subgraph cluster["Sistema en producción"]
        B["Agente<br/>reconciliador"]
        C["Estado real"]
    end
    A -->|"el agente observa<br/>(pull)"| B
    B -->|"compara"| C
    B -->|"aplica las diferencias"| C
    C -.->|"informa del estado<br/>y de la deriva"| A

Modelo de empuje frente a modelo de tirón

Empuje (tubería despliega) Tirón (GitOps)
Quién aplica los cambios La tubería de CI Un agente dentro del sistema
Credenciales de producción Las tiene la tubería Las tiene el agente; la tubería no
Deriva de configuración No se detecta Se detecta y se corrige sola
Estado tras un cambio manual Se queda así hasta el próximo despliegue Se revierte automáticamente
Superficie de ataque La tubería es un objetivo valioso Menor: nada externo entra al sistema
Auditoría Registros de la tubería El historial de Git es la auditoría
Desplegar Un push a la rama que dispara Un commit en el repositorio de estado

La ventaja de seguridad es contundente y merece explicarse: en el modelo de empuje, tu sistema de CI necesita credenciales de escritura sobre producción. Eso convierte al CI en el objetivo más valioso de la organización. En GitOps, la tubería solo necesita permiso para escribir en un repositorio Git; el agente, que vive dentro del sistema, tira de los cambios. Nada externo tiene acceso a producción.

Cómo se ve un despliegue en GitOps

Desplegar es literalmente hacer un commit:

# La tubería, tras construir y publicar el artefacto:
git clone https://git.ejemplo.es/gestor-tareas-estado.git
cd gestor-tareas-estado

# Actualizar la referencia de la imagen en el entorno correspondiente
sed -i "s|imagen: .*|imagen: registro.ejemplo.es/gestor-tareas:$SHA|" \
  entornos/preproduccion/servicio.yaml

git add entornos/preproduccion/servicio.yaml
git commit -m "deploy: preproduccion a ${SHA:0:7}

Origen: gestor-tareas@$SHA
Ticket: $(git -C ../gestor-tareas log -1 --format=%B "$SHA" | grep -oE 'GT-[0-9]+' | head -1)"

git push origin main

El agente detecta el commit en cuestión de segundos y reconcilia.

Y la promoción a producción es igual de simple:

# Promocionar exactamente lo que hay en preproducción
IMAGEN=$(grep 'imagen:' entornos/preproduccion/servicio.yaml)
sed -i "s|imagen: .*|$IMAGEN|" entornos/produccion/servicio.yaml
git commit -am "deploy: promover a produccion lo validado en preproduccion"
git push

Fíjate en la elegancia: la promoción entre entornos es copiar una línea de un fichero a otro. No se reconstruye nada, no se vuelve a etiquetar nada. Es la materialización literal de "construir una vez, desplegar muchas".

Qué implica para los permisos y las ramas protegidas

Si un commit en el repositorio de estado despliega en producción, entonces el control de acceso al repositorio ES el control de acceso a producción. Eso obliga a tomarse en serio lo que vimos en la lección 07-06:

Medida Por qué es obligatoria aquí
Rama protegida en main del repositorio de estado Un push directo sería un despliegue sin revisión
Revisión obligatoria, y de más de una persona para producción Es la única barrera humana que queda
CODEOWNERS sobre entornos/produccion/ Quien aprueba producción debe ser quien corresponde (10-01)
Prohibir el envío forzado Un push --force reescribiría el historial de auditoría
Commits firmados (08-05) El agente puede exigir firma antes de aplicar
Historial lineal La auditoría debe leerse sin ambigüedad

Y una consecuencia organizativa: la separación entre "repositorio de aplicación" y "repositorio de estado" deja de ser una manía y se convierte en una decisión de seguridad. En el primero se desarrolla; en el segundo se despliega. Cada uno con sus permisos.

La deriva

La propiedad más valiosa de GitOps es la detección de deriva. Si alguien cambia algo a mano en producción —para "arreglarlo rápido" a las tres de la mañana—, el agente detecta que el estado real no coincide con el declarado y lo revierte.

Es incómodo la primera vez que te pasa. Y es exactamente lo que debe ocurrir: obliga a que todo cambio pase por Git, que es lo que hace que el repositorio siga siendo la fuente de verdad. Sin esa reconciliación, "todo está en Git" es una afirmación que se degrada silenciosamente hasta ser falsa.

  1. Secretos en un mundo automatizado

Retomamos la lección 08-05 con el matiz de la automatización.

La regla, sin excepciones

Los secretos nunca van en Git. Ni cifrados con una contraseña débil, ni "solo este de pruebas", ni "es un repositorio privado".

La razón, que ya conoces bien: en Git, borrar no elimina. Un secreto confirmado está en el historial para siempre a menos que se reescriba y se coordine con todo el mundo, y aun así ya ha estado expuesto a todo el que clonara.

Qué va en Git y qué no

Va en Git No va en Git
El nombre de la variable (BD_PASSWORD) Su valor
Una referencia al secreto (gestor-secretos://produccion/bd/password) El secreto
La política de quién puede leerlo La clave que lo desbloquea
Certificados públicos Claves privadas
Secretos cifrados con una clave que no está en Git (patrón aceptable) Secretos cifrados con una clave que sí está

Cómo llega el secreto al proceso

name: Desplegar

on:
  push:
    tags: ['v*.*.*']

jobs:
  desplegar:
    runs-on: ubuntu-latest
    environment: produccion
    permissions:
      id-token: write      # para autenticación federada, sin secretos de larga vida
      contents: read
    steps:
      - uses: actions/checkout@v4

      - name: Obtener credenciales temporales del gestor de secretos
        uses: proveedor/autenticar@v2
        with:
          rol: rol-despliegue-produccion

      - name: Desplegar
        run: ./infra/desplegar.sh produccion "${{ github.sha }}"
        env:
          # Inyectado por el sistema, nunca escrito en ningún fichero
          BD_PASSWORD: ${{ secrets.BD_PASSWORD_PRODUCCION }}

Ese bloque permissions: id-token: write merece explicación, porque es la práctica moderna: en lugar de guardar una credencial de larga vida en el sistema de CI, la tubería obtiene un token de corta duración demostrando su identidad. Si alguien roba ese token, caduca en minutos y solo sirve para ese repositorio y esa rama.

Cuando hay que versionar secretos: cifrado con clave externa

Hay un patrón aceptable, muy usado en GitOps, donde los secretos viven en el repositorio pero cifrados con una clave que no está allí:

# entornos/produccion/secretos.enc.yaml
apiVersion: v1
kind: Secret
metadata:
  name: gestor-tareas-bd
data:
  password: ENC[AES256_GCM,data:8fK2mN...,type:str]
sops:
  kms:
    - arn: 'arn:proveedor:kms:region:cuenta:clave/id-de-la-clave'

El valor cifrado está en Git; la clave para descifrarlo vive en un servicio de gestión de claves al que solo el agente de despliegue tiene acceso. Ventajas: los secretos se versionan, se revisan y se auditan como todo lo demás. Requisito: que la clave nunca entre en el repositorio, y que rotarla sea un procedimiento probado.

Defensa en profundidad

Como en la lección 08-05, tres capas:

- name: Buscar secretos en la propuesta
  run: |
    # Análisis del histórico completo de la rama
    gitleaks detect --source . --log-opts="origin/main..HEAD" --verbose

- name: Comprobar que no hay ficheros de configuración con credenciales
  run: |
    git diff --name-only origin/main...HEAD | while read -r f; do
      case "$f" in
        *.env|*.pem|*.key|*credenciales*|*secreto*)
          echo "ERROR: $f no debería estar en el repositorio"; exit 1 ;;
      esac
    done

Más el hook de servidor (06-01) y el .gitignore (08-03). Y el procedimiento por si aun así ocurre, que empieza siempre por lo mismo: revocar el secreto primero, limpiar el historial después.

  1. Versionado y changelog automáticos

Aquí se cobra la inversión de la lección 08-01. Los Conventional Commits no eran solo una convención estética: son datos estructurados que una máquina puede leer.

De los commits a la versión

Tipo de commit Efecto en SemVer (05-05)
fix: Incrementa PATCH (1.3.41.3.5)
feat: Incrementa MINOR (1.3.41.4.0)
feat!: o pie BREAKING CHANGE: Incrementa MAJOR (1.3.42.0.0)
docs:, chore:, test:, refactor:, style: No incrementa nada
# Qué hay desde la última versión publicada
ULTIMA=$(git describe --tags --abbrev=0)
git log "$ULTIMA"..main --format='%s'
feat(tareas): GT-231 filtrar las tareas ocultas del listado
fix(interfaz): GT-238 corregir el contador tras eliminar una tarea
docs: GT-240 documentar el filtro de ocultas
chore: actualizar dependencias

Un feat y un fix sin cambios incompatibles: la siguiente versión es 1.4.0.

Calcularlo automáticamente

#!/bin/bash
# herramientas/siguiente-version.sh
set -euo pipefail

ULTIMA=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
COMMITS=$(git log "$ULTIMA"..HEAD --format='%s%n%b')

IFS=. read -r MAYOR MENOR PARCHE <<< "${ULTIMA#v}"

if echo "$COMMITS" | grep -qE '^(BREAKING CHANGE|BREAKING-CHANGE):|^[a-z]+(\(.+\))?!:'; then
  MAYOR=$((MAYOR + 1)); MENOR=0; PARCHE=0
elif echo "$COMMITS" | grep -qE '^feat(\(.+\))?!?:'; then
  MENOR=$((MENOR + 1)); PARCHE=0
elif echo "$COMMITS" | grep -qE '^fix(\(.+\))?!?:'; then
  PARCHE=$((PARCHE + 1))
else
  echo "Sin cambios que justifiquen una versión nueva." >&2
  exit 1
fi

echo "v$MAYOR.$MENOR.$PARCHE"

Generar el changelog

#!/bin/bash
# herramientas/changelog.sh
ULTIMA=$(git describe --tags --abbrev=0)
NUEVA=$1

{
  echo "## $NUEVA — $(date +%Y-%m-%d)"
  echo

  echo "### Novedades"
  git log "$ULTIMA"..HEAD --format='%s' \
    | grep -E '^feat' \
    | sed -E 's/^feat(\(([^)]+)\))?!?: /- (\2) /' \
    | sed 's/- () /- /'
  echo

  echo "### Correcciones"
  git log "$ULTIMA"..HEAD --format='%s' \
    | grep -E '^fix' \
    | sed -E 's/^fix(\(([^)]+)\))?!?: /- (\2) /' \
    | sed 's/- () /- /'
  echo

  if git log "$ULTIMA"..HEAD --format='%B' | grep -q 'BREAKING CHANGE:'; then
    echo "### Cambios incompatibles"
    git log "$ULTIMA"..HEAD --format='%B' \
      | grep -A 5 'BREAKING CHANGE:' | sed 's/^/  /'
    echo
  fi

  echo "### Detalles"
  echo "- Commits: $(git rev-list --count "$ULTIMA"..HEAD)"
  echo "- Rango: \`$ULTIMA..$NUEVA\`"
} > /tmp/changelog-nuevo.md

# Anteponer al fichero existente
cat /tmp/changelog-nuevo.md CHANGELOG.md > /tmp/c && mv /tmp/c CHANGELOG.md

La tubería de publicación completa

name: Publicar versión

on:
  workflow_dispatch:

jobs:
  publicar:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0        # imprescindible: describe necesita las etiquetas

      - name: Calcular la versión
        id: version
        run: echo "v=$(./herramientas/siguiente-version.sh)" >> "$GITHUB_OUTPUT"

      - name: Generar el changelog
        run: ./herramientas/changelog.sh "${{ steps.version.outputs.v }}"

      - name: Confirmar el changelog y crear la etiqueta anotada
        run: |
          git config user.name  "Tubería de publicación"
          git config user.email "[email protected]"

          git add CHANGELOG.md
          git commit -m "chore(release): ${{ steps.version.outputs.v }}"

          git tag -a "${{ steps.version.outputs.v }}" \
            -m "$(sed -n '/^## /,/^## /p' CHANGELOG.md | head -n -1)"

          git push origin main
          git push origin "${{ steps.version.outputs.v }}"

Ese git push origin "$VERSION" del final es lo que dispara la tubería de despliegue a producción del apartado 3. El circuito se cierra: de los mensajes de commit sale la versión, de la versión sale la etiqueta, y de la etiqueta sale el despliegue.

Es la mejor justificación posible para la disciplina del módulo 8. Un mensaje mal escrito ya no es solo feo: produce una versión incorrecta y un changelog que miente.

  1. Reversión en producción

Algo falla. ¿Qué se hace?

Las dos opciones

Opción A: git revert y volver a desplegar.

git revert 4f8a2e6c
git push origin main
# La tubería construye y despliega el commit nuevo

Opción B: redesplegar el artefacto anterior.

./infra/desplegar.sh produccion 9e2f7a4c
# o, con etiquetas:
./infra/desplegar.sh produccion v1.3.0

Por qué casi siempre es la B

git revert + desplegar Redesplegar el artefacto anterior
Tiempo hasta la recuperación Minutos: hay que construir, probar y desplegar Segundos: el artefacto ya existe
Riesgo El commit de reversión es código nuevo sin probar en producción Cero: ese artefacto ya estuvo funcionando
¿Puede fallar la reversión? : puede haber conflictos, o revertir de más No
Estado del historial Limpio y explícito main sigue conteniendo el cambio malo
Dependencia de la CI Total: si la CI está caída, no puedes revertir Ninguna
Interacción con migraciones Peligrosa si la migración no es reversible Igual de peligrosa, pero más rápida

La fila decisiva es la segunda: un git revert genera un commit que nunca ha estado en producción. Estás desplegando código nuevo para arreglar un incidente. Redesplegar el artefacto anterior es volver a un estado que sabes con certeza que funcionaba, porque estuvo funcionando hasta hace diez minutos.

Y la primera fila es la que decide en la práctica: durante un incidente, la diferencia entre segundos y minutos es la diferencia entre un susto y una postmortem.

El procedimiento correcto

flowchart TD
    A["Alerta:<br/>producción falla"] --> B["1. REDESPLEGAR<br/>la versión anterior"]
    B --> C["Servicio restablecido"]
    C --> D["2. Investigar<br/>sin prisa"]
    D --> E{"¿Se arregla<br/>rápido?"}
    E -->|sí| F["3a. Corregir hacia adelante:<br/>commit fix + desplegar"]
    E -->|no| G["3b. git revert en main,<br/>investigar con calma"]
    F --> H["4. Postmortem"]
    G --> H

Primero se restablece el servicio. Después se arregla el código. Son dos actividades distintas y mezclarlas alarga la incidencia.

Y el paso 3 no es opcional: si solo redespliegas la versión anterior, main sigue conteniendo el cambio malo, y el siguiente despliegue de cualquier persona lo volverá a llevar a producción. Hay que dejar main en un estado sano, ya sea con un revert o con una corrección.

Cómo se ve en GitOps

Aún más simple, porque revertir el despliegue es revertir un commit del repositorio de estado:

cd gestor-tareas-estado
git revert HEAD          # deshace el commit que cambió la imagen
git push origin main
# El agente reconcilia en segundos

Aquí sí es un git revert, pero fíjate en la diferencia: se revierte el commit de despliegue, no el commit de código. El resultado es que el fichero de estado vuelve a apuntar al artefacto anterior, que ya existe y ya funcionaba. Es la opción B expresada como un commit, con la trazabilidad de la opción A.

Preparar la reversión antes de necesitarla

#!/bin/bash
# herramientas/revertir-produccion.sh
set -euo pipefail

ACTUAL=$(kubectl get deployment gestor-tareas \
  -o jsonpath='{.spec.template.spec.containers[0].image}' | cut -d: -f2)

# La etiqueta anterior a la que corresponde a lo desplegado
ETIQUETA_ACTUAL=$(git tag --points-at "$ACTUAL" | head -1)
ANTERIOR=$(git tag --sort=-v:refname | grep -A1 "^$ETIQUETA_ACTUAL$" | tail -1)
SHA_ANTERIOR=$(git rev-list -n1 "$ANTERIOR")

echo "Desplegado ahora: $ACTUAL ($ETIQUETA_ACTUAL)"
echo "Se revertirá a:   $SHA_ANTERIOR ($ANTERIOR)"
echo
echo "Cambios que se van a deshacer:"
git log --oneline "$SHA_ANTERIOR".."$ACTUAL"
echo
read -rp "¿Confirmar? (escribe SI): " R
[ "$R" = "SI" ] || exit 1

./infra/desplegar.sh produccion "$SHA_ANTERIOR"

Un guion así, probado y con permisos claros, es la diferencia entre una reversión de treinta segundos y una de veinte minutos buscando en el historial de la terminal a las tres de la madrugada. Escríbelo y pruébalo un martes por la mañana, no durante un incidente.

Cuándo el revert sí es lo correcto

  • El fallo no es urgente: un error visual, algo que afecta a poca gente.
  • El artefacto anterior no es desplegable: hubo una migración de datos irreversible entremedias.
  • El cambio malo se detecta antes de llegar a producción, en preproducción.
  • Se quiere que el historial refleje explícitamente que ese cambio se deshizo.

  1. Que la tubería no dependa de un humano

Recopilación final de prácticas, cada una con su motivo.

  1. Todo en el repositorio, nada en la interfaz

Si la tubería está definida en un fichero YAML versionado, sus cambios se revisan, se revierten y tienen historial. Si está configurada haciendo clic en una web, no hay historial, no hay revisión y nadie sabe quién cambió qué.

  1. Fijar las versiones de todo

# Frágil: "v4" puede cambiar bajo tus pies
- uses: actions/checkout@v4

# Reproducible: un hash concreto
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11

Es exactamente el mismo principio que rige todo el curso: identificar por contenido, no por nombre móvil. Una etiqueta se puede mover; un hash, no.

  1. Idempotencia

Ejecutar la tubería dos veces sobre el mismo commit debe producir el mismo resultado. Si la segunda ejecución falla porque "la etiqueta ya existe" o "el artefacto ya está publicado", la tubería no es idempotente y cualquier reintento se convierte en una intervención manual.

  1. Que falle rápido y con un mensaje claro

# Malo
terraform apply -auto-approve

# Bueno
if ! git merge-base --is-ancestor "$COMMIT" origin/main; then
  echo "ERROR: el commit $COMMIT no está en main."
  echo "Solo se despliega lo integrado. Integra la propuesta primero."
  exit 1
fi

El mensaje debe decir qué ha pasado y qué hacer. Un error que solo dice exit 1 obliga a leer el guion.

  1. Registrar el porqué, no solo el qué

git commit -m "deploy: produccion a v1.4.0

Commit:  4f8a2e6c9d3b1a5e7f2c8b0d4a6e9f1c3b5d7a0e
Tickets: GT-231, GT-238
Aprobado por: Ana Ferrer
Ejecución: https://ci.ejemplo.es/ejecuciones/8842"

Un despliegue anotado así se investiga en un minuto seis meses después.

  1. Comprobaciones previas explícitas

Antes de tocar producción, verificar:

# ¿El commit está en main?
git merge-base --is-ancestor "$COMMIT" origin/main

# ¿La copia de trabajo está limpia?
git describe --always --dirty | grep -q -- '-dirty$' && exit 1

# ¿La etiqueta es anotada y está firmada?
[ "$(git cat-file -t "$ETIQUETA")" = "tag" ] || exit 1
git tag --verify "$ETIQUETA"

# ¿Existe el artefacto?
docker manifest inspect "registro.ejemplo.es/gestor-tareas:$COMMIT" >/dev/null

  1. Los mismos comandos en local y en la tubería

Si la CI ejecuta npm test y tú también, reproducir un fallo es trivial. Si la CI ejecuta doce pasos escritos en el YAML que no existen en ningún guion, no puedes reproducir nada. Extrae la lógica a guiones del repositorio y que la tubería solo los invoque.

  1. Ramas protegidas en serio

La protección de main (07-06) es la última barrera. Con despliegue continuo, un push --force a main puede desplegar cualquier cosa. Prohibido, sin excepciones ni "cuentas de emergencia".

Errores Comunes y Consejos

Error 1: reconstruir el artefacto en cada entorno. Lo que pruebas deja de ser lo que despliegas. Construir una vez, desplegar muchas; lo que cambia entre entornos es la configuración inyectada.

Error 2: usar latest como identificador. No identifica nada. El hash completo del commit como etiqueta canónica, y los nombres legibles como alias.

Error 3: git revert como primera reacción a un incidente. Despliega código nuevo sin probar para arreglar un problema urgente. Primero redesplegar lo anterior, después arreglar main.

Error 4: olvidar fetch-depth: 0 cuando la tubería usa git describe o compara ramas. Es el error de la lección 10-04: con un clon superficial no hay etiquetas ni base de fusión. Y en repositorios grandes, fetch-depth: 0 + filter: blob:none.

Error 5: secretos en el repositorio "porque es privado". Un repositorio privado tiene decenas de clones, réplicas, copias de seguridad y sistemas de CI con acceso. La regla no admite matices.

Error 6: etiquetas ligeras para producción. Sin autor, sin fecha, sin mensaje, sin firma. Y se pueden mover con git tag -f, cosa que un objeto de etiqueta anotada y firmada hace mucho más difícil de disimular.

Error 7: cambiar producción a mano. Crea deriva y el repositorio deja de ser la fuente de verdad. Si un cambio urgente es inevitable, el commit correspondiente se hace inmediatamente después, sin excepción.

Error 8: migraciones de base de datos no compatibles hacia atrás. Hacen imposible la reversión. Es la restricción operativa que más condiciona cómo se parten los commits.

Consejo 1: expón /version en la aplicación. Con el hash del commit. Responde a "¿qué hay realmente en producción?" sin acceso al sistema de despliegue.

Consejo 2: escribe el guion de reversión antes de necesitarlo. Y pruébalo. Un martes por la mañana.

Consejo 3: usa git describe --dirty para detectar construcciones no reproducibles. El sufijo -dirty significa que ese artefacto no se puede reconstruir desde el repositorio: rechazarlo.

Consejo 4: separa el repositorio de aplicación del de estado. Permisos distintos, historial de auditoría separado, y menos posibilidades de que un cambio de código toque producción por accidente.

Consejo 5: un panel que compare lo desplegado con main. git rev-list --count $DESPLEGADO..origin/main dice cuántos commits esperan. Es la métrica de "cuánto valor está parado".

Consejo 6: firma las etiquetas de producción. Así crear una versión requiere una clave privada, no solo permisos de escritura en el repositorio.

Ejercicios

Ejercicio 1: diseñar la tubería de despliegue de gestor-tareas

El equipo quiere:

  • Cada integración en main despliega automáticamente a preproducción.
  • Producción se despliega solo al crear una etiqueta anotada y firmada vX.Y.Z.
  • El artefacto se construye una vez y se reutiliza.
  • Debe ser posible revertir producción en menos de un minuto.

Escribe la tubería (o tuberías) en YAML, indicando qué comprobaciones de Git incluyes en cada punto y por qué. Explica el flujo completo de un cambio, desde la propuesta hasta producción.

Ejercicio 2: trazabilidad inversa desde un incidente

Son las 03:12. Alerta: gestor-tareas devuelve error 500 en producción. Lo único que sabes es que el contenedor desplegado es:

registro.ejemplo.es/gestor-tareas:4f8a2e6c9d3b1a5e7f2c8b0d4a6e9f1c3b5d7a0e

Escribe la secuencia de comandos para:

  1. Identificar qué versión y qué cambios contiene.
  2. Determinar si el problema entró con el último despliegue.
  3. Encontrar el commit sospechoso.
  4. Revertir producción.
  5. Dejar main en un estado sano.

Explica en qué orden lo harías y por qué.

Ejercicio 3: detectar problemas en una tubería ajena

Revisa esta tubería y señala todos los problemas, ordenados por gravedad, explicando qué puede fallar y cómo lo corregirías:

name: Desplegar

on:
  push:
    branches: [main]

jobs:
  desplegar:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@main

      - name: Construir
        run: |
          docker build -t gestor-tareas:latest .
          docker push registro.ejemplo.es/gestor-tareas:latest

      - name: Etiquetar la versión
        run: |
          VERSION=$(cat VERSION)
          git tag $VERSION
          git push origin $VERSION

      - name: Desplegar a producción
        run: |
          ssh [email protected] \
            "docker pull registro.ejemplo.es/gestor-tareas:latest && \
             DB_PASS=Verano2026! docker compose up -d"

      - name: Notificar
        run: curl -X POST https://chat.ejemplo.es/hook -d "Desplegado"

Soluciones

Solución 1

Tubería 1: integración (ya existe, de la lección 07-06). Pruebas y linter en cada propuesta, main protegida.

Tubería 2: construcción y despliegue a preproducción.

name: Construir y desplegar a preproducción

on:
  push:
    branches: [main]

jobs:
  construir:
    runs-on: ubuntu-latest
    outputs:
      sha: ${{ steps.info.outputs.sha }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0        # describe necesita las etiquetas

      - id: info
        name: Recopilar la procedencia
        run: |
          SHA=$(git rev-parse HEAD)
          DESC=$(git describe --tags --always --dirty)

          # COMPROBACIÓN: nada de construcciones no reproducibles
          case "$DESC" in
            *-dirty) echo "ERROR: copia de trabajo sucia"; exit 1 ;;
          esac

          echo "sha=$SHA"   >> "$GITHUB_OUTPUT"
          echo "desc=$DESC" >> "$GITHUB_OUTPUT"

      - name: Construir el artefacto UNA sola vez
        run: |
          docker build \
            --build-arg COMMIT_SHA="${{ steps.info.outputs.sha }}" \
            --build-arg VERSION="${{ steps.info.outputs.desc }}" \
            --build-arg FECHA_CONSTRUCCION="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
            -t "registro.ejemplo.es/gestor-tareas:${{ steps.info.outputs.sha }}" .

          docker push "registro.ejemplo.es/gestor-tareas:${{ steps.info.outputs.sha }}"

  preproduccion:
    needs: construir
    runs-on: ubuntu-latest
    environment: preproduccion
    steps:
      - name: Desplegar el artefacto ya construido
        run: ./infra/desplegar.sh preproduccion "${{ needs.construir.outputs.sha }}"

      - name: Comprobación de vida
        run: |
          sleep 10
          DESPLEGADO=$(curl -sf https://pre.ejemplo.es/version | jq -r .commit)
          [ "$DESPLEGADO" = "${{ needs.construir.outputs.sha }}" ] \
            || { echo "ERROR: se esperaba otro commit en /version"; exit 1; }

Comprobaciones incluidas y por qué:

Comprobación Motivo
fetch-depth: 0 git describe necesita las etiquetas; un clon superficial las omite (10-04)
Rechazar -dirty Un artefacto construido desde una copia sucia no es reproducible
Etiquetar con el hash completo Identificador canónico y no ambiguo
Incrustar procedencia como LABEL y ENV Permite el /version y la auditoría del artefacto
Comprobación de vida contra /version Verifica que se desplegó lo que se creía, no algo cacheado

Tubería 3: producción por etiqueta.

name: Desplegar a producción

on:
  push:
    tags: ['v*.*.*']

jobs:
  desplegar:
    runs-on: ubuntu-latest
    environment: produccion       # con revisores obligatorios
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Verificaciones previas
        id: verif
        run: |
          ETIQUETA="${{ github.ref_name }}"

          # 1. Debe ser anotada, no ligera
          [ "$(git cat-file -t "$ETIQUETA")" = "tag" ] \
            || { echo "ERROR: $ETIQUETA es ligera. Producción exige anotada."; exit 1; }

          # 2. Debe estar firmada
          git tag --verify "$ETIQUETA" \
            || { echo "ERROR: firma no válida en $ETIQUETA"; exit 1; }

          # 3. Debe estar en main
          git fetch origin main
          git merge-base --is-ancestor "$ETIQUETA" origin/main \
            || { echo "ERROR: $ETIQUETA no está en main"; exit 1; }

          # 4. El artefacto debe existir YA (no se reconstruye)
          SHA=$(git rev-list -n1 "$ETIQUETA")
          docker manifest inspect "registro.ejemplo.es/gestor-tareas:$SHA" >/dev/null \
            || { echo "ERROR: no hay artefacto para $SHA"; exit 1; }

          echo "sha=$SHA" >> "$GITHUB_OUTPUT"

      - name: Desplegar
        run: ./infra/desplegar.sh produccion "${{ steps.verif.outputs.sha }}"

      - name: Verificar y registrar
        run: |
          sleep 15
          DESPLEGADO=$(curl -sf https://gestor-tareas.ejemplo.es/version | jq -r .commit)
          [ "$DESPLEGADO" = "${{ steps.verif.outputs.sha }}" ] || exit 1

          echo "Producción: ${{ github.ref_name }} (${{ steps.verif.outputs.sha }})"
          git log --oneline "$(git describe --tags --abbrev=0 '${{ github.ref_name }}^')"..'${{ github.ref_name }}'

Tubería 4: reversión rápida (el requisito de "menos de un minuto").

name: Revertir producción

on:
  workflow_dispatch:
    inputs:
      etiqueta:
        description: 'Etiqueta a la que revertir (ej. v1.3.0)'
        required: true

jobs:
  revertir:
    runs-on: ubuntu-latest
    environment: produccion-urgente    # sin revisores: es una emergencia
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Desplegar el artefacto EXISTENTE de esa etiqueta
        run: |
          SHA=$(git rev-list -n1 "${{ inputs.etiqueta }}")
          docker manifest inspect "registro.ejemplo.es/gestor-tareas:$SHA" >/dev/null \
            || { echo "ERROR: no existe artefacto para esa etiqueta"; exit 1; }
          ./infra/desplegar.sh produccion "$SHA"

Es rápida precisamente porque no construye nada: el artefacto ya existe desde el día en que ese commit entró en main.

Flujo completo de un cambio:

  1. Ana crea GT-231-filtro-ocultas, trabaja, abre la propuesta.
  2. CI ejecuta pruebas y linter (07-06). Bruno revisa y aprueba.
  3. Se integra en main (rama protegida, squash).
  4. La tubería 2 construye el artefacto :4f8a2e6... una vez y despliega a preproducción.
  5. El equipo valida en preproducción.
  6. Cuando toca publicar: ./herramientas/siguiente-version.sh calcula v1.4.0 desde los Conventional Commits, se genera el changelog y se crea git tag -a -s v1.4.0.
  7. El push de la etiqueta dispara la tubería 3, que verifica y despliega el artefacto ya construido en el paso 4.
  8. Si algo falla: tubería 4 con v1.3.0, en segundos. Después, git revert en main con calma.

Solución 2

El orden importa muchísimo, y es contraintuitivo: primero se restablece el servicio, después se investiga. Pero con una salvedad: los pasos 1 a 3 cuestan segundos y dan la información necesaria para revertir con criterio.

# ========== FASE 1: SITUARSE (30 segundos) ==========

# 1. ¿Qué commit es y qué versión?
git show --stat 4f8a2e6c
git tag --points-at 4f8a2e6c
git describe --tags 4f8a2e6c
v1.4.0
# 2. ¿Qué cambió respecto a la versión anterior?
ANTERIOR=$(git describe --tags --abbrev=0 4f8a2e6c^)
echo "Versión anterior: $ANTERIOR"
git log --oneline "$ANTERIOR"..4f8a2e6c
Versión anterior: v1.3.0
4f8a2e6 feat(tareas): GT-231 filtrar las tareas ocultas del listado
8a1f6c3 fix(interfaz): GT-238 corregir el contador tras eliminar
2e9f4c7 chore: actualizar dependencias
# 3. ¿Cuándo se desplegó? ¿Coincide con el inicio de la alerta?
git log -1 --format='%ci' 4f8a2e6c
git for-each-ref --format='%(refname:short) %(creatordate:iso)' refs/tags/v1.4.0

Si la etiqueta se creó a las 03:05 y la alerta saltó a las 03:12, el problema entró con este despliegue. Esa correlación es lo que justifica revertir en lugar de investigar.

# ========== FASE 2: RESTABLECER (inmediato) ==========

# 4. Revertir a la versión anterior, YA
./herramientas/revertir-produccion.sh
# o directamente:
./infra/desplegar.sh produccion "$(git rev-list -n1 v1.3.0)"

# 5. Verificar
curl -sf https://gestor-tareas.ejemplo.es/version | jq

Por qué aquí y no después: cada minuto de investigación es un minuto de servicio caído. El artefacto de v1.3.0 estuvo funcionando hasta hace veinte minutos: es la opción de riesgo cero.

Por qué NO hacer git revert ahora: generaría un commit nuevo, habría que construirlo (minutos), probarlo y desplegarlo, y ese artefacto nunca ha estado en producción. Es exactamente lo que no se quiere durante un incidente.

# ========== FASE 3: INVESTIGAR (con el servicio restablecido) ==========

# 6. Solo tres commits sospechosos. Ver qué hace cada uno
git log -p "$ANTERIOR"..4f8a2e6c

# 7. Si no es evidente, bisect entre las dos versiones (lección 06-02)
git bisect start 4f8a2e6c v1.3.0
git bisect run ./pruebas/reproducir-error-500.sh

# 8. Cuando aparezca el culpable, entender la intención (lección 06-03)
git log -1 --format=%B <commit-culpable>
git blame -L 40,60 app.js

Con solo tres commits, bisect es exagerado; se usaría en un rango de cincuenta. Aquí, leer los tres diffs es más rápido.

# ========== FASE 4: DEJAR main SANO ==========

# 9a. Si la causa está clara y es pequeña: corregir hacia adelante
git switch -c GT-245-corregir-error-500 main
# ... arreglar ...
git commit -m "fix(tareas): GT-245 corregir error 500 al filtrar tareas ocultas

El filtro introducido en GT-231 no contemplaba el caso de tareas sin
la marca 'oculta' definida, lo que provocaba una excepción no capturada
en renderizarTareas().

Se añade una comprobación explícita y una prueba de regresión.

Refs: GT-231, GT-245"
# Propuesta, revisión, integración, nueva versión v1.4.1

# 9b. Si no está clara o hay prisa: revertir el commit problemático
git revert 4f8a2e6c
git push origin main

Por qué el paso 9 es obligatorio: si solo se redesplegó v1.3.0, main sigue conteniendo el fallo. La próxima persona que publique una versión lo llevará otra vez a producción sin saberlo. La reversión del despliegue restablece el servicio; solo el paso 9 arregla el problema.

# ========== FASE 5: APRENDER ==========
# ¿Por qué las pruebas no lo detectaron? -> añadir la prueba que falta.
# ¿Por qué no lo vimos en preproducción? -> ¿faltan datos representativos?
# ¿Cuánto tardó la reversión? -> si fue mucho, mejorar el guion.

Solución 3

Ocho problemas, de más grave a menos.

1. CRÍTICO — Secreto en texto claro en el guion.

DB_PASS=Verano2026! docker compose up -d

La contraseña de la base de datos de producción está en un fichero versionado, en el historial para siempre, visible para todo el que clone, y probablemente en los registros de cada ejecución de la tubería. Viola la lección 08-05 de la forma más directa posible.

Corrección: revocar la contraseña ahora mismo (antes que nada), guardarla en el gestor de secretos, inyectarla como variable de entorno del sistema de CI y, si el repositorio es público o ha sido clonado, limpiar el historial con git filter-repo (08-05).

env:
  DB_PASS: ${{ secrets.BD_PASSWORD_PRODUCCION }}

2. CRÍTICO — Despliegue a producción sin ninguna barrera.

Cualquier integración en main va directa a producción, sin environment, sin aprobación, sin comprobaciones previas, sin preproducción. Un cambio de una línea mal revisado llega a los usuarios en dos minutos.

Corrección: separar en dos tuberías (integración a preproducción por rama; producción por etiqueta anotada y firmada), y declarar environment: produccion con revisores obligatorios.

3. GRAVE — latest como identificador.

docker build -t gestor-tareas:latest .

No identifica nada. Imposible saber qué se está ejecutando, imposible revertir a una versión concreta, imposible reproducir un problema. Y una condición de carrera evidente: si dos integraciones ocurren seguidas, la segunda puede sobrescribir latest mientras la primera despliega.

Corrección: etiquetar con el hash completo del commit y usar los nombres legibles solo como alias.

4. GRAVE — Se reconstruye en cada despliegue y no hay separación construcción/despliegue.

No hay entorno de preproducción, así que lo que llega a producción no se ha probado nunca desplegado. Y como se construye aquí mismo, no hay forma de garantizar que el artefacto de producción sea el mismo que pasó las pruebas.

Corrección: construir una vez con el hash, desplegar ese mismo artefacto en preproducción y luego en producción.

5. GRAVE — La versión sale de un fichero VERSION y la etiqueta es ligera.

VERSION=$(cat VERSION)
git tag $VERSION

Tres problemas en tres líneas:

  • Etiqueta ligera (git tag sin -a): sin autor, sin fecha, sin mensaje, sin firma.
  • Falla si la versión no cambió: git tag sobre una etiqueta existente da error y rompe la tubería. No es idempotente.
  • El fichero VERSION se actualiza a mano: alguien lo olvidará.

Corrección: calcular la versión desde los Conventional Commits (apartado 8) y crear etiqueta anotada y firmada:

VERSION=$(./herramientas/siguiente-version.sh)
git tag -a -s "$VERSION" -m "Versión $VERSION"

6. MEDIO — actions/checkout@main.

Referencia a una rama móvil: lo que la tubería ejecuta puede cambiar sin que nadie lo decida. Es un riesgo de cadena de suministro y de reproducibilidad.

Corrección: fijar por hash. Y añadir fetch-depth: 0 si se van a usar etiquetas o comparaciones.

7. MEDIO — Sin comprobaciones ni verificación posterior.

No comprueba nada antes de tocar producción, y no verifica nada después. La notificación dice "Desplegado" pase lo que pase, porque el curl se ejecuta igual: no hay if: success(), ni se comprueba que el servicio responda.

Corrección: comprobaciones previas (commit en main, copia limpia, artefacto existente) y una comprobación de vida contra /version que confirme el hash desplegado.

8. MENOR — ssh con docker compose como mecanismo de despliegue.

Sin control de versiones del estado, sin reversión, sin despliegue progresivo, sin registro. Si la conexión SSH se corta a mitad, el estado queda indeterminado.

Corrección: un guion de despliegue versionado en el repositorio (infra/desplegar.sh) que sea idempotente y registre lo que hace; y a medio plazo, valorar GitOps (apartado 6), que además elimina la necesidad de que la tubería tenga credenciales de producción.

Resumen de la corrección: esta tubería hace todo en un solo trabajo sin barreras, con un secreto en claro y sin identificación reproducible del artefacto. La reescritura correcta es la del ejercicio 1: separar construcción de despliegue, identificar por hash, exigir etiqueta anotada y firmada para producción, inyectar secretos desde el gestor, y añadir verificación antes y después.

Conclusión

Git deja de ser una herramienta de desarrolladores y pasa a ser la pieza central de la operación.

  • El repositorio se convierte en la fuente única de verdad: no solo código, también infraestructura, configuración por entorno, migraciones y la definición de las propias tuberías. La regla: si algo puede romper producción, debe estar en Git; y si está en Git, no debe cambiarse fuera de Git. Con una excepción absoluta: los secretos.
  • Integración, entrega y despliegue continuos se distinguen por dónde está el botón humano: la integración garantiza que lo que entra funciona, la entrega que lo que está en main puede desplegarse, y el despliegue que está desplegado. Quitar el botón es una decisión de confianza que exige pruebas fiables, reversión en minutos y cambios pequeños.
  • Un despliegue se dispara por rama (preproducción), por etiqueta anotada y firmada (producción, cerrando la 05-05) o por aprobación manual sobre un commit. Las verificaciones que importan son de Git puro: git cat-file -t para exigir etiqueta anotada, git tag --verify para la firma, y git merge-base --is-ancestor para exigir que esté en main.
  • El artefacto se identifica por el hash completo del commit, y se construye una vez y se despliega muchas. Es la misma idea que sostiene todo Git: identificar por contenido, no por un nombre que se mueve. De ahí sale la trazabilidad completa —del contenedor al ticket en cinco comandos— y el git describe --dirty que detecta construcciones no reproducibles.
  • La infraestructura como código da a los servidores todo lo que Git sabe hacer: historial, blame, revisión, revert. Y las migraciones de base de datos imponen la restricción que más condiciona los commits: deben ser compatibles hacia atrás durante al menos un despliegue, porque el código se revierte y los datos no.
  • GitOps invierte el sentido: un agente dentro del sistema tira del estado declarado y reconcilia. Gana en seguridad (la tubería no tiene credenciales de producción), detecta y corrige la deriva, y convierte el historial de Git en el registro de auditoría. A cambio, el control de acceso al repositorio es el control de acceso a producción, y las ramas protegidas dejan de ser una recomendación.
  • Los secretos se inyectan, nunca se versionan; y la práctica moderna sustituye las credenciales de larga vida por tokens de corta duración obtenidos por identidad federada.
  • El versionado y el changelog automáticos cobran la inversión de la 08-01: los Conventional Commits son datos estructurados de los que salen la versión SemVer, el changelog y —vía etiqueta— el despliegue.
  • Y la reversión en producción es casi siempre redesplegar el artefacto anterior, no git revert: el artefacto anterior estuvo funcionando hace diez minutos y está listo en segundos, mientras que un revert genera código nuevo sin probar que hay que construir y desplegar. Primero se restablece el servicio; después se deja main sano, que no es opcional.

La idea que atraviesa toda la lección:

En una organización con despliegue automatizado, git push deja de significar "he guardado mi trabajo" y pasa a significar "esto llegará a los usuarios". Todo el rigor del curso —commits atómicos, mensajes que explican el porqué, historial limpio, etiquetas anotadas, ramas protegidas— deja de ser una virtud profesional y se convierte en un requisito operativo.

Lo que viene

Ya sabes usar Git en todos los contextos que existen hoy: solo, en equipo, en proyectos enormes y en producción automatizada. Queda una última pregunta, que es la más honesta que puede hacerse al final de un curso técnico: ¿cuánto de esto seguirá siendo cierto dentro de diez años?

Git no está quieto. Hay cosas que ya existen y puedes usar hoy aunque casi nadie las conozca: un formato de referencias nuevo que sustituye a refs/ y packed-refs, un git que puede funcionar con SHA-256 en lugar de SHA-1, los clones parciales y el índice disperso que acabamos de ver, y la consolidación de switch y restore frente al viejo checkout. Y hay cosas que son tendencia o especulación, que conviene distinguir con claridad de las anteriores.

También hay algo que casi con seguridad no va a cambiar, y es precisamente lo que aprendiste en el módulo 1: el modelo de datos direccionable por contenido, el grafo dirigido acíclico de commits y la naturaleza distribuida. Por eso lo que llevas aprendido no caduca.

La lección 10-06: El Futuro de Git cierra el curso: qué está maduro y qué es promesa, hacia dónde apuntan las herramientas, cómo seguir aprendiendo por tu cuenta, y una recapitulación completa del camino recorrido.

Dominando Git: De Principiante a Avanzado

Módulo 1: Introducción a Git

Módulo 2: Operaciones Básicas de Git

Módulo 3: Ramas y Fusión

Módulo 4: Trabajando con Repositorios Remotos

Módulo 5: Operaciones Avanzadas de Git

Módulo 6: Herramientas y Técnicas de Git

Módulo 7: Estrategias de Colaboración y Flujo de Trabajo

Módulo 8: Mejores Prácticas y Consejos de Git

Módulo 9: Solución de Problemas y Depuración

Módulo 10: Git en el Mundo Real

© Copyright 2026. Todos los derechos reservados