107 commits. Conventional commits impecables desde el primer día. Feat, fix, refactor, chore — todo perfectamente etiquetado. ¿Y el CHANGELOG? Vacío. Inexistente. Un fichero que «ya escribiré mañana» durante dos meses.
Si esto te suena, no estás solo. Escribir un changelog a mano es un coñazo de categoría olímpica. No es que sea difícil — es que es tedioso, repetitivo, y siempre hay algo más urgente que hacer. Y justo por eso existe git-cliff.
Qué es git-cliff (en 30 segundos)
Es un generador de changelogs escrito en Rust que lee tus commits de git, los parsea según conventional commits, y escupe un CHANGELOG.md agrupado por versión y tipo. Sin dependencias raras, sin plugins, sin magia negra. Un binario, un fichero de configuración, y listo.
Dicho en cristiano: le das tus commits y te devuelve el fichero que llevas meses posponiendo.
brew install git-cliff
git cliff --output CHANGELOG.md
Esas dos líneas son literalmente todo lo que necesitas para empezar. Si tus commits siguen la convención tipo: descripción, git-cliff los entiende sin configuración adicional.
El caso real: changelog retroactivo para 8 versiones
En Tokamak (mi app de menu bar para monitorizar la cuota de Claude) tenía exactamente ese problema: 107 commits perfectos y un CHANGELOG en blanco. La app ya iba por la v1.3.0 pero solo existía un tag v0.1 del principio de los tiempos.
El plan era sencillo:
Paso 1: Crear tags retroactivos en los commits de cada versión.
git tag v0.2.0 32950f4 # Dashboard, biblioteca, achievements v1
git tag v0.3.0 9c56985 # Rename a Tokamak, 6 idiomas
git tag v1.0.0 a283490 # App Store: sandbox, privacy manifest
git tag v1.3.0 6248bac # HEAD: multi-provider, fetch pipeline
# ... y así sucesivamente
Paso 2: Ejecutar git-cliff.
git cliff --output CHANGELOG.md
Resultado: un CHANGELOG.md completo con 8 versiones, cada una con sus features, bug fixes, refactors y chores agrupados. 189 líneas. 30 segundos.
La configuración: cliff.toml
Git-cliff usa un fichero TOML con dos secciones principales: [changelog] para el formato de salida y [git] para cómo interpretar los commits.
Esta es la configuración que uso:
[changelog]
header = """
# Changelog
All notable changes to Tokamak are documented in this file.\n
"""
body = """
{%- if version %}
## {{ version | trim_start_matches(pat="v") }} — {{ timestamp | date(format="%Y-%m-%d") }}
{% else -%}
## Unreleased
{% endif -%}
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits -%}
- {{ commit.message | split(pat="\n") | first | trim }}
{% endfor -%}
{% endfor %}
"""
trim = true
[git]
conventional_commits = true
filter_unconventional = true
commit_parsers = [
{ message = "^feat", group = "Features" },
{ message = "^fix", group = "Bug Fixes" },
{ message = "^refactor", group = "Refactor" },
{ message = "^test", group = "Tests" },
{ message = "^docs", group = "Documentation" },
{ message = "^chore", group = "Chores" },
]
tag_pattern = "v[0-9].*"
sort_commits = "oldest"
Tres cosas a notar:
-
trim_start_matches(pat="v")— Los tags sonv1.3.0pero en el changelog quiero1.3.0. Este filtro de Tera lo hace. -
filter_unconventional = true— Descarta commits que no sigan la convención. Si tienes un repo con commits antiguos tipo «fixed stuff», ponlo afalsey añade un parser catch-all:{ message = ".*", group = "Other" }. -
sort_commits = "oldest"— Los commits dentro de cada grupo van en orden cronológico. Para orden inverso:"newest".
Templates: el lenguaje que no sabías que necesitabas
El body usa Tera, un motor de plantillas inspirado en Jinja2. La curva de aprendizaje no es cero, pero tampoco es Haskell. Algunos filtros útiles:
| Filtro | Qué hace | Ejemplo |
|---|---|---|
trim_start_matches |
Quita prefijo | v1.0 → 1.0 |
upper_first |
Capitaliza | features → Features |
split + first |
Primera línea | Ignora cuerpo del commit |
date |
Formatea fecha | %Y-%m-%d |
group_by |
Agrupa | Por tipo de commit |
El truco más útil: commit.message | split(pat="\n") | first extrae solo la primera línea del mensaje de commit. Si escribes cuerpos largos en tus commits (y deberías), esto evita que el changelog se convierta en una novela.
Gotcha: el whitespace de Tera
Este es el que más tiempo me costó. Los templates de Tera son sensibles al whitespace. Un salto de línea de más o de menos en el template y tu changelog sale con huecos raros o con secciones pegadas.
Las claves:
{%-(con guión): elimina el whitespace antes del tag-%}(con guión): elimina el whitespace después del tagtrim = trueen[changelog]: elimina whitespace de cada línea generada
El problema es que trim = true también se come los saltos de línea que sí quieres — como la separación entre versiones. La solución es dejar un {% endfor %} sin guión al final del loop exterior, para que Tera emita el salto extra.
No es exactamente intuitivo. Pero una vez que funciona, funciona.
Casos de uso que no son obvios
Git-cliff no es solo «genera un CHANGELOG y ya». Hay usos que merecen la pena conocer:
Generar solo la última versión
git cliff --latest
Perfecto para release notes. En vez del changelog completo, solo te da la sección de la versión más reciente. Ideal para pegarlo en la descripción de un release de GitHub o Gitea.
Preview sin tag
git cliff --tag v2.0.0 --unreleased
Genera la sección de una versión que aún no has taggeado. Útil para revisar qué va a incluir tu próximo release antes de crearlo.
Bump automático
git cliff --bumped-version
Git-cliff analiza los commits desde el último tag y te dice qué versión debería ser la siguiente según semver: si solo hay fix, sube el patch; si hay feat, sube el minor; si hay breaking changes, sube el major.
Changelog por rango
git cliff v1.0.0..v1.2.0
Solo los commits entre dos tags. Útil para generar las notas de un hotfix o para auditar qué cambió entre dos versiones específicas.
Monorepos
Si tu repo tiene varios proyectos, git-cliff soporta --include-path para filtrar commits que afectan a una ruta concreta:
git cliff --include-path "api/**"
Cada subproyecto puede tener su propio cliff.toml y su propio changelog.
Integrarlo en tu flujo (sin que se te olvide)
El mayor riesgo de git-cliff no es técnico — es humano. Te vas a olvidar de ejecutarlo. Lo sé porque me ha pasado.
La solución más pragmática: integrarlo como dependencia del paso que sí te vas a acordar de hacer. En mi caso, el Makefile:
changelog: ## Generar CHANGELOG.md desde commits
git cliff --output CHANGELOG.md
archive: generate changelog ## Crear xcarchive (Release)
xcodebuild archive ...
Ahora make archive regenera el changelog automáticamente antes de compilar para release. No tengo que acordarme de nada — cuando voy a publicar, el changelog se actualiza solo.
Otras opciones razonables:
- GitHub Actions:
git cliff --latesten el workflow de release para generar las notas automáticamente. - Pre-push hook: regenerar antes de cada push. Más agresivo, más ruidoso.
- Post-tag hook: regenerar al crear un tag. El más lógico semánticamente, pero git no tiene un hook nativo para tags (necesitarías un wrapper).
Mi recomendación: ata el changelog al paso de release. Es donde realmente importa. Un changelog desactualizado en main entre releases no le importa a nadie.
Conventional commits: el requisito previo
Todo esto asume que tus commits siguen la convención tipo(scope): descripción:
feat: add login with OAuth
fix: prevent crash on empty response
refactor: extract auth logic to separate module
chore: update dependencies
docs: add API reference
test: add edge cases for parser
Si tus commits son del tipo «wip», «stuff», «asdf» y «fix things maybe», git-cliff no va a hacer milagros. Basura entra, basura sale.
Pero la buena noticia es que no necesitas herramientas para hacer conventional commits. Es solo una convención de naming. Eso sí, si quieres validación, commitlint o un pre-commit hook sencillo te aseguran que nadie (incluido tú a las 3AM) se salte el formato.
Por qué git-cliff y no otra cosa
Hay alternativas: standard-version, semantic-release, conventional-changelog, release-please. Todas funcionan. Pero git-cliff tiene tres ventajas que para mí son decisivas:
-
Es un binario.
brew instally funciona. Sin Node, sin Python, sin ecosistema. En un proyecto Swift como el mío, no quiero añadirpackage.jsonsolo por el changelog. -
Es rápido. 120 milisegundos para 10.000 commits (según benchmarks reales). Las alternativas basadas en Node tardan 30 segundos para el mismo repo.
-
El template es tuyo. Si quieres emojis, los pones. Si quieres links a issues, los pones. Si quieres el changelog en YAML, puedes. El motor Tera te da control total sobre el output.
El resultado
De 0 a changelog completo en menos de 5 minutos:
## 1.3.0 — 2026-02-22
### Features
- QuotaFetchStrategy pipeline with fallback for resilient quota fetching
- semi-automatic screenshot capture for App Store (8 locales × 3 scenarios)
### Bug Fixes
- attack phrase wrapping in exhausted view (TOK-115)
- resolve all notarization signing issues (TOK-93/94/95/96)
### Refactor
- QuotaProvider protocol for multi-provider support (TOK-53)
## 1.2.0 — 2026-02-22
...
8 versiones, 107 commits, agrupados por tipo, con fechas y referencias a issues. Sin escribir ni una línea a mano.
La próxima vez que alguien te pregunte «¿qué cambió en la última versión?», no tendrás que rebuscar entre commits ni improvisar de memoria. Tendrás un fichero. Generado automáticamente. Que se actualiza solo cuando publicas.
Y si algún día cambias de opinión sobre el formato, cambias el template. Los commits — tus commits de siempre — son la fuente de verdad. git-cliff solo los presenta bonitos.
Read this article in English.



