Automatizando el versionado de nuestro proyecto

Índice

  1. La importancia de la automatización
  2. El significado del versionado
  3. Dotando de significado al versionado: Semantic versioning
  4. Registrando la naturaleza de los cambios
  5. Ejemplo práctico con Git y GitHub
  6. Conclusiones

1. La importancia de la automatización

La automatización es esencial en cualquier proyecto de desarrollo. Nos permite ahorrar tiempo, evitando centrarnos en tareas repetitivas y centrarnos en tareas más creativas, reduciendo la carga mental. Además, también ayuda a reducir el error humano.

Poco a poco han surgido un montón de prácticas y herramientas para automatizar el desarrollo. Algunas de las más importantes son la integración continua y el despliegue continuo (CI/CD), mediante herramientas como GitHub Actions. Estas nos permiten analizar, empaquetar y desplegar el software automáticamente.

Otra automatización que tiene un impacto directo en la efectividad del CI/CD es el testing automático, ya que nos permite ser más ágiles creando nuevas funcionalidades y desplegarlas en producción sin preocupaciones, además de permitirnos usar técnicas de manejo de ramas más livianas como el trunk based development, mucho más sencillo que otras estrategias como GitFlow.

Una automatización que puede no parecer tan obvia a primera vista es el versionado de nuestra aplicación. Esto se debe a que para poder automatizarlo con éxito necesitamos cumplir algunos requisitos: dotar de un significado a la versión y registrar la naturaleza de los cambios que realizamos.

2. El significado del versionado

Cuando cambiamos el código de una aplicación, ya sea introduciendo una corrección o una nueva funcionalidad, el resultado final deja de ser el mismo que teníamos antes, y su funcionamiento general cambia. Aquí es cuando decimos que hemos generado una nueva versión de software. Para tener registradas estas versiones, las nombramos asignándole a cada una de ellas un identificador único. Pero, ¿qué estructura debe tener este identificador?

Una primera aproximación para nombrar una versión puede ser usar la fecha de construcción del paquete de software, el hash del último commit, o un simple número que se incremente en cada proceso de versionado. Pero no es lo ideal.

Imaginemos que estamos desarrollando una librería L de la que dependen otros proyectos. Hemos hecho varios desarrollos y queremos actualizarla, por lo que subimos una nueva versión. El proyecto P, que lo lleva otro equipo, tiene a la librería L como dependencia, y el equipo decide actualizarla a la última versión, ya que parece una tarea trivial. Después de actualizarla, ejecutan los tests, y algunos dejan de funcionar. Suena frustrante, ¿verdad?

Para que no sucedan estas situaciones, el versionado debe tener un significado. Este significado debe basarse en la magnitud de los cambios realizados. Por ejemplo, si hubiéramos versionado la librería L con un identificador que indique si se ha modificado el comportamiento de la funcionalidad expuesta, el equipo que desarrolla P podría haber no considerado la actualización de L como algo trivial, y dedicaría un tiempo a investigar los cambios en la funcionalidad proporcionada por la librería. De esta manera se evitarían sorpresas.

3. Dotando de significado al versionado: Semantic versioning

Una de las convenciones de versionado más utilizadas hoy en día es el versionado semántico o semantic versioning. Es bastante sencillo y está explicado en la página semver.org.

En esta convención, el número de versión tiene la forma X.Y.Z, donde X, Y y Z son números no negativos. Cada uno de estos tres números representa diferentes magnitudes de cambios realizados a la API pública, es decir, a la funcionalidad que nuestro software expone públicamente a usuarios u otros programas.

Patch version

La patch version viene representada por Z, y se incrementará cuando se introduzcan correcciones a bugs que no alteran el funcionamiento esperado por un usuario final. Es decir, aunque la implementación cambie, la API pública debe permanecer sin cambios. De esta manera es totalmente seguro pasar a usar una nueva versión de un software que solo incremente la patch version. Ejemplo de incremento: 1.6.1 -> 1.6.2.

Minor version

La minor version es representada por Y, y debe incrementarse cuando los cambios añadan nueva funcionalidad, pero sin afectar al comportamiento de la API pública que existía hasta ahora. Por tanto también puede incluir cambios que afectarían a la patch version. Es posible también que se marquen como obsoletas algunas funcionalidades de la API pública, por lo que si esto ocurre conviene buscar alternativas a estas, antes de que sean eliminadas.

Suele ser seguro pasar a una nueva versión de un software que incremente la minor version. Sin embargo, en el momento que empecemos a usar la nueva funcionalidad de una nueva versión de un software, ya no podemos pasar a usar una versión con una minor version anterior. En muchos casos, es posible indicar esta restricción en nuestro fichero de dependencias, como package.json en el caso de JS, o cargo.toml en el caso de Rust.

Cuando se incremente la minor version, la patch version debe establecerse a 0. Ejemplo: 1.6.1 -> 1.7.0.

Major version

La major version es representada por X, y se incrementará cuando se realicen cambios incompatibles con versiones anteriores. Funcionalidades de la API pública pueden sufrir cambios o dejar de existir. Puede incluir cambios que afectarían a la patch version y a la minor version. Al incrementar este valor, el resto de valores de versión de establecerán a 0. Ejemplo: 1.6.1 -> 2.0.0.

4. Registrando la naturaleza de los cambios

Antes de poder automatizar nuestros números de versión, necesitamos una forma de identificar el tipo de cambios que realizamos en el software. Como seguramente estemos usando un sistema de control de versiones como Git, podemos añadir información en los commits a modo de metadatos, que especifiquen el tipo de los cambios realizados.

Una de las convenciones más usadas a la hora de hacer esto es Conventional Commits. Su objetivo es definir un formato estándar para los commits, de manera que sea mucho más sencillo escribir herramientas que realicen tareas automáticas en base al historial de commits. Lo mejor es que esta convención está especialmente diseñada para trabajar con semantic versioning. 🎉

Esta convención nos dice que debemos estructurar nuestros commits de la siguiente manera:

<type>[optional scope]: <description>

[optional body]

[optional footer(s)]

Lo que más nos importa de este formato es el tipo de los commits, indicados en la parte <type>. Los principales son los siguientes:

  • fix para commits que arreglen un bug en el software. Ejemplo de mensaje de commit: fix(auth): fixed authentication via google on the browser webapp.
  • feat para commits que añadan una nueva funcionalidad. Ejemplo: feat(emoji): added support for new unicode emojis.
  • BREAKING CHANGE, o !, si nuestro commit realiza un cambio que rompe funcionalidad existente en la API pública de nuestro software. Ejemplos:
refactor(protocol): update of the messaging protocol`

BREAKING CHANGE: This new version of the protocol breaks some functionality with older versions.

feat!: remove support for Chrome 80 and earlier due to outdated Web API compatibility

5. Ejemplo práctico con Git y GitHub

Una vez explicados todos los conceptos, ya podemos ponerlos en práctica. 👩‍💻

Vamos a tratar de hacer algo muy sencillo, que nos dará como resultado final una imagen Docker cuyo propósito será imprimir el número de versión por pantalla.

NOTA: es importante que dispongas de Docker instalado en tu máquina y de una cuenta de GitHub si quieres seguir el proceso.

Pasos a seguir

Para simplificar las cosas usaremos un simple script, en vez de crear un proyecto de software al estilo de Spring o Node. Por tanto, simplemente crearemos un directorio vacío, iniciando en él un repositorio Git.

mkdir automatic-versioning
cd automatic-versioning
git init

Crearemos además un repositorio de GitHub vacío, al que daremos permisos de escritura para las actions. Esto se hace accediendo al repositorio en Settings > Actions > General > Workflow permissions.

Lo primer será crear un fichero donde especifiquemos la versión de nuestro software. En nuestro ejemplo, vamos a crear un fichero VERSION.txt, que contendrá la versión actual del proyecto, con el siguiente contenido:

v0.0.1

NOTA: En un proyecto real, dependiendo del ecosistema, puede que sea más conveniente establecer este número de versión en otro sitio. Por ejemplo, si trabajamos con JavaScript/TypeScript, podríamos indicar la versión en el fichero package.json.

Posteriormente, crearemos el script donde mostraremos la versión por pantalla, en este caso, con el nombre echo_version.sh:

#!/bin/sh

VERSION=$(cat VERSION.txt)

echo "Current version is: $VERSION"

Por último, crearemos un fichero Dockerfile como este, para poder construir la imagen:

FROM alpine:latest
WORKDIR /app
COPY echo_version.sh VERSION.txt ./
RUN chmod +x echo_version.sh
CMD ["/app/echo_version.sh"]

Como última fase, vamos a crear un workflow de GitHub Actions. Este se ejecutará en cada commit realizado a la rama main, y ejecutará las siguientes acciones:

  • Calcular la siguiente versión, a partir de los commits generados desde la última tag
  • Modificar el fichero VERSION.txt con la versión del cálculo anterior
  • Realizar un commit con los cambios del paso anterior.
  • Crear una tag con la versión calculada anteriormente.
  • Logearnos en el repositorio Docker asociado a nuestra cuenta de GitHub
  • Construir y subir la imagen Docker generada, etiquetada con la versión, al repositorio de imágenes.

Para ello, crearemos el siguiente archivo en .github/workflows/version-bump.yml:

name: Version Bump

on:
  push:
    branches:
      - main

jobs:
  version-bump:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Set up Git
        run: |
          git config --global user.name "github-actions[bot]"
          git config --global user.email "github-actions[bot]@users.noreply.github.com"
          git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git

      - name: Get next version
        id: semver
        uses: ietf-tools/semver-action@v1
        with:
          token: ${{ github.token }}
          branch: main

      - name: Write version on VERSION.txt
        run: |
          echo "${{ steps.semver.outputs.next }}" > VERSION.txt

      - name: Commit version bump
        run: |
          git add VERSION.txt
          git commit -m "chore(version): bump version to ${{ steps.semver.outputs.next }}"
          git push

      - name: Create a lightweight tag
        run: |
          git tag ${{ steps.semver.outputs.next }}
          git push origin ${{ steps.semver.outputs.next }}

      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Push to Docker Hub
        uses: docker/build-push-action@v4
        with:
          context: .
          push: true
          tags: |
            ghcr.io/<username>/<repo-name>:latest
            ghcr.io/<username>/<repo-name>:${{ steps.semver.outputs.next }}
        env:
          DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
          DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}

NOTA: en la parte que pone ghcr.io/<username>/<repo-name>, asegúrate de poner tu nombre de usuario y nombre del repo, ambos en minúsculas.

Ahora, necesitaremos hacer un commit siguiendo conventional commits, y una tag siguendo el versionado semántico. Esto es necesario ya que necesitamos tener al menos una tag con la que comparar los nuevos commits:

git add .
git commit -m "chore: first working version"
git tag v0.0.1

Podemos probar ahora a construir y ejecutar la imagen en nuestro equipo. Para ello, ejecutaremos las siguientes instrucciones:

docker build . --tag=automatic-versioning-example
docker run automatic-versioning-example

Y obtendremos el resultado siguiente:

Current version is: v0.0.1

Ahora haremos un nuevo commit extra. En este caso será un commit vacío dado que no vamos a modificar ningún fichero:

git commit --allow-empty -m "fix: empty commit"

Finalmente subiremos todo a nuestro repositorio de GitHub. Es importante subir la tag que hemos creado en local también. En mi caso lo he hecho así:

git remote add origin git@github.com:Suguis/version-test.git
git branch -M main
git push -u origin main --tags

Podemos ejecutar nuestra imagen Docker de la manera siguiente. En mi caso sería así:

docker run ghcr.io/suguis/automatic-versioning-example:latest

NOTA: si has creado el repositorio como privado, tendrás que cambiar la visibilidad de los paquetes a “Public”.

Aclaraciones

Es importante destacar que cuando la action se ejecuta, el incremento se realiza una sola vez, independientemente del número de commits que existan. Si por ejemplo hacemos una pull request hacia main con 2 commits marcados como feat y 8 marcados como fix, la minor version solo se incrementará en 1 y la patch version se establecerá a 0. Si tenemos estos mismos 10 commits en nuestra rama main local y los subimos a la rama main remota, también sucederá lo mismo.

A la hora de trabajar en un proyecto real, dependiendo de nuestra estrategia de ramas, implantaremos esta action de una u otra forma. Por ejemplo, si trabajamos con trunk based development, probablemente querremos ejecutar la action con cada push a main. Si en cambio trabajamos con GitFlow, podríamos querer ejecutarla con cada push a develop, ya que al tener el versionado en develop podríamos crear fácilmente las ramas release/X.Y.Z correspondientes.

Repositorio

En este repositorio puedes ver el código realizado. Puedes probar a ejecutar la última versión de la imagen Docker correspondiente de esta manera:

docker run ghcr.io/suguis/automatic-versioning-example:latest

¡También puedes realizar un fork y experimentar! Ten en cuenta que en ese caso necesitarás cambiar el fichero .github/workflows/version-bump.yml para poner en estas dos líneas tu nombre de usuario y nombre de tu repositorio, en minúsculas:

ghcr.io/<username>/<repo-name>:latest
ghcr.io/<username>/<repo-name>:${{ steps.semver.outputs.next }}

Si quieres experimentar con tu propio fork y realizar commits para observar cómo se modifica la versión, puedes crear un fichero aparte dentro del propio repositorio para poder realizarlos, si no quieres tocar el resto de ficheros. Otra opción igual de válida, es crear commits en blanco, de la siguiente manera:

git commit --allow-empty -m "feat: empty commit"

Al hacer push a main, ya sea commiteando directamente o a través de una PR, la action se ejecutará generarando una nueva imagen Docker, que imprimirá por pantalla la nueva versión calculada a partir de tus commits.

6. Conclusiones

En este artículo hemos visto la importancia del versionado en el mundo del software, y de los dolores de cabeza que podemos ahorrarle a los demśa utilizando un buen versionado. Además hemos conocido técnicas como el versionado semántico y los conventional commits.

Al utilizar prácticas y convenciones standard, es más fácil encontrar software disponible para usar, de forma que podemos empezar a aplicar estas prácticas de manera fácil y sencilla. Por supuesto, para implementar algunas de ellas será necesario explicárselas a todo el equipo para que se familiaricen con ellas. En el caso del versionado automático, por ejemplo, podemos hablar sobre los conventional commits.

Además, las prácticas que hemos aprendido no solo son útiles para automatizar la versión, sino que pueden dar lugar a muchas otras automatizaciones interesantes, como un changelog automático, algo también esencial cuando trabajamos con software.

Recuerda que muchos procesos son automatizables. Detectarlos y automatizarlos mejorará el rendimiento y la motivación del equipo.

Anaís Solís
Anaís Solís
Me dedico al desarrollo backend, especialmente con código legacy, lo que ha despertado mi pasión por el testing, las buenas prácticas, el refactoring y la automatización. Me gusta mucho aprender, pero también enseñar. Apasionada del testing automático y de la programación funcional

Otros artículos que te pueden interesar

El día en el que comencé a desarrollar todas mis webs con Python

0
En diciembre de 2022 apareció un nuevo framework llamado Pynecone, en su versión 0.1.8 Alpha. Cero ruido. Ya en julio de 2023, con su cambio de nombre a Reflex, y el lanzamiento de su versión 0.2.0, descubrí por primera vez este framework. Y la comunidad comenzó a hablar de él.
Java 23, claves y datos necesarios de la última versión de java

Java 23: ¿dónde estamos y cómo hemos llegado hasta aquí?

0
Java 23 ya está disponible desde el 17 de septiembre de 2024. Como siempre que se lanza una nueva versión, es útil conocer las novedades que incluye nuestro lenguaje favorito.

Tests de integración con Spring Boot y Testcontainers

0
En el desarrollo de aplicaciones Spring Boot es fundamental asegurar que todo funcione correctamente. Los tests unitarios son esenciales para validar el comportamiento de componentes de manera aislada.