15 marzo 2023

Cómo usar GitHub Actions para integración y despliegue continuos


En un artículo anterior ya expliqué cómo usar Travis CI para aplicar integración continua en tus proyectos. El problema con Travis es que ha cambiado sus términos de uso y se ha vuelto bastante incómodo para aplicarlo en proyectos open source. Ellos siguen diciendo que son gratuitos para proyectos open source pero en realidad tienes que suplicar por los créditos gratuitos cada vez que se agotan y te hacen probar que sigues cumpliendo con lo que entienden por un proyecto open source (he leído casos de desarrolladores que fueron descartados por el mero hecho de tener activados los GitHub Sponsors).

Por eso, he acabado buscando alternativas para mis proyectos. Dado que ya uso GitHub para mis proyectos open source, es natural probar su sistema de integración y despliegue continuos: GitHub Actions.

Conceptualmente, GitHub Actions es similar a Travis CI, por eso no voy a repetir los conceptos que desarrollé en el artículo de Travis CI.  Si quieres repasar esos conceptos y las razones para aplicar integración y despliegue continuos (CI/CD) puedes leer el artículo que he enlazado al comienzo de este. Por eso, vamos a enfocarnos en cómo usar GitHub Actions.

Como pasaba en Travis CI, todo lo que haces en GitHub Actions gira en torno a los ficheros yaml que vas creando en la carpeta .github/workflows de tu repositorio. GitHub buscará en esa carpeta los flujos de CI/CD que tiene que ejecutar. Cada uno de esos ficheros define un flujo de trabajo (workflow). Puedes tener múltiples flujos de trabajo para ser ejecutados como respuesta a diferentes eventos de la plataforma de GitHub, como pushes sobre determinadas ramas, pull requests recibidos, altas de nuevas incidencias de usuario (issues) y un largo etcétera.

La diferencia es que encuentro GitHub Actions bastante más cómodo y potente que Travis CI. Lo que diferencia a GitHub Action es que facilita la reutilización de componentes de manera masiva. Cada paso se puede encapsular y compartir entre flujos diferentes de trabajo o incluso con otros desarrolladores en GitHub para que lo apliquen a sus respectivos flujos de trabajo. Con tanta gente desarrollando y compartiendo en GitHub lo normal usar las tareas de otros (los que se denomina Actions), incluso más que implementarlas por uno mismo. A no ser que estés automatizando algo realmente raro, lo normal es que alguien lo haya implementado ya y lo haya compartido. En este artículo usaremos las acciones de otros desarrolladores e implementaremos nuestros propios pasos. Además, otra ventaja de GitHub Actions es que puedes reutilizar tus propios flujos de trabajo, o compartirlos con otros, de manera que no tengas que implementarlos desde cero en otros flujos de trabajo.

En este artículo nos enfocaremos al típico flujo de "probar -> empaquetar -> distribuir" y lo llamaremos  test_and_deploy.yaml (puedes llamarlo como quieras si te sientes creativo, pero intenta ser expresivo). Tienes el código fuente empleado en este artículo en este commit de mi proyecto cifra-rust en GitHub.

Para crear ese fichero yaml tienes dos opciones: crearlo en tu IDE y hacerle push como a cualquier otro fichero o bien crearlo usando el editor web integrado con GitHub. Para tu primera vez mi consejo es que uses el editor web, ya que te guía mejor a la hora de hacer funcionar tu primer fichero yaml. Teniendo en cuenta lo anterior, ve a tu repositorio en GitHub y pincha sobre la pestaña de Actions:


Allí, se te ofrece crear tu primer flujo, cuando aceptas se te ofrece usar una plantilla predefinida como punto de partida (GitHub tiene muchísimas, para diferentes tareas y lenguajes) o bien crear un flujo desde cero. Por aquello de aprender, nosotros vamos a elegir la opción de partir desde cero ("set up a workflow yourself"). Será entonces cuando entraremos al editor web. El flujo estará relleno con algo muy básico.

Ahora vamos a analizar el fichero yaml de Cifra-rust para ver lo que podemos hacer con las GitHub Actions.


Cabecera

En las primeras líneas (de la 1 a la 12) puedes ver el nombre de este flujo (en la etiqueta "name"). Utiliza un nombre expresivo, que identifique de un vistazo lo que hace el flujo. Este nombre será útil también para reutilizar este flujo en otros repositorios.

La etiqueta "on" define qué eventos disparan este flujo. En mi caso el flujo se dispara con los pushes y pull requests sobre mi rama staging. Hay muchos más eventos que puedes usar.

La etiqueta "workflow_dispatch" te permite disparar manualmente un flujo desde el interfaz web de GitHub. Suelo meterlo en todos mis flujos, no hace daño contar con esa opción.



Trabajos (Jobs)

Lo siguiente es la etiqueta "jobs" (línea 15) que es justo donde comienza el meollo de los flujos. Un flujo está compuesto de jobs. Cada job se ejecuta en una máquina virtual diferente (el runner) de manera que sus respectivas dependencias quedan encapsuladas. Esa encapsulación es buena para evitar que las dependencias de un job estropeen las dependencias y el sistema de ficheros de los otros jobs. Mi consejo es que intentes que cada job se centre en una única tarea (sí, aquí también aplica el principio de maximizar la cohesión).

Por defecto los jobs se ejecutan en paralelo a menos que se fijen dependencias explícitas entre ellos. Si necesitas que un job B se ejecute después de que el job A se finalice correctamente lo que tienes que hacer es usar la etiqueta "needs" para fijar que B necesita que A se complete correctamente para comenzar. Por ejemplo, en Cifra-rust los trabajos "merge_staging_and_master", "deploy_crates_io" y "generate_deb_package" necesitan que el trabajo "tests" finalice correctamente para que ellos puedan comenzar.  Puedes ver un ejemplo del uso de la etiqueta "needs" en la línea 53:

Como "deploy_debian_package" necesita respectivamente que "generate_deb_package" finalice antes, al final acabas con un árbol de ejecución como el siguiente:


Actions

Cada job se compone de uno o más pasos (steps). Un step es una secuencia de comandos de shell. Estos pueden comandos pueden ser los nativos del sistema operativo sobre el que estés ejecutando el job o bien scripts que hayas incluido en tu repositorio. De la línea 112 a la 115 podemos ver uno de estos steps:

En el step anterior estamos llamando a un script de la carpeta ci_scripts del repositorio. Fíjate en el pipe ("|") junto a la etiqueta "run". Sin ese pipe sólo podrías incluir un comando junto a la etiqueta, pero gracias a él se pueden meter varios comandos separados en líneas independientes (como en los steps de las líneas 44 a 46).

Si te encuentras repitiendo los mismos comandos en flujos diferentes entonces deberías pensar en encapsular esos comandos en una action. Como te puedes imaginar. las Actions son la clave de las GitHub Actions. Una action es un conjunto de comandos empaquetados para ser compartidos y reutilizados en diferentes flujos de trabajo (tuyos o de otros usuarios). Una action tiene entradas (inputs) y salidas (outputs), pero lo que pasa dentro de ella no es tu problema siempre y cuando funcione como se le supone. El cómo desarrollar tus propias actions y compartirlas con otros merece su propio artículo. En el próximo artículo convertiré ese step para generar el manpage en una action que pueda ser reutilizada.

A la derecha, el editor web de GitHub Actions tiene un buscador para encontrar actions útiles para la tarea que queramos hacer. Supongamos que quieres instalar el toolchain de Rust, en ese caso podemos hacer esta búsqueda:

Aunque el editor web de GitHub es realmente completo y sirve para encontrar errores en los ficheros yaml, su buscador carece de una manera de filtrar y reordenar sus resultados. Aún así es tu mejor opción para encontrar actions de otros para tus flujos.

Una vez que haces click en cualquier resultado del buscador, se te muestra un resumen acerca de que texto incluir en tu fichero yaml para usar esa action. Puedes conseguir una información aún más detallada haciendo click en el enlace "View full Marketplace listing".

Como con los step, una action usa una etiqueta "name" para describir lo que se supone que va a hacer la tarea, así como una "id" si ese action debe ser referenciado desde otros puntos del flujo de trabajo (mira por ejemplo la línea 42). Lo que diferencia a un step de una action es que este último utiliza la etiqueta "uses". Esa etiqueta referencia la action que queremos usar. El texto a usar en esa etiqueta difiere para cada action, pero puedes averiguar qué escribir allí consultando las instrucciones que salen en los resultados de la búsqueda para esa action. En esas instrucciones se suele describir las entradas que acepta la action. Esas entradas se incluyen con la etiqueta "with". Por ejemplo, en las líneas 23 a 27 usé una action para instalar el framework de compilación de Rust: 

Como puedes ver, en la etiqueta "uses" puedes decir qué versión de la action usar. Hay wildcards para usar la última versión pero lo mejor es usar una versión específica para evitar que los flujos se rompan por actualizaciones de los actions.

Los jobs se componen de actions encadenados en forma de pasos. Los pasos de un job se ejecutan secuencialmente. Si cualquiera de ellos falla, el job completo fracasa.


Compartir datos entre pasos y flujos

Aunque los steps de un flujo se ejecutan en la misma máquina virtual no pueden compartir variables de entorno porque cada step lanza un proceso de bash diferente. Hay dos maneras de fijar una variable de entorno en un step que necesite ser usado en otro step del mismo job:

  • Rápido y sucio: Concatenar la variable de entorno a la variable $GITHUB_ENV de manera que esa variable pueda ser accedida después usando el contexto env. Por ejemplo, en la línea 141 creamos la variable de entorno:

Esa variable de entorno es accedida en la línea 146, en el step siguiente:


  • Fijar el output del step: El problema con el último método es que aunque te permite compartir datos entre steps no te permite hacerlo entre jobs diferentes. Fijar los outputs de un step es algo más lento pero deja a tu step preparado para compartir datos no sólo con otros steps del mismo job, sino con steps de cualquier workflow. Para configurar una variable de entorno como el output de un step hace falta pasar el valor de esa variable a ::set-output y darle nombre a esa variable, seguida de su valor tras una pareja de dos puntos ("::"). Tienes un ejemplo de cómo hacerlo en la línea 46:
 
Fíjate en que ese step debe ser identificado con una etiqueta "id" para poder recuperar luego la variable compartida. Como ese step se identifica como "version_tag", la variable creada como "package_tag" puede ser recuperada luego desde otro step del mismo job usando:

${{ steps.version_tag.outputs.package_tag }}

En realidad, ese método se usa en la línea 48 para preparar esa variable para ser recuperada desde otro job. Recuerda que hasta ahora hemos visto que ese método sirve para pasar datos entre los steps de un mismo job. Para exportar datos desde un job, para ser usados desde otro job, tienes que declararlo primero como un output del job (líneas 47-48):


Fíjate en que en la última captura de pantalla, el nivel de indentación debe estar al mismo nivel que la etiqueta "steps" para que se pueda configurar a package_tag como un output del job.

Para recuperar ese output desde otro job, el job de destino debe declarar al job de origen en su etiqueta "needs". Después de eso, el valor puede ser recuperado usando el siguiente formato:

${{ needs.<needed_job>.outputs.<output_varieble_name> }} 

En nuestro ejemplo, "deploy_debian_package" necesita el valor exportado en la línea 48, por eso declara su job (test) como una dependencia en la línea 131:

Después de eso, ya puede leer esa variable en la línea 157:



Pasando ficheros entre jobs

A veces, pasar una variable no es suficiente porque necesitas producir ficheros en un job para que luego sean consumidos en otro job.

Puedes compartir ficheros entre steps de un mismo job porque esos steps comparten la misma máuina virtual. Pero entre jobs necesitas trasferir ficheros desde una máquina virtual a otra.

Cuando generas un fichero (un artefacto) en un job, puedes subirlo a un almacenamiento temporal compartido, para permitir que otros jobs del mismo flujo tengan acceso a ese artefacto. Para subir y descargar un artefacto hacia/desde ese almacenamiento temporal tienes dos actions predefinidas: upload-artifact and download-artifact.

En nuestro ejemplo, el job "generate_deb_package" genera un paquete debian requerido por "deploy_debian_package". Por eso, en las líneas 122 a 126 "generated_deb_package" sube ese paquete:

En el otro lado "deploy_debian_package" descarga el artefacto salvado en las líneas 132 a 136:



Usando el repositorio de código fuente

Por defecto empiezas cada job con una máquina virtual limpia. Para hacer que tu código fuente se descargue en cada máquina virtual tienes que utilizar una action denominada checkout. En nuestro ejemplo se usa como el primer step del job "tests" (línea 21) para hacer que el código se compile y se pruebe: 

Puedes hacer que se descargue el código de cualquier rama, pero si no especificas ninguna en concreto se descargará el de la rama que haya disparado el evento para arrancar el job. En nuestro ejemplo, esa rama es la de staging.


Ejecutando nuestro flujo

Tienes dos opciones para probar un flujo: puedes provocar el evento configurado para disparar el flujo (en nuestro ejemplo hacer push a la rama de staging) o se puede lanzar el flujo manualmente desde el interfaz web de GitHub (suponiendo que hayas incluido la etiqueta "workflow_dispatch" en tu fichero yml, como te aconsejé).

Para lanzar el flujo manualmente, ve a la pestaña Actions del repositorio y selecciona el flujo que quieres lanzar. Entonces verás el botón "Run workflow" a mano derecha:


Una vez que ya has pulsado el botón, el flujo comenzará y la lista de la pestaña mostrará que el flujo está activo. Si seleccionamos ese flujo en la lista se mostrará un árbol de ejecución como el que mostré antes. Haciendo click en cualquiera de las cajas mostrará los logs generados en tiempo real para cada paso del job. Es extremadamente fácil navegar entre los diferentes logs generados.


Conclusión

La verdad es que he encontrado las GitHub Actions muy agradables de usar. Su enfoque a la reutilización y a compartir las actions hace realmente fácil crear flujos extremadamente complejos, dado que a menos que estés desarrollando flujos realmente raros, lo más probable será que la mayor parte (si no todos) de los componentes que vayas a necesitar ya hayan sido implementados y compartidos por otros, en forma de actions. De esta manera hacer flujos complejos se convierte en una tarea fácil de unir piezas ya disponibles.

La documentación de GitHub Actions es realmente buena y su popularidad facilita encontrar respuestas online para cualquier duda que pudiera surgirte.

Además, la estructura de los fichero yml me ha parecido coherente y lógicas, por lo que es fácil coger los conceptos y alcanzar un buen nivel realmente rápido.

Siendo gratuito e ilimitado para repositorios open source, sospecho que voy a migrar todos mis flujos de CI/CD desde Travis a GitHub Actions.