Saltar al contenido principal

SemVer: el versionado que nadie lee hasta que te rompe todo

· 10 min de lectura
Oscar Adrian Ortiz Bustos
Ingeniero en Gestión y Desarrollo de Software
Contando lecturas...

Introducción​

Seguramente ya te ha pasado. Corres un pnpm install, todo funciona bien, te vas a dormir tranquilo, y al día siguiente el build está roto sin que tú hayas tocado ni una sola línea de código. Vas a investigar y resulta que una dependencia se actualizó sola de la 2.4.1 a la 3.0.0 y decidió, sin avisarte con la debida cortesía, cambiar por completo su API.

Ahí es cuando uno se pregunta: ¿para qué existen los números de versión si de todos modos nos rompen el proyecto? Pues resulta que sí existe una respuesta, y es que ese numerito no se escribe al azar, tiene un nombre y unas reglas bien definidas que la mayoría ignoramos hasta que nos pega en la cara. Se llama Semantic Versioning, o como le decimos de cariño, SemVer.

WelcomeBanner

¿Qué es SemVer?​

SemVer es una especificación (no una ley universal, ya llegaremos a eso) creada por Tom Preston-Werner, cofundador de GitHub, que propone una forma estandarizada de numerar versiones de software para que tanto humanos como máquinas puedan entender, con solo ver el número, qué tan grave (o no) es actualizar una dependencia.

El formato es el siguiente:

MAJOR.MINOR.PATCH

Por ejemplo 2.4.1. Cada uno de esos tres números cuenta una historia distinta:

  • MAJOR: Se incrementa cuando haces cambios incompatibles con versiones anteriores, es decir, rompes cosas a propósito (o sin querer, pero las rompes).
  • MINOR: Se incrementa cuando agregas funcionalidad nueva de manera compatible con versiones anteriores. Nada se rompe, solo se agrega.
  • PATCH: Se incrementa cuando arreglas bugs de manera compatible con versiones anteriores. No agregas nada nuevo, solo corriges lo que ya estaba mal.
Regla de oro

Una vez que un paquete se publica, su contenido no debe cambiar. Si necesitas modificar algo, se publica una nueva versión. Así de simple.

Un ejemplo claro​

Supongamos que estás desarrollando una librería llamada neander-utils y arrancas en la versión 1.0.0. A partir de ahí:

Cambio realizadoNueva versiónPor qué
Arreglas un bug en una función que ya existía1.0.1Es un PATCH, corriges algo sin romper nada
Agregas una nueva función formatDate()1.1.0Es un MINOR, sumas funcionalidad sin romper lo anterior
Renombras formatDate() a formatearFecha()2.0.0Es un MAJOR, cualquiera que use formatDate() se le rompe el código

Fíjate que el orden importa. Un MAJOR resetea el MINOR y el PATCH a cero, y un MINOR resetea el PATCH a cero. Es decir, de 1.7.3 con un cambio MAJOR pasas directo a 2.0.0, no a 2.7.3.

¿Y los rangos en package.json?​

Aquí es donde la mayoría se pierde. Si has abierto un package.json seguramente has visto cosas como:

{
"dependencies": {
"express": "^4.18.2",
"lodash": "~4.17.21",
"react": "18.2.0"
}
}

Esos símbolos antes del número no son decoración, son operadores que le dicen al gestor de paquetes (npm, yarn, pnpm, el que uses) qué tan permisivo puede ser al actualizar:

Qué permite actualizar cada operador

^ (caret)

  • Permite MINOR y PATCH
  • ^4.18.2 acepta 4.x.x
  • Nunca acepta 5.0.0

~ (tilde)

  • Permite solo PATCH
  • ~4.17.21 acepta 4.17.x
  • No acepta 4.18.0

Sin símbolo

  • Versión exacta
  • Ni un número se mueve
  • Requiere update manual
Advertencia

El caret (^) se comporta distinto cuando el MAJOR es 0. Con 0.x.y se considera que el paquete todavía está en desarrollo inicial, así que ^0.4.2 solo permite actualizar el PATCH, tratando al MINOR como si fuera el MAJOR. Esto confunde a muchísima gente (a mí también me confundió la primera vez).

0.y.z: zona de guerra​

La especificación de SemVer dice algo que casi nadie lee: mientras tu paquete esté en 0.y.z, se considera desarrollo inicial y todo puede cambiar en cualquier momento, incluso entre versiones MINOR. Es básicamente una zona sin reglas donde el autor te está avisando "usa esto bajo tu propio riesgo, todavía no prometo estabilidad".

Por eso, cuando ves que una librería lleva años estancada en 0.x.x, es una señal (a veces buena, a veces mala) de que el mantenedor considera que el proyecto aún no está listo para comprometerse con una API estable.

¿Por qué no es ley universal?​

Aquí viene la parte incómoda. SemVer es una convención, no una obligación técnica. npm no te va a impedir publicar un cambio que rompe todo bajo un PATCH, es tu responsabilidad como mantenedor respetar las reglas. Y lamentablemente no todo el ecosistema lo hace de forma perfecta.

Hay proyectos que:

  • Publican breaking changes en versiones MINOR porque "total, es una función poco usada".
  • Cambian comportamientos internos "no documentados" sin subir el MAJOR, porque técnicamente no rompieron la API pública.
  • Simplemente no siguen SemVer y usan su propio esquema (miradas a Electron y su versionado ligado a Chromium, por ejemplo).

Es por esto que confiar ciegamente en ^ en producción, sobre todo en proyectos grandes, puede ser jugar a la ruleta rusa. Por eso existen los lockfiles (package-lock.json, yarn.lock, pnpm-lock.yaml), que fijan exactamente qué versión se instaló la última vez, para que tu build sea reproducible sin importar qué tan permisivos sean tus rangos en el package.json.

Consejo

En proyectos que van a producción, considera usar ~ en vez de ^ para dependencias críticas, o directamente fijar la versión exacta y actualizar de forma manual y consciente, revisando el changelog antes de subir el número.

Prerelease y metadata de build​

SemVer también contempla versiones de prueba y metadata adicional, algo que muchos ven pero pocos entienden:

1.0.0-alpha
1.0.0-alpha.1
1.0.0-beta
1.0.0-rc.1
1.0.0+20130313144700
  • Todo lo que va después de un guión (-alpha, -beta.2, -rc.1) es una versión de prueba, se considera menos estable que la versión normal correspondiente. 1.0.0-alpha es menor que 1.0.0.
  • Todo lo que va después de un + es metadata de build, no afecta la precedencia de versiones, es solo información extra (como el hash de un commit o la fecha del build).

Cómo lo manejo en NeoComposer​

Toda esta teoría suena bien hasta que alguien tiene que decidir, a mano, si el cambio que acaba de hacer es un MAJOR, un MINOR o un PATCH. Y ahí es donde la mayoría de los proyectos fallan: el versionado termina siendo una decisión subjetiva de último momento, justo antes de hacer el release, cuando ya nadie se acuerda bien de todo lo que cambió.

En NeoComposer resolví esto quitándome a mí mismo la decisión de encima. El repositorio usa Conventional Commits como requisito, no como sugerencia, y git-cliff se encarga de traducir ese historial directamente en el siguiente número de versión. Si mis commits están bien escritos, SemVer se calcula solo.

El cliff.toml del proyecto define exactamente qué tipo de commit mapea a qué grupo del changelog:

[git]
conventional_commits = true
filter_unconventional = true

commit_parsers = [
{ message = "^feat", group = "Features" },
{ message = "^fix", group = "Bug Fixes" },
{ message = "^perf", group = "Performance" },
{ message = "^refactor", group = "Refactor" },
{ message = "^docs", group = "Documentation" },
{ message = "^chore\\(release\\):", skip = true },
]

Con eso, git-cliff --bumped-version puede leer todos los commits desde el último tag y calcular la siguiente versión sin que yo tenga que pensarlo: un feat sube el MINOR, un fix sube el PATCH, y un BREAKING CHANGE en el footer del commit sube el MAJOR. Es la especificación de Conventional Commits mapeada 1:1 a las reglas de SemVer que ya vimos arriba.

Y todo esto corre solo, vía GitHub Actions. Tengo dos workflows separados para las dos ramas del repo:

  • changelog.yml, en cada push a develop, regenera CHANGELOG.md y docs/changelog.json con lo "Unreleased" hasta ese momento, sin crear tag ni versión todavía.
  • release.yml, en cada push a main, corre git-cliff --bumped-version para calcular la siguiente versión, y si es distinta a la del último tag, genera el changelog final, comitea chore(release): vX.Y.Z, crea el tag, publica el GitHub Release con las notas generadas por git-cliff, y sincroniza develop con main por fast-forward.
- name: Install git-cliff
uses: orhun/git-cliff-action@v4
id: bumped
with:
config: cliff.toml
args: --bumped-version

- name: Commit, tag and push
if: steps.compute.outputs.released == 'true'
run: |
git commit -m "chore(release): ${{ steps.compute.outputs.version }}"
git tag -a "${{ steps.compute.outputs.version }}" -m "Release ${{ steps.compute.outputs.version }}"
git push origin HEAD:main
git push origin "${{ steps.compute.outputs.version }}"

De un commit a un release publicado

1

Push a main con Conventional Commits

feat, fix, refactor, etc.

2

git-cliff --bumped-version

Calcula MAJOR/MINOR/PATCH según los commits desde el último tag

3

Genera CHANGELOG.md

Agrupado por tipo de commit

4

Commit chore(release): vX.Y.Z

Y crea el tag

5

Publica GitHub Release

Con las notas generadas por git-cliff

6

Sincroniza develop con main

Fast-forward

Yo únicamente mergeo a main con commits bien escritos, el resto (calcular versión, escribir changelog, taguear, publicar release) lo hace el Action. También dejo un release.sh local por si algún día necesito forzar una versión específica o cortar un release manualmente sin depender de CI, pero en el día a día ni lo toco. Nada de esto depende de que yo recuerde qué cambié desde el último release, todo sale del historial de commits.

Por qué importa

Sin Conventional Commits, git-cliff no tiene forma de saber si un commit es un feat o un fix, y sin esa clasificación, no hay forma automática de calcular si el siguiente bump es MAJOR, MINOR o PATCH. Los commits convencionales no son solo estética para el changelog, son el input que hace posible automatizar SemVer sin adivinar.

Si quieres profundizar en cómo estructurar tus propios commits para que algo como esto funcione en tu proyecto, ya escribí sobre eso a fondo en Domina tus commits: metodología Conventional Commits.

Conclusión​

SemVer no te va a salvar de todos los dolores de cabeza al momento de actualizar dependencias, pero sí te da un lenguaje común para poder tomar decisiones informadas antes de correr un npm update a lo loco. Entender qué significa cada número te permite saber cuándo puedes actualizar con confianza y cuándo es mejor leer el changelog dos veces antes de tocar algo.

La próxima vez que veas un ^, un ~ o un simple número fijo en tu package.json, ya vas a saber exactamente qué tanto riesgo estás aceptando. Y si algún día publicas tu propia librería, ya sabes, respeta las reglas, que el ecosistema entero confía (aunque sea un poquito) en que lo vas a hacer bien.

Si quieres leer la especificación completa y oficial, la puedes encontrar en semver.org.

"Given a version number MAJOR.MINOR.PATCH, increment the MAJOR version when you make incompatible API changes."

— Tom Preston-Werner
Escrito por un humano