Apuntes DAM
Volver al inicio

Versionado semántico automático: de los commits a la versión y el changelog

Ejercicio de JavaScriptMedioUnos 60 minutos

Haz lo que hacen semantic-release o release-please al distribuir una aplicación: leer los commits en formato Conventional Commits, decidir si la versión sube la major, la minor o el patch según SemVer (con la regla de 0.x y las prereleases beta y rc) y generar la etiqueta y el changelog.

  • Versionado semántico (SemVer)
  • Conventional Commits
  • Prereleases (alpha, beta, rc)
  • Changelog
  • Distribución e integración continua
  • Expresiones regulares

Enunciado

Una versión MAYOR.MENOR.PARCHE no es un número cualquiera: según SemVer, sube el PARCHE si solo hay correcciones, la MENOR si hay funciones nuevas compatibles y la MAYOR si algo deja de funcionar como antes. Así, quien usa ^1.4.2 sabe que puede actualizar hasta antes de la 2.0.0 sin miedo. Las prereleases (2.0.0-beta.1, 2.0.0-rc.1) son versiones de prueba que van antes de la estable.

Si los mensajes de los commits siguen Conventional Commits (tipo(ámbito)!: descripción), la versión se puede calcular sola: feat es una función nueva, fix una corrección, ! o una línea BREAKING CHANGE: un cambio incompatible, y docs, chore o test no cambian la versión. Es lo que hacen en la integración continua herramientas como semantic-release antes de publicar.

Qué tiene que hacer el programa

  1. La entrada empieza con version X.Y.Z (puede llevar v delante y una prerelease -canal.N), sigue con una línea opcional canal NOMBRE (solo minúsculas) y después un commit por línea. Sin la primera línea: Falta la línea «version X.Y.Z»; versión mal escrita: Versión no válida: X; canal mal escrito: Canal no válido: X. Después escribe Versión actual: X (sin la v).
  2. Un commit es tipo(ámbito)!: descripción (ámbito y ! opcionales, el tipo en minúsculas, y : con espacio). Si no lo cumple: Aviso: «línea» no sigue Conventional Commits; si el tipo no es uno de los de la tabla: Aviso: tipo desconocido «t» en «línea». Una línea BREAKING CHANGE: nota (o BREAKING-CHANGE) marca como incompatible el commit anterior y guarda la nota (si no hay anterior, es una línea que no sigue el formato).
  3. El nivel es el mayor de los commits: incompatible 3, feat 2, fix y perf 1, el resto 0. Si es 0: No hay cambios que publicar y nada más. Con una versión estable: 3 sube la major, 2 la minor y 1 el patch, salvo en 0.x, donde 3 sube la minor; con canal, se añade -canal.1. Con una prerelease, la base no cambia: mismo canal, el número sube en 1; otro canal, -canal.1; sin canal, la versión estable.
  4. Escribe Siguiente versión: V (motivo), Etiqueta: vV y ## V. Motivos: major: hay cambios incompatibles, minor: hay funciones nuevas, patch: solo hay correcciones, minor: hay cambios incompatibles, pero la versión es 0.x, con ; prerelease del canal C si hay canal; y para prereleases, nueva prerelease del canal C, cambio al canal C o se publica X como estable.
  5. El changelog: ### Cambios incompatibles con los commits incompatibles, y después ### Nuevas funciones (feat), ### Correcciones (fix) y ### Rendimiento (perf) con los demás, cada sección solo si tiene alguno, en el orden de entrada y como - **ámbito:** descripción (sin ámbito, - descripción) más — nota si la tiene. Al final, si hay commits de otros tipos (no incompatibles): Otros cambios: N (tipos sin repetir, en orden alfabético, separados por coma).

Entrada

Línea 1: version X.Y.Z[-canal.N].

Opcional: canal NOMBRE.

Un commit por línea, o BREAKING CHANGE: nota tras el commit al que se refiere.

Datos de referencia

Tipos de commit
TipoPara quéNivel
featfunción nuevaminor
fixcorrección de un errorpatch
perfmejora de rendimientopatch
docs, style, refactor, testdocumentación, formato, reestructuración, pruebasninguno
build, ci, chore, revertcompilación, integración continua, mantenimiento, deshacerninguno
cualquiera con ! o BREAKING CHANGEcambio incompatiblemajor (minor en 0.x)

Ejemplos de ejecución

Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.

De la 1.4.2 a la 2.0.0

Entrada

version 1.4.2
feat(alumnos): exportar la lista a CSV
fix: la media ya no cuenta las notas vacías
feat(api)!: quitar el endpoint /v1
BREAKING CHANGE: los clientes deben usar /v2
docs: actualizar el README
chore(deps): subir vite a la 6
Merge branch 'main'

Salida por consola

Versión actual: 1.4.2
Aviso: «Merge branch 'main'» no sigue Conventional Commits
Siguiente versión: 2.0.0 (major: hay cambios incompatibles)
Etiqueta: v2.0.0
## 2.0.0
### Cambios incompatibles
- **api:** quitar el endpoint /v1 — los clientes deben usar /v2
### Nuevas funciones
- **alumnos:** exportar la lista a CSV
### Correcciones
- la media ya no cuenta las notas vacías
Otros cambios: 2 (chore, docs)

Una versión 0.x y commits mal escritos

Entrada

version 0.9.3
BREAKING CHANGE: suelta
fix(login): no perder la sesión al recargar
Feat: con mayúscula
feature: tipo inventado
fix:sin espacio
perf: cachear las consultas
refactor!: cambiar el formato de la configuración
test: más pruebas
ci: probar con Node 22

Salida por consola

Versión actual: 0.9.3
Aviso: «BREAKING CHANGE: suelta» no sigue Conventional Commits
Aviso: «Feat: con mayúscula» no sigue Conventional Commits
Aviso: tipo desconocido «feature» en «feature: tipo inventado»
Aviso: «fix:sin espacio» no sigue Conventional Commits
Siguiente versión: 0.10.0 (minor: hay cambios incompatibles, pero la versión es 0.x)
Etiqueta: v0.10.0
## 0.10.0
### Cambios incompatibles
- cambiar el formato de la configuración
### Correcciones
- **login:** no perder la sesión al recargar
### Rendimiento
- cachear las consultas
Otros cambios: 2 (ci, test)

Guía paso a paso

Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.

1. Una expresión regular para cada cosa

La versión y el commit se reconocen con expresiones regulares con grupos opcionales: (?:\(([^()\s]+)\))? es el ámbito entre paréntesis, si lo hay, y (!)? la marca de incompatible.

javascript
const RE_COMMIT = /^([a-z]+)(?:\(([^()\s]+)\))?(!)?: (\S.*)$/;
const m = RE_COMMIT.exec("feat(api)!: quitar /v1");
// m[1] = "feat", m[2] = "api", m[3] = "!", m[4] = "quitar /v1"
2. El nivel máximo

Convierte cada commit en un número (3, 2, 1 o 0) y quédate con el mayor: Math.max(0, ...niveles). El 0 inicial evita -Infinity sin commits.

3. Subir la versión

Al subir la major, la minor y el patch vuelven a 0; al subir la minor, el patch vuelve a 0: de 1.4.2 con un feat se pasa a 1.5.0, no a 1.5.2.

4. El changelog

Recorre las secciones en su orden y filtra los commits de cada tipo. Los incompatibles van solo en su sección, para que nadie se los pierda.

Resuélvelo aquí

El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 5 casos ocultos que buscan los errores típicos.

🟨JavaScriptVersionado semántico automático: de los commits a la versión y el changelogMedio

Ejemplo

Entrada (lo que se escribe por teclado)
version 1.4.2
feat(alumnos): exportar la lista a CSV
fix: la media ya no cuenta las notas vacías
feat(api)!: quitar el endpoint /v1
BREAKING CHANGE: los clientes deben usar /v2
docs: actualizar el README
chore(deps): subir vite a la 6
Merge branch 'main'
Salida esperada
Versión actual: 1.4.2
Aviso: «Merge branch 'main'» no sigue Conventional Commits
Siguiente versión: 2.0.0 (major: hay cambios incompatibles)
Etiqueta: v2.0.0
## 2.0.0
### Cambios incompatibles
- **api:** quitar el endpoint /v1 — los clientes deben usar /v2
### Nuevas funciones
- **alumnos:** exportar la lista a CSV
### Correcciones
- la media ya no cuenta las notas vacías
Otros cambios: 2 (chore, docs)
⏳
Test oculto #3
⏳
Test oculto #4
⏳
Test oculto #5
⏳
Test oculto #6
⏳
Test oculto #7
0/7 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
javascript
1const lineas = require("fs").readFileSync(0, "utf8").split("\n");
2
3const RE_VERSION = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([a-z]+)\.(\d+))?$/;
4const RE_COMMIT = /^([a-z]+)(?:\(([^()\s]+)\))?(!)?: (\S.*)$/;
5const TIPOS = ["feat", "fix", "perf", "docs", "style", "refactor", "test", "build", "ci", "chore", "revert"];
6const NIVEL = { feat: 2, fix: 1, perf: 1 };            // 3 = major, 2 = minor, 1 = patch
7const SECCIONES = [["feat", "Nuevas funciones"], ["fix", "Correcciones"], ["perf", "Rendimiento"]];
8
9/** Lee los commits; un «BREAKING CHANGE: nota» se une al commit anterior. */
10function leerCommits(filas) {
11  const commits = [];
12  for (const fila of filas) {
13    const ruptura = /^BREAKING[ -]CHANGE: (.+)$/.exec(fila);
14    if (ruptura && commits.length) {
15      const ultimo = commits[commits.length - 1];
16      ultimo.rompe = true;
17      ultimo.nota = ruptura[1];
18      continue;
19    }
20    const m = RE_COMMIT.exec(fila);
21    if (!m) {
22      console.log(`Aviso: «${fila}» no sigue Conventional Commits`);
23      continue;
24    }
25    if (!TIPOS.includes(m[1])) {
26      console.log(`Aviso: tipo desconocido «${m[1]}» en «${fila}»`);
27      continue;
28    }
29    commits.push({ tipo: m[1], ambito: m[2], rompe: Boolean(m[3]), texto: m[4], nota: null });
30  }
31  return commits;
32}
33
34const linea = (c) => `- ${c.ambito ? `**${c.ambito}:** ` : ""}${c.texto}${c.nota ? ` — ${c.nota}` : ""}`;
35
36function main() {
37  const filas = lineas.map((l) => l.trim()).filter(Boolean);
38  const cabecera = /^version (\S+)$/.exec(filas[0] ?? "");
39  if (!cabecera) {
40    console.log("Falta la línea «version X.Y.Z»");
41    return;
42  }
43  const v = RE_VERSION.exec(cabecera[1]);
44  if (!v) {
45    console.log(`Versión no válida: ${cabecera[1]}`);
46    return;
47  }
48  let resto = filas.slice(1), canal = null;
49  const c = /^canal (\S+)$/.exec(resto[0] ?? "");
50  if (c) {
51    if (!/^[a-z]+$/.test(c[1])) {
52      console.log(`Canal no válido: ${c[1]}`);
53      return;
54    }
55    canal = c[1];
56    resto = resto.slice(1);
57  }
58  const [mayor, menor, parche] = v.slice(1, 4).map(Number);
59  const pre = v[4] ? { canal: v[4], n: Number(v[5]) } : null;
60  const actual = `${mayor}.${menor}.${parche}` + (pre ? `-${pre.canal}.${pre.n}` : "");
61  console.log(`Versión actual: ${actual}`);
62
63  const commits = leerCommits(resto);
64  let nivel = Math.max(0, ...commits.map((k) => (k.rompe ? 3 : NIVEL[k.tipo] ?? 0)));
65  if (nivel === 0) {
66    console.log("No hay cambios que publicar");
67    return;
68  }
69  const base = `${mayor}.${menor}.${parche}`;
70  let nueva, motivo;
71  if (pre) {
72    // una prerelease ya tiene decidida su versión base: solo cambia la etiqueta
73    if (canal === pre.canal) [nueva, motivo] = [`${base}-${canal}.${pre.n + 1}`, `nueva prerelease del canal ${canal}`];
74    else if (canal) [nueva, motivo] = [`${base}-${canal}.1`, `cambio al canal ${canal}`];
75    else [nueva, motivo] = [base, `se publica ${actual} como estable`];
76  } else {
77    // en 0.x todo puede cambiar: un cambio incompatible solo sube la minor
78    const cero = mayor === 0 && nivel === 3;
79    if (cero) nivel = 2;
80    nueva = nivel === 3 ? `${mayor + 1}.0.0` : nivel === 2 ? `${mayor}.${menor + 1}.0` : `${mayor}.${menor}.${parche + 1}`;
81    motivo = cero ? "minor: hay cambios incompatibles, pero la versión es 0.x"
82      : ["", "patch: solo hay correcciones", "minor: hay funciones nuevas", "major: hay cambios incompatibles"][nivel];
83    if (canal) {
84      nueva += `-${canal}.1`;
85      motivo += `; prerelease del canal ${canal}`;
86    }
87  }
88  console.log(`Siguiente versión: ${nueva} (${motivo})`);
89  console.log(`Etiqueta: v${nueva}`);
90  console.log(`## ${nueva}`);
91  const rompen = commits.filter((k) => k.rompe);
92  if (rompen.length) {
93    console.log("### Cambios incompatibles");
94    rompen.forEach((k) => console.log(linea(k)));
95  }
96  for (const [tipo, titulo] of SECCIONES) {
97    const lista = commits.filter((k) => k.tipo === tipo && !k.rompe);
98    if (lista.length === 0) continue;
99    console.log(`### ${titulo}`);
100    lista.forEach((k) => console.log(linea(k)));
101  }
102  const otros = commits.filter((k) => !k.rompe && !NIVEL[k.tipo]);
103  if (otros.length) console.log(`Otros cambios: ${otros.length} (${[...new Set(otros.map((k) => k.tipo))].sort().join(", ")})`);
104}
105
106main();

SemVer es un contrato con quien usa tu aplicación o tu librería: el número dice cuánto riesgo tiene actualizar. Por eso npm instala con ^: acepta minors y patches nuevos, que por contrato no rompen nada, pero no una major.

La regla de 0.x existe porque una versión 0 es desarrollo inicial: la API todavía no es estable y publicar la 1.0.0 es una decisión consciente, no algo que deba pasar solo por un commit con !.

Las prereleases ordenan antes que su estable (2.0.0-beta.4 < 2.0.0-rc.1 < 2.0.0) y los gestores de paquetes no las instalan salvo que se pidan expresamente: sirven para que unos pocos prueben antes de publicar para todos.

Automatizar la versión en la integración continua quita una tarea manual propensa a errores (olvidar subirla, subir la que no es) y deja el changelog escrito para cada publicación.

Para ir más allá

  • Ordena una lista de versiones según la precedencia de SemVer (con las prereleases).
  • Añade la sección ### Deshecho para los revert: que citen un commit anterior.
  • Calcula la versión base de una prerelease a partir de la última estable y los commits, como hace semantic-release.

Dónde se explica