Versionado semántico automático: de los commits a la versión y el changelog
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
- La entrada empieza con
version X.Y.Z(puede llevarvdelante y una prerelease-canal.N), sigue con una línea opcionalcanal 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 escribeVersión actual: X(sin lav). - 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íneaBREAKING CHANGE: nota(oBREAKING-CHANGE) marca como incompatible el commit anterior y guarda la nota (si no hay anterior, es una línea que no sigue el formato). - El nivel es el mayor de los commits: incompatible 3,
feat2,fixyperf1, el resto 0. Si es 0:No hay cambios que publicary 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. - Escribe
Siguiente versión: V (motivo),Etiqueta: vVy## 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 Csi hay canal; y para prereleases,nueva prerelease del canal C,cambio al canal Cose publica X como estable. - El changelog:
### Cambios incompatiblescon 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— notasi 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
| Tipo | Para qué | Nivel |
|---|---|---|
| feat | función nueva | minor |
| fix | corrección de un error | patch |
| perf | mejora de rendimiento | patch |
| docs, style, refactor, test | documentación, formato, reestructuración, pruebas | ninguno |
| build, ci, chore, revert | compilación, integración continua, mantenimiento, deshacer | ninguno |
| cualquiera con ! o BREAKING CHANGE | cambio incompatible | major (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.
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.
Ejemplo
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'
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)
Solución explicada
Ver la solución completa
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
### Deshechopara losrevert: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.