Módulo 4: Deployment Automation

Generación de Changelog Basada en Diffs

Generación de Changelog Basada en Diffs

Descripción

El changelog es el documento que comunica al equipo y a los stakeholders qué cambió entre versiones. Hecho a mano, es tedioso y propenso a inconsistencias entre releases. Hecho mal (basado solo en commit messages), es vago y omite contexto importante. Esta cápsula te enseña a generar changelogs profesionales y precisos automáticamente, analizando los diffs reales del código — no solo los commit messages.

Al terminar, vas a tener un workflow que se dispara con cada release tag, analiza los cambios reales, categoriza por tipo (features, fixes, breaking changes), y produce un changelog estructurado listo para publicar — con detección automática de breaking changes que normalmente se omiten.


Por Qué los Commit Messages No Alcanzan

Imagina un commit con mensaje "fix typo in payment.py". Suena trivial. Pero el "typo" era cambiar if amount < threshold por if amount > threshold — invierte la lógica. Es un cambio funcional crítico, pero un changelog basado en commit messages diría:

## Bug fixes
- fix typo in payment.py

Inútil. El equipo no sabe qué pasó. Stakeholders no entienden el riesgo.

Generación basada en diffs lee el cambio real:

## Bug fixes
- **payment.py**: Corregida lógica de validación de monto. La condición
  `if amount < threshold` se cambió a `if amount > threshold`. Este cambio
  invierte el comportamiento previo — revisar tests de regresión antes
  del deploy.

La diferencia es información accionable vs ruido.


Anatomía de un Changelog Profesional

# Changelog

## [2.5.0] - 2026-05-03

### ⚠️ Breaking Changes
- **API**: El endpoint `/api/users/<id>` ahora requiere autenticación
  (antes era público). Clientes deben actualizar.
- **Database**: La columna `users.legacy_email` se eliminó. Asegúrate
  de haber migrado a `users.email` antes del deploy.

### 🚀 Features
- **Pagos**: Soporte para Stripe Connect, permite pagos a múltiples cuentas
- **Notifications**: Nuevo canal de SMS además de email

### 🐛 Bug Fixes
- **payment.py**: Corregida lógica de validación de monto que rechazaba
  pagos válidos en montos altos
- **OrderService**: Manejo correcto de race condition en concurrencia

### 🔧 Internal / Refactoring
- Migración a SQLAlchemy 2.0 completa
- Tests de integración separados de unit tests

### 📚 Documentation
- README actualizado con instrucciones de Docker
- Nuevo doc de arquitectura

### 🔒 Security
- Actualización de dependencias con CVEs (requests, urllib3)

Las 6 categorías estándar (basadas en Keep a Changelog + extensiones):

CategoríaCuándo
⚠️ Breaking ChangesCambios que rompen compatibilidad
🚀 FeaturesFuncionalidad nueva
🐛 Bug FixesCorrección de bugs
🔧 InternalRefactoring, cleanup, mejoras técnicas sin cambio funcional
📚 DocumentationSolo docs
🔒 SecurityPatches de seguridad

El Workflow: Trigger por Tag

# .github/workflows/release-changelog.yml
name: Generate Release Changelog

on:
  push:
    tags:
      - 'v*'  # se dispara con tags como v2.5.0, v3.0.0-beta

permissions:
  contents: write
  pull-requests: write

jobs:
  generate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # historial completo para comparar tags
      
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
          cache: 'pip'
      
      - run: pip install anthropic requests
      
      - name: Generate changelog
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITHUB_REPOSITORY: ${{ github.repository }}
          NEW_TAG: ${{ github.ref_name }}
        run: python scripts/generate_changelog.py
      
      - name: Upload changelog as artifact
        uses: actions/upload-artifact@v4
        with:
          name: changelog
          path: CHANGELOG_${{ github.ref_name }}.md

El Script: Generación con Diffs Reales

"""scripts/generate_changelog.py

Genera changelog analizando diffs reales entre el tag previo y el nuevo.
"""
import json
import os
import re
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from anthropic import Anthropic
import requests


@dataclass
class CommitInfo:
    sha: str
    message: str
    author: str
    pr_number: int | None
    files_changed: list[str]
    diff_summary: str  # truncated


def get_previous_tag(new_tag: str) -> str:
    """Encontrar el tag anterior al nuevo."""
    result = subprocess.run(
        ["git", "tag", "--sort=-creatordate"],
        capture_output=True, text=True, check=True,
    )
    tags = result.stdout.strip().split("\n")
    
    # El nuevo tag debería estar primero o cerca
    if new_tag in tags:
        idx = tags.index(new_tag)
        if idx + 1 < len(tags):
            return tags[idx + 1]
    
    # Fallback: primer commit
    first_commit = subprocess.run(
        ["git", "rev-list", "--max-parents=0", "HEAD"],
        capture_output=True, text=True, check=True,
    )
    return first_commit.stdout.strip().split("\n")[0]


def get_commits_between(old_ref: str, new_ref: str) -> list[CommitInfo]:
    """Listar commits entre dos refs con info útil."""
    result = subprocess.run(
        ["git", "log", f"{old_ref}..{new_ref}",
         "--format=%H||%s||%an"],
        capture_output=True, text=True, check=True,
    )
    
    commits = []
    for line in result.stdout.strip().split("\n"):
        if not line:
            continue
        parts = line.split("||")
        if len(parts) < 3:
            continue
        sha, message, author = parts[0], parts[1], parts[2]
        
        # Extraer PR number del mensaje (formato típico: "feat: X (#123)")
        pr_match = re.search(r"\(#(\d+)\)$", message)
        pr_number = int(pr_match.group(1)) if pr_match else None
        
        # Archivos modificados
        files_result = subprocess.run(
            ["git", "show", "--name-only", "--format=", sha],
            capture_output=True, text=True, check=True,
        )
        files = [f for f in files_result.stdout.strip().split("\n") if f]
        
        # Diff summary (truncado para no saturar)
        diff_result = subprocess.run(
            ["git", "show", "--stat", "--format=", sha],
            capture_output=True, text=True, check=True,
        )
        
        commits.append(CommitInfo(
            sha=sha[:8],
            message=message,
            author=author,
            pr_number=pr_number,
            files_changed=files[:10],  # max 10 archivos en summary
            diff_summary=diff_result.stdout[:500],  # max 500 chars
        ))
    
    return commits


def fetch_pr_descriptions(pr_numbers: list[int], repo: str, token: str) -> dict[int, str]:
    """Traer descripciones de PRs para contexto adicional."""
    descriptions = {}
    headers = {
        "Authorization": f"Bearer {token}",
        "Accept": "application/vnd.github+json",
    }
    
    for pr in pr_numbers:
        try:
            url = f"https://api.github.com/repos/{repo}/pulls/{pr}"
            r = requests.get(url, headers=headers, timeout=10)
            if r.status_code == 200:
                data = r.json()
                descriptions[pr] = data.get("body", "") or ""
        except Exception:
            pass  # tolerar fallos individuales
    
    return descriptions


def categorize_with_claude(
    commits: list[CommitInfo],
    pr_descriptions: dict[int, str],
    new_tag: str,
) -> str:
    """Llamar a Claude para generar el changelog estructurado."""
    
    # Construir el contexto a pasar al modelo
    commits_summary = []
    for c in commits:
        commits_summary.append(f"""
## Commit {c.sha} (PR #{c.pr_number or 'N/A'})
**Author:** {c.author}
**Message:** {c.message}
**Files:** {', '.join(c.files_changed[:5])}
**Stats:**
{c.diff_summary}
{f'**PR description:** {pr_descriptions.get(c.pr_number, '')[:300]}' if c.pr_number else ''}
""")
    
    commits_text = "\n---\n".join(commits_summary)
    
    prompt = f"""Genera un changelog profesional para la versión {new_tag}.

Tienes esta información de commits y PRs:

{commits_text}

INSTRUCCIONES:
1. Categoriza cada commit en: Breaking Changes, Features, Bug Fixes, Internal, Documentation, Security
2. **Breaking changes son críticos** — si detectas cambios que rompen compatibilidad (eliminar APIs, cambiar firmas, modificar schemas), márcalo aunque el commit message no lo diga
3. Escribe entradas **descriptivas** (no solo el commit message). Explica qué cambió y por qué importa
4. Si un commit es trivial (typo en doc, formatting), inclúyelo en Internal
5. Agrupa commits relacionados en una entrada cuando aplique
6. Output: SOLO el markdown del changelog, con esta estructura:

```markdown
## [{new_tag}] - YYYY-MM-DD

### ⚠️ Breaking Changes
[items o "Ninguno" si no hay]

### 🚀 Features
[items o omitir sección si no hay]

### 🐛 Bug Fixes
[items]

### 🔧 Internal
[items]

### 📚 Documentation
[items o omitir]

### 🔒 Security
[items o omitir]

Devuelve SOLO el markdown del changelog, sin explicación previa. """

client = Anthropic()
response = client.messages.create(
    model="claude-sonnet-5",  # Sonnet para más calidad en categorización
    max_tokens=4000,
    messages=[{"role": "user", "content": prompt}],
)

text = response.content[0].text.strip()
# Limpiar code fences si los hay
if text.startswith("```"):
    text = "\n".join(text.split("\n")[1:-1])

return text

def main() -> int: new_tag = os.environ["NEW_TAG"] repo = os.environ["GITHUB_REPOSITORY"] token = os.environ["GITHUB_TOKEN"]

print(f"Generando changelog para {new_tag}...")

# 1. Encontrar tag previo
prev_tag = get_previous_tag(new_tag)
print(f"Tag anterior: {prev_tag}")

# 2. Listar commits entre tags
commits = get_commits_between(prev_tag, new_tag)
print(f"Commits a incluir: {len(commits)}")

if not commits:
    print("No hay commits nuevos. Skipping.")
    return 0

# 3. Fetch PR descriptions para contexto
pr_numbers = [c.pr_number for c in commits if c.pr_number]
pr_descriptions = fetch_pr_descriptions(pr_numbers, repo, token)

# 4. Generar changelog con Claude
changelog = categorize_with_claude(commits, pr_descriptions, new_tag)

# 5. Guardar
output_file = Path(f"CHANGELOG_{new_tag}.md")
output_file.write_text(changelog)
print(f"Changelog generado: {output_file}")
print(f"\n--- Preview ---\n{changelog[:1000]}\n...")

return 0

if name == "main": sys.exit(main())


---

## Detección de Breaking Changes

La parte más valiosa del changelog automático es **detectar breaking changes** que el commit message no menciona explícitamente. Patrones que Claude Code puede identificar:

### Patrones de breaking change

| Patrón | Ejemplo |
|--------|---------|
| **Eliminación de funciones/endpoints públicos** | `def get_user(id)` se elimina |
| **Cambio de firma** | `def create_user(name, email)` → `def create_user(name, email, role)` (parámetro requerido nuevo) |
| **Cambio en estructura de respuestas** | Un endpoint que retornaba `{user: {...}}` ahora retorna `{...}` directo |
| **Cambio en nombres de columnas/schemas** | `users.legacy_email` se renombra o elimina |
| **Cambio en valores de configuración** | Variable de env requerida cambia de nombre |
| **Cambio en formato de outputs** | CLI que imprimía JSON ahora imprime texto |
| **Cambio en comportamiento por default** | Un parámetro opcional cambia su valor default |

### Prompt enfatizando detección

Mejor versión del prompt para detección:

```python
prompt += """
DETECCIÓN DE BREAKING CHANGES (crítico):
Aunque el commit message no lo diga, marca como breaking change si:
- Un identificador público (función, clase, método, endpoint) fue eliminado o renombrado
- La firma de una función pública cambió (parámetro nuevo requerido, tipos cambiados)
- Una estructura de respuesta API cambió (campos eliminados, renombrados, tipo cambiado)
- Schemas de DB cambiaron (columnas eliminadas, renombradas)
- Variables de environment requeridas cambiaron de nombre
- Comportamientos default cambiaron

Si tienes DUDA, INCLÚYELO como breaking change. Mejor over-flagger que under-flagger en este caso.
"""

Integrar el Changelog al Repo

Después de generar, quieres que viva en el repo (no solo como artifact):

- name: Update CHANGELOG.md in repo
  run: |
    # Agregar el nuevo changelog al inicio de CHANGELOG.md
    if [ -f CHANGELOG.md ]; then
      mv CHANGELOG.md CHANGELOG.md.bak
      cat CHANGELOG_${{ github.ref_name }}.md > CHANGELOG.md
      echo "" >> CHANGELOG.md
      cat CHANGELOG.md.bak >> CHANGELOG.md
      rm CHANGELOG.md.bak
    else
      echo "# Changelog" > CHANGELOG.md
      echo "" >> CHANGELOG.md
      cat CHANGELOG_${{ github.ref_name }}.md >> CHANGELOG.md
    fi
    
    git config user.name "github-actions[bot]"
    git config user.email "github-actions[bot]@users.noreply.github.com"
    git add CHANGELOG.md
    git commit -m "chore: update changelog for ${{ github.ref_name }}"
    git push origin HEAD:main

O alternativa: crear un PR con el changelog para review humano antes de merge.


Publicar como GitHub Release

- name: Create GitHub Release
  uses: actions/create-release@v1
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  with:
    tag_name: ${{ github.ref_name }}
    release_name: Release ${{ github.ref_name }}
    body_path: CHANGELOG_${{ github.ref_name }}.md
    draft: false
    prerelease: ${{ contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') }}

Ahora cada tag genera automáticamente:

  • Changelog en el archivo CHANGELOG.md del repo
  • GitHub Release con ese mismo changelog como descripción

Trampas Comunes

Error 1: Generar changelog pero no detectar breaking changes

Síntoma: El changelog tiene Features y Bug Fixes pero falta la sección de Breaking Changes — y los hay.

Por qué pasa: Prompt genérico que no enfatiza la detección.

Cómo corregir: Énfasis explícito en el prompt sobre patrones de breaking change. "Mejor over-flagger que under-flagger" es la guía.

Error 2: Pasar todos los diffs completos al modelo

Síntoma: Costo alto, puede saturar context window en releases grandes.

Por qué pasa: No truncar el diff por commit.

Cómo corregir: Usar git show --stat (que da resumen, no diff completo). Pasar al modelo el resumen de cambios + commit message + PR description, no el diff completo.

Error 3: Commits triviales se incluyen como Features

Síntoma: El changelog incluye "Updated dependencies" o "Fixed typo" en Features.

Por qué pasa: El modelo no distingue entre cambios significativos y triviales.

Cómo corregir: Pedirle explícitamente que commits triviales (typos, dependency updates rutinarios, formatting) vayan a "Internal".

Error 4: PR descriptions no se aprovechan

Síntoma: El changelog reproduce los commit messages literales sin contexto.

Por qué pasa: No estás trayendo descripciones de PRs.

Cómo corregir: Como muestra el script, fetch las PR descriptions vía API y pasalas al modelo. Ahí está el contexto humano que el commit message no tiene.

Error 5: No versionar tags con semver consistente

Síntoma: El script falla porque "no existe el tag previo".

Por qué pasa: Tags inconsistentes (release-1, 2.5.0, v3-beta).

Cómo corregir: Adoptar convención semver con prefijo v: v1.0.0, v1.1.0, v2.0.0-beta.1. Ajustar el filtro tags: ['v*'] en el workflow.


Diagnóstico

Pregunta 1: ¿Tu changelog actual incluye Breaking Changes claramente identificados?

Si no, los stakeholders no saben qué requiere atención. La sección de Breaking Changes es la más importante.

Pregunta 2: ¿Generas el changelog basado en commit messages o en diffs reales?

Solo commit messages = limitado. Diffs reales (con PR descriptions) = preciso.

Pregunta 3: ¿El changelog está versionado en el repo o solo se publica en releases?

Versionarlo en CHANGELOG.md da historial accesible y es estándar de la industria (Keep a Changelog).

Pregunta 4: ¿Tu workflow se dispara con tags o con merge a main?

Con tags (semver) es el momento natural — define un "release". Con cada merge sería overkill.

Pregunta 5: ¿Cómo manejas pre-releases (beta, alpha, rc)?

Si el tag tiene -beta o similar, marca prerelease: true en GitHub Release. Cambia visibilidad y semánticamente.


Ejercicios

Ejercicio 1: Workflow básico (Fácil)

Configura un workflow que se dispare con tags v*. Hace actions/checkout con fetch-depth: 0 y prints git log --oneline $(git describe --abbrev=0 HEAD~1)..HEAD para ver los commits incluidos.

Ejercicio 2: Generar changelog con Claude (Medio)

Implementa la pipeline completa:

  1. Extraer commits entre tag previo y nuevo
  2. Pasar al modelo con prompt estructurado
  3. Guardar como artifact
  4. Verificar el output con un release de prueba
Ver checklist
  • Workflow se dispara solo con tags
  • Encuentra correctamente el tag anterior
  • Lista commits entre los dos tags
  • Genera changelog con las 6 categorías
  • Detecta breaking changes (probarlo con un commit que rompa compatibilidad)

Ejercicio 3: Auto-PR con changelog para review (Difícil)

Modifica el workflow para que:

  1. En lugar de pushear CHANGELOG.md directamente a main
  2. Cree una rama nueva con el cambio
  3. Abra un PR con el changelog para review humano
  4. Asigne el PR al manager o tech lead

Ventaja: humano revisa el changelog antes de que se publique como release.


Resumen

  • Diffs reales > commit messages para changelogs precisos
  • 6 categorías estándar: Breaking, Features, Bug Fixes, Internal, Documentation, Security
  • Detección de breaking changes es la parte más valiosa del cambio automático
  • Trigger por tag (v*) es el momento natural — define un release
  • PR descriptions dan contexto humano que los commit messages no tienen
  • Truncar diffs evita costo alto y context overflow
  • Auto-PR para review es opción más conservadora que push directo

Próxima cápsula: 03 — Deployment readiness validation. Ya tienes el changelog. Antes del deploy, hay que validar que el sistema está listo: tests pasan, migrations preparadas, env vars configuradas. Aprendes a automatizar ese checklist con Claude Code.


Recursos Adicionales

  1. Keep a Changelog — El estándar de formato
  2. Semantic Versioning — Convención de versionado
  3. Conventional Commits — Convención de commits que ayuda a categorizar
  4. GitHub Releases API — Endpoint para crear releases
  5. git log advanced — Filtros y formatos útiles
  6. Anthropic batch API — Para repos enormes con muchos commits