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
- Git como fuente única de verdad
- Integración, entrega y despliegue continuos
- Qué dispara un despliegue y cómo se ata a Git
- El artefacto se identifica por el hash del commit
- Infraestructura como código
- GitOps: el repositorio como estado deseado
- Secretos en un mundo automatizado
- Versionado y changelog automáticos
- Reversión en producción
- Que la tubería no dependa de un humano
- 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.sqlQué 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.
- 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 | Sí: 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
- Pruebas en las que se confía de verdad. Si el equipo ejecuta pruebas manuales "por si acaso" antes de desplegar, no está listo.
- Reversión en minutos. El seguro no es "no fallar nunca", sino "arreglarlo antes de que importe".
- Despliegue progresivo. Sacar la versión nueva a un porcentaje del tráfico y observar antes de completar.
- Observabilidad. Métricas y alertas que detecten un problema antes que los usuarios.
- 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.
- 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"]
- 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/main4. 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
fiY 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í?".
- 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 4f8a2e6cEse 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-approvePublicar 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:
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:
- Añadir la columna nueva; el código escribe en ambas y lee de la vieja.
- Migrar los datos; el código lee de la nueva.
- 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.
- 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 mainEl 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 pushFí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.
- 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 sí 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
doneMá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.
- 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.4 → 1.3.5) |
feat: |
Incrementa MINOR (1.3.4 → 1.4.0) |
feat!: o pie BREAKING CHANGE: |
Incrementa MAJOR (1.3.4 → 2.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.mdLa 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.
- Reversión en producción
Algo falla. ¿Qué se hace?
Las dos opciones
Opción A: git revert y volver a desplegar.
Opción B: redesplegar el artefacto anterior.
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? | Sí: 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 segundosAquí 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.
- Que la tubería no dependa de un humano
Recopilación final de prácticas, cada una con su motivo.
- 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é.
- Fijar las versiones de todo
# Frágil: "v4" puede cambiar bajo tus pies
- uses: actions/checkout@v4
# Reproducible: un hash concreto
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11Es 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.
- 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.
- 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
fiEl mensaje debe decir qué ha pasado y qué hacer. Un error que solo dice exit 1 obliga a leer el guion.
- 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.
- 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
- 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.
- 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
maindespliega 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:
Escribe la secuencia de comandos para:
- Identificar qué versión y qué cambios contiene.
- Determinar si el problema entró con el último despliegue.
- Encontrar el commit sospechoso.
- Revertir producción.
- Dejar
mainen 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:
- Ana crea
GT-231-filtro-ocultas, trabaja, abre la propuesta. - CI ejecuta pruebas y linter (07-06). Bruno revisa y aprueba.
- Se integra en
main(rama protegida, squash). - La tubería 2 construye el artefacto
:4f8a2e6...una vez y despliega a preproducción. - El equipo valida en preproducción.
- Cuando toca publicar:
./herramientas/siguiente-version.shcalculav1.4.0desde los Conventional Commits, se genera el changelog y se creagit tag -a -s v1.4.0. - El
pushde la etiqueta dispara la tubería 3, que verifica y despliega el artefacto ya construido en el paso 4. - Si algo falla: tubería 4 con
v1.3.0, en segundos. Después,git revertenmaincon 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# 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"..4f8a2e6cVersió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.0Si 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 | jqPor 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.jsCon 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 mainPor 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.
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).
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.
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.
Tres problemas en tres líneas:
- Etiqueta ligera (
git tagsin-a): sin autor, sin fecha, sin mensaje, sin firma. - Falla si la versión no cambió:
git tagsobre una etiqueta existente da error y rompe la tubería. No es idempotente. - El fichero
VERSIONse actualiza a mano: alguien lo olvidará.
Corrección: calcular la versión desde los Conventional Commits (apartado 8) y crear etiqueta anotada y firmada:
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
mainpuede 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 -tpara exigir etiqueta anotada,git tag --verifypara la firma, ygit merge-base --is-ancestorpara exigir que esté enmain. - 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 --dirtyque 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 unrevertgenera código nuevo sin probar que hay que construir y desplegar. Primero se restablece el servicio; después se dejamainsano, que no es opcional.
La idea que atraviesa toda la lección:
En una organización con despliegue automatizado,
git pushdeja 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
- ¿Qué es Git?
- Instalando Git
- Terminología Básica de Git
- El Modelo de Datos de Git
- Configurando Git
- Configuración Inicial
Módulo 2: Operaciones Básicas de Git
- Creando un Repositorio
- Clonando un Repositorio
- Flujo de Trabajo Básico de Git
- Preparando y Confirmando Cambios
- Inspeccionando Cambios con git diff
- Visualizando el Historial de Confirmaciones
Módulo 3: Ramas y Fusión
- Entendiendo las Ramas
- Creando y Cambiando Ramas
- Fusionando Ramas
- Estrategias de Fusión
- Resolviendo Conflictos de Fusión
- Gestión de Ramas
Módulo 4: Trabajando con Repositorios Remotos
- Entendiendo los Repositorios Remotos
- Agregando un Repositorio Remoto
- Autenticación con Repositorios Remotos
- Obteniendo y Extrayendo Cambios
- Enviando Cambios
- Rastreando Ramas
Módulo 5: Operaciones Avanzadas de Git
- Rebase
- Rebase Interactivo
- Cherry-Picking de Confirmaciones
- Guardando Cambios Temporales
- Etiquetando Confirmaciones
- Revirtiendo Confirmaciones
Módulo 6: Herramientas y Técnicas de Git
- Usando Git Hooks
- Git Bisect
- Git Blame
- Git Log y Alias
- Submódulos de Git
- Múltiples Copias de Trabajo con git worktree
Módulo 7: Estrategias de Colaboración y Flujo de Trabajo
- Forks y Pull Requests
- Revisiones de Código con Git
- Flujo de Trabajo Git Flow
- GitHub Flow
- Trunk Based Development
- Integración Continua con Git
Módulo 8: Mejores Prácticas y Consejos de Git
- Escribiendo Buenos Mensajes de Confirmación
- Manteniendo un Historial Limpio
- Ignorando Archivos con .gitignore
- Atributos de Fichero con .gitattributes
- Mejores Prácticas de Seguridad
- Consejos de Rendimiento
Módulo 9: Solución de Problemas y Depuración
- Problemas Comunes de Git
- Deshaciendo Cambios
- Resolviendo Divergencias con el Remoto
- Recuperando Confirmaciones Perdidas
- Tratando con Repositorios Corruptos
- Técnicas Avanzadas de Depuración
