Entrega continua
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:
-
Descargar el repositorio Git de trabajo.
-
Instalar las dependencias necesarias como, por ejemplo, cualquier herramienta que vayamos a utilizar en el lanzamiento.
-
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.
-
Generar las notas de lanzamiento.
-
Fijar la versión del lanzamiento.
-
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:
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 }}