SemVer en la práctica
Cuándo hacer bump major, minor o patch — y por qué SemVer es la herramienta equivocada para servicios. Con un formato de changelog que la gente sí lee.
Un equipo en un monorepo debate sobre hacer un bump de versión major. El cambio de API en cuestión es en una librería interna — usada por exactamente dos servicios, ambos del mismo equipo, ambos desplegados simultáneamente. El "consumidor" del cambio breaking son ellos mismos, esta tarde.
La mitad del equipo dice "es un cambio breaking, eso es major, esa es la regla". La otra mitad dice "controlamos ambos lados, ¿por qué gastar una versión major en algo que nadie más va a saber?"
Ambas mitades tienen razón. Simplemente dejaron de ponerse de acuerdo en para qué sirve SemVer.
Lo que SemVer dice en realidad
SemVer es un protocolo de comunicación. Existe para que un consumidor que no controlas pueda leer un número de versión y saber si actualizar romperá su build. La promesa:
| Bump | Significado para el consumidor |
|---|---|
| Patch | No cambié nada que puedas observar |
| Minor | Añadí algo; el código antiguo sigue funcionando |
| Major | Rompí algo; debes leer el changelog |
Esa promesa es valiosa en exactamente una situación: cuando el consumidor está lejos en el tiempo, la propiedad o la distancia organizativa. Un desconocido que instale tu librería desde npm dentro de cinco años necesita saber si actualizar romperá su build. SemVer es cómo se lo dices.
El valor de SemVer no está en el número de versión. Está en la disciplina de preguntarse, antes de cada release, "¿he roto a alguien que no puedo ver?" Si la respuesta es "no, porque no hay nadie que no pueda ver", el número de versión ya no lleva información útil.
Para librerías que publicas: tómatelo en serio
Si tu código vive en un registro público, en un CDN que alguien más fija, o dentro de un contrato del que depende un equipo externo — SemVer no es negociable y la disciplina importa más que el número.
Define "breaking" en un párrafo
No es una cuestión de gusto. Renombrar una función pública es breaking. Añadir un parámetro requerido es breaking. Cambiar un tipo de retorno es breaking. Añadir un parámetro opcional no lo es.
Ejecuta un test downstream
Mantén una pequeña app consumidora fijada a tu major actual. Ejecuta su build
contra tu main. Si se rompe, debes un bump major.
Depreca antes de eliminar
Marca el nombre antiguo como @deprecated, apunta al nuevo, despliega eso como
minor. Elimínalo en el siguiente major. Los consumidores que siguen las
deprecaciones migran a su ritmo.
// En v2.4 (minor)
/** @deprecated Usa `findById` en su lugar. Eliminado en v3.0. */
export function getById(id: string) {
return findById(id)
}
// En v3.0 (major)
// `getById` ha desaparecido.Para servicios: SemVer es la herramienta equivocada
Un servicio no se consume con líneas import. Se consume con peticiones HTTP,
llamadas gRPC o colas de mensajes. El consumidor es otro proceso en ejecución
— actualizado con un calendario diferente, posiblemente con varias versiones
funcionando a la vez.
Un servicio no necesita un número de versión. Necesita tres cosas:
Un contrato de API vigente
Schema, OpenAPI, protobuf — registrado en el repo y verificado en CI.
Una ventana de deprecación progresiva
Cuando el contrato cambia, la forma antigua sigue funcionando durante un periodo publicado (90 días, dos ciclos de release, lo que encaje). Ambas formas son válidas durante la ventana.
Tests de contrato en la frontera
Cada consumidor ejecuta un test que verifica que puede parsear lo que devuelve el servicio. El servicio ejecuta un test que verifica que produce lo que espera cada consumidor.
Si debes poner un número a un servicio, el número que importa es la versión de API — habitualmente en la ruta URL o una cabecera — y solo cambia cuando rompes el contrato.
GET /v1/users/123 # actual
GET /v2/users/123 # nueva forma — ambas activas durante la ventana de deprecaciónLa versión del software desplegado (my-service:1.42.7) es contabilidad para
los operadores, no un contrato con los consumidores.
El término medio: libs internas que "parecen publicadas"
Volvamos al monorepo. Las librerías internas usadas entre fronteras de equipo — un paquete de tipos compartidos, un helper de auth, un wrapper de logging — están en el incómodo término medio. No están publicadas, pero los consumidores sí tienen calendarios de actualización distintos y puede que no estén en la sala cuando despliegas.
Lo correcto es tratarlas como librerías públicas, con un atajo: puedes
acortar la ventana de deprecación porque puedes hacer grep de cada caller.
Una deprecación de dos semanas en una librería pública sería hostil; una
deprecación de dos semanas en tu monorepo donde abriste un PR añadiendo la
etiqueta @deprecated a cada caller es razonable.
El atajo que no tienes: saltarte el bump major porque "controlamos ambos lados". Si los call sites pertenecen a otros equipos, no controlas ambos lados — controlas el código y ellos controlan el calendario.
Un changelog que la gente lee
La mayoría de changelogs son listas autogeneradas de títulos de commits. Nadie los lee, incluida la gente que los escribió.
Un changelog que se lee está organizado por la pregunta del lector, no por tu log de commits:
## v3.0.0 — 2024-11-15
### Si llamas a `getById`
Ha desaparecido. Usa `findById`. Migración: `git grep getById` y renombra.
### Si importas desde `~/auth/legacy`
La ruta es ahora `~/auth`. El re-export antiguo fue eliminado.
### Si usas `<Form onSubmit>`
Ahora recibe `(values, helpers)` en lugar de solo `values`. Añade un segundo
parámetro.
### Si ninguno de los anteriores aplica
La actualización es segura. Lee el resto solo si quieres saber qué mejoró.
---
### Otros cambios
- Renderizado inicial más rápido (#1234)
- Mejores mensajes de error para props inválidas (#1240)El cambio está agrupado por a quién afecta. El lector escanea los encabezados y o bien se encuentra o no. Si no lo hace, ha terminado. Si lo hace, el paso de migración está justo ahí.
Una lista autogenerada de commits "feat:" y "fix:" no es un changelog. Es un log de commits con una plantilla más bonita. El autor sigue teniendo que hacer el trabajo de decir qué necesita hacer el lector.
Versionado semántico en la práctica: el framework de decisión
Dejas de discutir sobre si algo es major. Te preguntas: ¿a quién se lo estoy diciendo y qué necesitan hacer?
- Desconocidos que dependen de tu librería: SemVer con disciplina, depreca antes de eliminar.
- Otros servicios que llaman a tu servicio: versiones de contrato y ventanas de deprecación, no versiones desplegadas.
- Otros equipos en tu monorepo: como una librería pero con una ventana más corta.
- Tú mismo, esta tarde: despliégalo. El número de versión puede esperar.
La herramienta correcta para cada caso. Sin majors bumpeados que nadie notó; sin actualizaciones rotas que todos notaron.
Plantilla de entrada
Organiza cada versión por quién se ve afectado, no por tipo de commit.
v0.0.0 — AAAA-MM-DD
Si llamas a [función eliminada o renombrada]
[Acción requerida y cómo migrar. Un comando si es posible.]
Si importas desde [ruta eliminada o movida]
[Nueva ruta. El re-export antiguo fue eliminado.]
Si usas [componente o API con firma cambiada]
[Qué cambió en la firma — parámetro añadido, eliminado o reordenado.]
Si nada de lo anterior aplica
Actualización segura. Lee el resto solo si quieres saber qué mejoró.
Otros cambios
- [Descripción breve (#número)]
- [Descripción breve (#número)]
Cuándo bumear cada número
| Bump | Cuándo | Ejemplo |
|---|---|---|
major | Rompe al consumidor — eliminar, renombrar, cambiar firma | Eliminar getById |
minor | Nueva funcionalidad compatible hacia atrás | Añadir findBySlug |
patch | Corrección de bug sin cambio de API | Fix del comportamiento de timeout |
pre-release | Cambio no estable — 1.0.0-beta.1 | Feature flag activo |
Regla de deprecación
Antes de eliminar un símbolo público:
- Márcalo como
@deprecatedcon una nota de migración - Mantenlo durante al menos un minor release
- Elimínalo en el siguiente major con una entrada en el changelog