Hasta el momento, hemos presentado los flujos de integración continua, aquellos que nos permiten llevar a cabo el CI del CI/CD. Pero todavía no hemos prestado especial atención a la entrega continua, esto es, al CD del CI/CD. Ha llegado el momento de verlo.

Al finalizar, sabrá:

  • Qué es la entrega continua.

  • Cómo utilizar las etiquetas de Git para identificar versiones en el historial del repositorio.

  • Qué son y cómo crear las interdependencias de trabajos.

  • Cómo generar y publicar imágenes de Docker de manera automática con GitHub Actions.

Introducción

La entrega continua (continuous delivery) está relacionada con la entrega más rápida y frecuente de software. Si conseguimos entregar los cambios más rápidamente, generamos más valor para nuestro proyecto y para sus usuarios. Además, al tenerlo automatizado seremos menos propensos a errores de entrega. Básicamente, añade una fase de despliegue del software en entornos de producción o pruebas al flujo CI/CD.

La entrega continua no se ejecuta cada vez que realizamos una confirmación o combinación de ramas. Se podría ejecutar, pero no es el objetivo. El objetivo es tenerlo todo preparado para poder realizar entregas o publicaciones rápidamente en cualquier momento.

Si conseguimos automatizar el proceso de entrega, podemos reducir fácilmente el tiempo de espera (lead time), ya sea el que transcurre entre que se detecta una necesidad y la ponemos a disposición de los usuarios; o bien, el que transcurre desde que subimos un cambio al repositorio Git y este llega finalmente al usuario en la siguiente entrega. Por ejemplo, si estamos usando una estrategia de ramificación troncal, como ya sabemos, la rama troncal es siempre construible y estable. Eso permite que podamos publicar muy fácilmente nuevos módulos o paquetes. Por ejemplo, al finalizar el día, podemos generar lo que se conocen como una versión nocturna (nightly version), versión generada por la noche con el código actual de la rama troncal, todo ello, sin necesidad de intervención manual. Otro ejemplo sería la publicación o entrega bajo demanda. Esto es posible porque la rama troncal es siempre construible y podemos desplegar de inmediato al tener el proceso automatizado.

De la misma manera que podemos automatizar el proceso de integración continua con GitHub Actions, también podemos hacer lo mismo con la entrega continua. Siguiendo con las versiones nocturnas, podemos configurar un disparador a medianoche que genere esta versión y la publique automáticamente poniéndola, así, a disposición de nuestros usuarios sin necesidad de realizar ningún trabajo manual.

En este punto, vamos a recordar algunas métricas ya presentadas anteriormente, recuperarlas nos ayudará a asentarlas un poquito más en nuestra memoria:

  • El tiempo de espera (lead time) está relacionado con el tiempo transcurrido desde que nos ponemos a trabajar en una cosa hasta que llega al usuario. Se suele medir en días, semanas o meses. Lo ideal es que sea en unos días o pocas semanas.

  • La frecuencia de entrega (delivery frequency) hace referencia a cada cuánto tiempo entregamos versiones del producto a los usuarios. Aquí también lo ideal es hacerlo cada pocos días o semanas.

  • El tiempo medio de reparación (mean time to repair o MTTR) es el tiempo que transcurre desde que identificamos un problema, lo resolvemos y los ponemos a disposición de los usuarios. Y como no podía ser de otra manera, también debe ser pequeño.

  • La tasa de fallos de cambio (change failure rate) es el porcentaje de entregas que resultaron en fallo. Ayuda a detectar si estamos haciendo cosas mal que acaban generando software que falla. Una tasa del cero al diez por ciento se considera más o menos aceptable; pero mayor, no.

De la misma manera que la integración continua ayuda a mejorar algunas de estas métricas, la entrega continua también lo hace. Ambas son claves y mejoran nuestra eficiencia y productividad, así como el valor de nuestro producto de software.

Flujo de lanzamiento

Un lanzamiento (release) representa una versión de un paquete. Esta versión debe generarse mediante un flujo de trabajo que se conoce formalmente como flujo de trabajo de lanzamiento (release workflow), el cual puede descomponerse en los siguientes pasos:

  1. Descargar el repositorio Git de trabajo.

  2. Instalar las dependencias necesarias como, por ejemplo, cualquier herramienta que vayamos a utilizar en el lanzamiento.

  3. Ejecutar el flujo de CI para garantizar que todo va bien. Otra posibilidad es extraer el artefacto que contiene el paquete compilado y ya probado del almacén de artefactos.

  4. Generar las notas de lanzamiento.

  5. Fijar la versión del lanzamiento.

  6. Crear el lanzamiento en GitHub si es necesario.

    Los lanzamientos de GitHub son muy recomendados y los describimos en la lección posterior.

Es buena práctica separar el flujo de generación de una nueva versión o lanzamiento de su publicación. Aunque algunas organizaciones los suelen fusionar en un único flujo.

Permisos del flujo de lanzamiento

Por seguridad, no debemos olvidar el principio de menor privilegio. Generalmente, los flujos de lanzamiento necesitan permiso de lectura y escritura de contenido, porque suelen hacer cambios al código fuente y, a continuación, los suben a GitHub. Este tipo de flujos tienden a tener la siguiente propiedad permissions:

permissions:
  contents: write

Etiquetado de Git

El etiquetado (tagging) es una operación por la que asociamos una etiqueta a un cambio del repositorio de Git. Se utiliza principalmente, aunque no únicamente, para identificar versiones dentro del historial. Es importante saber cómo trabajar con ellas para poder identificar puntos del historial del repositorio donde se encuentra el código concreto de una determinada versión.

Creación de etiquetas

Git soporta dos tipos de etiquetas, las ligeras y las anotadas. Una etiqueta ligera (lightweight tag) no es más que un puntero a una determinada confirmación (commit). Se crean mediante el comando git tag como sigue:

# etiqueta el último commit
git tag etiqueta

# etiqueta el commit indicado
git tag etiqueta sumaDeComprobación

Ejemplo:

git tag v1.2.3

Por otra parte, tenemos las etiquetas anotadas (annotated tags), se implementan mediante un objeto específico dentro del historial de Git. Contiene información, por ejemplo, sobre quién la creó e incluso un mensaje de etiqueta específico que no tienen las ligeras. Se crean mediante el comando git tag con las opciones -a y -m:

# etiqueta el último commit
git tag -a etiqueta -m 'Mensaje de etiquetado'

# etiqueta el commit indicado
git tag -a etiqueta -m 'Mensaje de etiquetado' sumaDeComprobación

Ejemplo:

git tag -a v1.2.3 -m "Release: v1.2.3"

Publicación de etiquetas

Las etiquetas que creamos en nuestro entorno local no se publican automáticamente cuando hacemos un git push. Es necesario utilizar una sintaxis especial de este comando:

# publica una determinada etiqueta
git push idRepoRemoto etiqueta

# publica todas las etiquetas
git push idRepoRemoto --tags

Ejemplo:

git push origin --tags

Listado de etiquetas

Para listar las etiquetas existentes, utilizaremos el comando git tag -l:

# lista las etiquetas existentes
git tag -l

# lista las etiquetas que cumplen un patrón
git tag -l patrón

He aquí un ejemplo ilustrativo:

git tag -l v1.2*

Supresión de etiquetas

Para suprimir una etiqueta, usaremos uno de los siguientes comandos:

# supresión local de una etiqueta
git tag -d etiqueta

# supresión remota de una etiqueta
git push idRepoRemoto --delete etiqueta

Interdependencia de trabajos

Los trabajos de un flujo de trabajo son independientes y se ejecutan aisladamente en sus propias máquinas virtuales o contenedores. Eso permite que se puedan ejecutar de manera paralela todos o parte de ellos.

En ocasiones, necesitamos que los trabajos se ejecuten uno detrás de otro, por cuestiones de dependencia, por ejemplo, porque un trabajo necesita una salida o un artefacto generado por otro. Existe entonces una interdependencia de trabajos (job interdependency), ya que un trabajo depende de otro del mismo flujo. Esto hace que el dependiente tenga que esperar a que su dependencia termine.

Propiedad needs de los trabajos

En estos casos, podemos indicar que un trabajo depende de otro y sólo puede ejecutarse tras ese otro, mediante su propiedad needs. El valor de esta propiedad puede ser el identificador del trabajo del que depende, es decir, que debe ejecutarse antes, o bien un array de los trabajos de los que depende.

A continuación, se muestra un ejemplo de un flujo de trabajo que espera a que las pruebas de unidad se hayan ejecutado para comenzar con las de integración:

name: CI

on:
  push:
    branches:
      - "**"

permissions:
  contents: write

jobs:
  runUnitTests:
    uses: siacodelabs/.github/.github/workflows/actions-ci.yaml@main

  runSystemTests:
    needs: runUnitTests
    runs-on: ubuntu-latest

    env:
      skylink: sia://AAAFCzW_tyQKKJZL_xHXHWE-XwusklwWBSv9HFFtZhtecA
      localFilePath: /tmp/hello-world.txt

    steps:
      - name: Clone repo
        uses: actions/checkout@v3
      
      - name: Install dependencies
        run: npm i --production
      
      - name: Download skylink
        uses: ./
        with:
          skylink: ${{ env.skylink }}
          path: ${{ env.localFilePath }}
      
      - name: Check local file
        run: |
          cat ${{ env.localFilePath }}

          if [[ ! $(grep -i hello ${{ env.localFilePath }}) ]]; then
            echo "::error::File should contain the text 'hello'."
            exit 1
          fi

Propiedad outputs de los trabajos y los pasos

Un trabajo puede generar artefactos y datos para sus dependientes. Los artefactos los presentamos en una lección anterior, pero también es posible generar datos como, por ejemplo, el nombre de un artefacto generado para que sea usado por un trabajo dependiente. Estos datos se conocen formalmente como salidas (outputs) y se indican mediante la propiedad outputs del trabajo. Esta propiedad de tipo objeto debe contener una propiedad para cada una de sus salidas, donde su valor es el valor de la propiedad. Si alguna salida contiene una expresión, esta se ejecutará al finalizar la ejecución del trabajo para que pueda, así, acceder a los valores finales de los contextos del trabajo.

Veamos un ejemplo:

jobs:
  version:
    outputs:
      version: ${{ env.version }}
    
    steps:
      # ...

Contexto steps

Como los pasos son los que realmente generan los artefactos y los datos, estos también pueden contener una propiedad outputs. Cuando un trabajo necesita acceder a la salida de uno de sus pasos, puede utilizar su contexto steps. Para cada paso, existe una propiedad cuyo valor es un objeto que describe el paso. El formato para acceder al valor de una salida de un paso es el siguiente:

${{ steps.idDelPaso.outputs.nombreDeLaSalida }}

En estos casos, el paso debe tener una propiedad id que indique su identificador en el contexto steps.

Contexto needs

Un trabajo puede utilizar su contexto needs para acceder a las salidas de cualquiera de los trabajos de los que depende. Para ello, podemos utilizar una expresión similar a la siguiente:

${{ needs.nombreTrabajoDelQueSeDepende.outputs.nombreSalida }}

Generación de imágenes de Docker

Docker es una plataforma de virtualización basada en contenedores ampliamente utilizada hoy en día. Son muchos los proyectos de software que además de publicarse en registros como, por ejemplo, NPM, también generan imágenes de Docker para facilitar su uso. Cuando este es el caso, la generación de las imágenes y su posterior publicación en un registro de Docker pueden generarse automáticamente. En el caso de Node.js, esta generación se suele realizar tras la publicación de los paquetes correspondientes en NPM, o sea, tras la ejecución del flujo de lanzamiento.

Evento workflow_run

GitHub Actions proporciona el evento workflow_run con el que atar o desencadenar la ejecución de un flujo una vez comenzado o terminado otro. Su sintaxis es como sigue:

on:
  workflow_run:
    workflows: [lista de nombres de flujo de trabajo]
    types: [cuándo debe ejecutarse este flujo]

Veamos un ejemplo introductorio extraído de https://github.com/akromio/nodejs-akromio/blob/main/.github/workflows/release-docker.yaml:

name: release-docker

on:
  workflow_run:
    workflows: [release-npm]
    types: [completed]

jobs:
  # ...

La propiedad workflows indica los flujos de los que depende. Contiene una lista de nombres de estos flujos. Recuerde que el nombre de un flujo es su propiedad name, no el nombre del archivo. Así pues, si el archivo es, por ejemplo, release-npm.yaml y su propiedad name es Release NPM, tendremos que indicar Release NPM.

Por otra parte, tenemos la propiedad types que afina mejor el evento de disparo. Consiste en una lista de elementos, cuyos valores pueden ser:

Valor Descripción
completed Una vez terminado el flujo del que se depende.
requested Una vez solicitada la ejecución del flujo del que se depende.
in_progress Una vez se encuentre el flujo indicado en ejecución.

En el ejemplo anterior, la generación y publicación de las imágenes en el registro oficial de Docker se realiza una vez ha finalizado el flujo release-npm. En este caso, no se tiene en cuenta si el flujo termina con éxito o en fallo. Siempre que finalice, se ejecutará. Por suerte, podemos indicar que el trabajo sólo debe ejecutarse si el flujo termina bien mediante una propiedad if como la siguiente:

jobs:
  docker-hub:
    name: Publish images on Docker Hub
    if: github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest

    # ...

Lo que estamos diciendo en el if es: el trabajo sólo debe ejecutarse si el evento workflow_run disparador asociado a cualquiera de los flujos indicados en on.workflow_run.workflows finaliza completamente sin errores.

Este evento se puede utilizar tanto para la ejecución automática del flujo de publicación de imágenes en Docker como para otras cosas como, por ejemplo, la ejecución de pruebas de integración una vez terminada la ejecución del flujo de integración continua como, por ejemplo:

name: ci-itg

on:
  workflow_run:
    workflows: [ci]
    types: [completed]

permissions:
  contents: read

jobs:
  tests:
    name: Run integration tests
    if: github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node: [16.x, 18.x]
    
    services:
      redis:
        image: redis:alpine
        ports:
          - 6379:6379
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - name: Clone repository
        uses: actions/checkout@v3
      
      - name: Set up redis-cli
        run: sudo apt install redis-tools
      
      - name: Set up Node.js ${{ matrix.node }}
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node }}
      
      - name: Set up Gattuso
        uses: akromio/setup-gattuso@v1
      
      - name: Install dependencies
        run: npm ci

      - name: Run integration tests
        run: gattuso r ci/itg

Acción docker/login-action

Para poder publicar una imagen en un registro de Docker, primero debemos identificarnos. Para esto, disponemos de la acción docker/login-action. Es muy sencilla, he aquí un ejemplo:

- name: Login to Docker Hub
  uses: docker/login-action@v2
  with:
    username: ${{ secrets.DOCKER_HUB_USERNAME }}
    password: ${{ secrets.DOCKER_HUB_TOKEN }}

Observe que espera dos argumentos de entrada. Con username, indicamos nuestro nombre de usuario en el registro, el cual mantendremos a buen recaudo en el almacén de secretos de GitHub Actions. Mientras que con password, indicamos el token de acceso que nos proporciona el registro de Docker, el cual también mantendremos en el almacén de secretos. En el caso del registro oficial, para generar este token, hay que ir a Account Settings > Security > Access Tokens y hacer clic en New Access Token. Como permisos de acceso indicaremos Read, Write, Delete o simplemente Read & Write, según nuestras necesidades:

Generación de *token* de acceso en el registro de *Docker*

De manera predeterminada, se utiliza el registro oficial de Docker. En caso de usar otro, tendremos que indicar el argumento registry, además de username y password, y su dominio como, por ejemplo:

# GitHub Container Registry
registry: ghcr.io

# Azure Container Registry
registry: nombreDeNuestroRegistro.azurecr.io

Acción docker/build-push-action

Una vez identificados, podemos pasar a construir nuestra imagen y a publicarla. Para este fin, disponemos de la acción docker/build-push-action. Veamos un ejemplo de uso:

- name: Build and push Gattuso image
  uses: docker/build-push-action@v3
  with:
    context: ./docker/gattuso/alpine
    build-args: version=${{ env.version }}
    push: true
    tags: akromio/gattuso:latest,akromio/gattuso:${{ env.mmVersion}},akromio/gattuso:${{ env.version }}

El argumento context indica el directorio en el que se encuentra el Dockerfile a usar para generar la imagen. Mediante build-args, pasamos los argumentos a usar, si son necesarios, para esta construcción. El nombre y las etiquetas de la imagen se indican en la propiedad tags; cada imagen:etiqueta se separa de la siguiente con una coma. Finalmente, para indicar que debe publicarse la imagen en el registro se fijará el argumento push a true.

Permisos del flujo de Docker

Por lo general, el flujo de construcción y publicación de imágenes de Docker no suele modificar el contenido del repositorio. Por lo que tendrá una su propiedad permissions como sigue:

permissions:
  contents: read

Ejemplo de flujo de generación y publicación de imagen de Docker

A continuación, vamos a presentar un ejemplo que publica varias imágenes en el registro oficial de Docker tras cada publicación en NPM:

name: release-docker

on:
  workflow_run:
    workflows: [release-npm]
    types: [completed]

permissions:
  contents: read

jobs:
  docker-hub:
    name: Publish images on Docker Hub
    if: github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node: [18.x]

    steps:
      - name: Clone repository
        uses: actions/checkout@v3
      
      - name: Set up Node.js ${{ matrix.node }}
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node }}
      
      - name: Determine versions to publish
        run: |
          # (1) latest version
          version=$(npm pkg --workspace @akromio/gattuso get version | grep gattuso | grep -o -E "[[:digit:]]+.[[:digit:]]+.[[:digit:]]")
          echo "version=$version" >> $GITHUB_ENV

          # (2) major.minor version
          mmVersion=$(npm pkg --workspace @akromio/gattuso get version | grep gattuso | grep -o -E "[[:digit:]]+.[[:digit:]]+")
          echo "mmVersion=$mmVersion" >> $GITHUB_ENV
      
      - name: Login to Docker Hub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKER_HUB_USERNAME }}
          password: ${{ secrets.DOCKER_HUB_TOKEN }}
      
      - name: Build and push Gattuso image
        uses: docker/build-push-action@v3
        with:
          context: ./docker/gattuso/alpine
          build-args: version=${{ env.version }}
          push: true
          tags: akromio/gattuso:latest,akromio/gattuso:${{ env.mmVersion}},akromio/gattuso:${{ env.version }}

      - name: Build and push Carboni image
        uses: docker/build-push-action@v3
        with:
          context: ./docker/carboni/alpine
          build-args: version=${{ env.version }}
          push: true
          tags: akromio/carboni:latest,akromio/carboni:${{ env.mmVersion}},akromio/carboni:${{ env.version }}

      - name: Build and push Cavani image
        uses: docker/build-push-action@v3
        with:
          context: ./docker/cavani/alpine
          build-args: version=${{ env.version }}
          push: true
          tags: akromio/cavani:latest,akromio/cavani:${{ env.mmVersion}},akromio/cavani:${{ env.version }}