Este artículo es una introducción a las oportunidades que ofrece GitHub Actions para ejecutar código en la nube. El objetivo que queremos lograr es compartir automáticamente un tweet cada vez que se publique un nuevo artículo en nuestro blog personal. Es verdad que compartir un tweet no es algo difícil, pero si el editor de un blog puede ahorrarse un par de minutos automatizando esta tarea, vale la pena aprender sobre las tecnologías que nos permiten hacerlo.

Requisitos

Evidentemente debe existir un blog cuyas actualizaciones deseamos compartir; particularmente, el blog debe poseer un feed RSS con la estructura sugerida por el World Wide Web Consortium (W3C). Si usted utiliza un servicio como WordPress, o un generador como Hugo, seguramente no tiene que preocuparse por la estructura del feed, solo debe encontrar su dirección. Yo utilizaré el feed de este mismo blog. El otro requisito es poseer un perfil de Twitter y de GitHub.

Acceso a Twitter Developer

Un perfil de Twitter no basta para automatizar la generación de tweets; es necesario obtener acceso a un perfil de Twitter Developer, que ofrece una API gratuita. Para solicitar acceso ingresaremos al Developer Portal, donde se nos pedirá explicar el uso que daremos a la API; en este punto podemos seleccionar que crearemos un bot para que genere tweets en nuestro nombre:

En los siguientes puntos se nos pedirá más información sobre nuestra solicitud. Explicaremos (en inglés) que crearemos una aplicación para compartir nuevos artículos de un blog. Por cierto, con la API también tendremos acceso a tweets o timelines de usuarios de Twitter así que, si esa aplicación nos interesa, no es mala idea incluirla en nuestra solicitud.

Cuando la solicitud haya sido aprobada recibiremos un correo de confirmación; entonces vamos a regresar al Developer Portal, seleccionar el menú Projects & Apps y el botón Create App. No hay que crear un proyecto, solo una aplicación estándar, porque necesitamos la versión 1.1 de la API.

El siguiente punto es nombrar la aplicación; el nombre no es importante, pero no debe haber sido utilizado por ningún otro usuario. A continuación aparecerán en pantalla dos valores: API Key y API Secret Key. Estas claves otorgan acceso a la API en nuestro nombre, así que las copiaremos en un lugar seguro. Despúes, en la pestaña Settings, modificaremos los permisos de la aplicación para que pueda crear tweets:

Finalmente, en la pestaña Keys and tokens, seleccionaremos el botón Generate ubicado al lado de Access Token and Secret. Aparecerán el Access Token y el Access Token Secret; en total son cuatro claves secretas que debemos conservar para que la aplicación funcione. Si alguna vez las extraviamos, siempre podemos regresar al Developer Portal, revocar y regenerar estos valores.

Nuevo repositorio de GitHub

GitHub Actions es un servicio diseñado para realizar Continuous Integration y Deployment (CI/CD), es decir, para construir, probar y entregar software a través de “acciones”. Una acción es un paquete de código capaz de ejecutar una tarea que deseamos automatizar. GitHub Actions también permite ejecutar acciones que no tengan nada que ver con CI/CD; en el GitHub Marketplace existen muchas acciones contribuidas por la comunidad, y cualquier usuario puede escribir sus propias acciones.

Las acciones son controladas utilizando archivos denominados workflows, que describen cuándo y cómo se ejecutarán las acciones. Un workflow debe ser escrito en el lenguaje de serialización YAML (con extensión .yml) y ubicado en el directorio .github/workflows/ de un repositorio alojado en GitHub. Si el repositorio es público, obtendremos un tiempo ilimitado de ejecución en la nube; si es privado, obtendremos una cuota mensual.

Independientemente de que el repo sea público o privado, hay que suministrar las claves secretas de nuestra aplicación de Twitter. Escribir las claves en el workflow no es un método seguro; en vez de eso vamos a crear un nuevo repositorio, seleccionar la pestaña Settings y el menú Secrets:

Con el botón New repository secret podemos crear variables encriptadas que solamente pueden ser leídas por una acción. Crearemos cuatro variables, una por cada clave de la aplicación; los nombres de las variables serán TWITTER_API_KEY, TWITTER_API_KEY_SECRET, TWITTER_ACCESS_TOKEN y TWITTER_ACCESS_TOKEN_SECRET respectivamente. Ahora estamos listos para escribir un workflow que genere tweets, y lo haremos paso a paso.

Utilizar una acción de GitHub

Si buscamos en el GitHub Marketplace encontraremos algunas acciones para crear tweets. En este tutorial utilizaremos Send Tweet Action, escrita por Edward Thomson. El siguiente código YAML representa el workflow más simple (solo contiene elementos imprescindibles) que permite ejecutar esa acción; podemos insertar el código en un archivo .github/workflows/my_first_workflow.yml de nuestro repositorio.

on:
  schedule:
    - cron:  '0 12 * * *'

jobs:
  tweet:
    runs-on: ubuntu-latest
    steps:
      - uses: ethomson/send-tweet-action@v1.0.0
        with:
          status: 'My first #GitHubAction tweet'
          consumer-key: ${{ secrets.TWITTER_API_KEY }}
          consumer-secret: ${{ secrets.TWITTER_API_KEY_SECRET }}
          access-token: ${{ secrets.TWITTER_ACCESS_TOKEN }}
          access-token-secret: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }}

El elemento on:

A continuación explicaremos uno por uno los elementos que forman el workflow. El primero, on:, especifica cuáles eventos pueden desencadenar la ejecución del workflow. Eventos soportados por GitHub incluyen la actualización del repositorio vía push, la creación de pull requests o issues, entre otros. El evento que estamos utilizando, schedule:, programa la ejecución a determinadas horas, definidas con una sintaxis cron.

En el código de arriba, la sintaxis '0 12 * * *' significa ejecutar todos los días a las 12:00 UTC. El uso de UTC es una buena práctica al diseñar un servidor, así que uno debe calcular el equivalente en su propio huso horario. Esto es importante pues será la hora aproximada de creación del tweet; y es aproximada pues hay que considerar la carga del servicio en ese momento y el tiempo de ejecución del workflow. En este sentido, es aconsejable elegir horas que no sean en punto, por ejemplo las 11:51 UTC especificando '51 11 * * *'. La sintaxis cron permite otros tipos de programación (como semanal o mensualmente) y es fácil de entender; para aprenderla puede visitar el sitio crontab.guru.

El elemento jobs:

El segundo elemento, jobs:, contiene las tareas que el workflow debe realizar. Si son varias, las tareas pueden correr en paralelo y en diferentes runners, es decir en diferentes tipos de servidor. GitHub ofrece servidores con los sistemas operativos Ubuntu, Windows Server y macOS. Nuestro workflow contiene una sola tarea denominada tweet: que correrá en la última versión LTS de Ubuntu (runs-on: ubuntu-latest), la 20.04.2 actualmente.

El elemento steps:

El elemento steps: se encuentra dentro de jobs: y es la lista de pasos que conforman la tarea actual. Existen dos tipos de pasos: run: permite ejecutar comandos o programas en la terminal del runner (bash en el caso de Ubuntu), y uses: permite ejecutar cualquier acción disponible en un repositorio de GitHub. La tarea tweet: de nuestro workflow contiene un solo paso uses: donde utilizamos Send Tweet Action; la sintaxis para utilizar una acción es user/repo@version.

Algunas acciones necesitan inputs para funcionar y deben ser ingresados como subelementos de with:. La acción que estamos utilizando necesita cinco inputs correspondientes al contenido del tweet (status:) y a las cuatro claves que guardamos anteriormente como variables encriptadas. La sintaxis para acceder a estas variables es ${{ secrets.VARIABLE }}; las llaves dobles sirven para acceder a entornos, funciones y variables en GitHub Actions.

En este punto, si añadimos my_first_workflow.yml a nuestro repo, automáticamente generará un tweet todos los días, pero el contenido será siempre el mismo (el que definimos en status:). Para modificar el contenido, con la dirección de un nuevo artículo de nuestro blog, debemos añadir más pasos a la tarea tweet:.

Insertar pasos en la acción

En GitHub Actions, todos los runners poseen varios programas y herramientas útiles ya instalados. Entre los programas incluidos actualmente en nuestro runner ubuntu-latest está R versión 4.1.0, así que podré escribir un script en este lenguaje para ejecutar las tareas que faltan en el workflow. Debo mencionar que, para que el script funcione, el runner también debe tener curl o libcurl4 instalado (actualmente ambos lo están).

Para obtener el feed y el enlace a un nuevo artículo utilizaré el paquete xml2. El problema es que, dentro del runner, uno no tiene acceso al directorio donde R normalmente instala un nuevo paquete. Una solución es aprovechar acciones ya existentes (r-lib/actions) para crear un entorno R funcional. Esa es la solución recomendada pero, como solo necesito un paquete, voy a hacer algo más simple: instalaré xml2 en un directorio arbitrario y crearé un cache para reusarlo cuando sea necesario. Vamos a modificar my_first_workflow.yml: conservaremos los elementos on: y jobs: pero después de steps: insertaremos la acción Cache de la siguiente manera:

      - id: rcache
        uses: actions/cache@v2.0.0
        with:
          path: rlib
          key: ${{ runner.os }}-Rcache

Este paso, la próxima vez que se ejecute el workflow, guardará el contenido del directorio rlib en un cache con la clave Linux-Rcache. Mientras no cambie esta clave, las ejecuciones subsecuentes automáticamente restaurarán el contenido de rlib; el siguiente paso consiste en instalar xml2 en este directorio:

      - if: steps.rcache.outputs.cache-hit != 'true'
        run: |
          dir.create("rlib")
          install.packages("xml2", lib = "rlib")
        shell: Rscript {0}

El elemento if: se encarga de que este paso se ejecute solamente si el cache no existe. En nuestro caso solo ahorraremos unos segundos, pero este es un procedimiento útil cuando hay más paquetes; recordemos que en sistemas Linux la instalación demora más porque se hace con el código fuente. Por otra parte, todas las líneas contenidas entre run: | y shell: Rscript {0} serán ejecutadas a través de Rscript, la herramienta que permite ejecutar código R desde la terminal. Esta sintaxis es útil, pues permite ejecutar código multilínea con bash u otro programa.

Ahora vamos a diseñar el script. Dos variables character deberán estar definidas: feed_url (la dirección URL del feed) y twt_head (un mensaje para iniciar cada tweet). De xml2 utilizaremos read_xml() para obtener el documento que representa al feed, y xml_find_all() para extraer ciertos nodos de ese documento.

.libPaths("rlib"); message("Using ", R.version.string)
## Using R version 4.1.0 (2021-05-18)
library("xml2"); message(" & xml2 version ", packageVersion("xml2"))
##  & xml2 version 1.3.2
items = read_xml(feed_url)
items_dates = xml_find_all(items, "channel/item/pubDate") |> xml_text() |>
   as.POSIXct(tryFormats = paste0("%a, %d %b %Y", c("", " %T", " %T %z")))
(new_items = which(items_dates < Sys.time() & items_dates > Sys.time() - 86400))
## [1] 1

En esta primera parte hemos extraído las fechas de publicación (los nodos pubDate) de todos los artículos presentes en el feed; luego hemos filtrado aquellas que han ocurrido en el último día. Esto lo hacemos porque el workflow fue configurado para ser ejecutado diariamente; aunque la hora de ejecución no será exactamente la misma siempre, este método funciona (siempre y cuando no se publique a la hora escogida en el workflow).

En la segunda parte del script se crea el contenido del tweet. Desafortunadamente no pude resolver cómo compartir varios tweets con una sola ejecución del workflow así que, si varios artículos fueron publicados en el último día, solo se compartirá el primero que se encuentre. Noten que, en la línea donde defino twt_lim, limito la longitud del tweet a 240 caracteres, incluyendo twt_head y la dirección del artículo (pero no incluye los saltos de línea).

if (length(new_items) == 0) message("NO NEW POSTS") else for (x in new_items) {
   twt_url = xml_find_all(items, sub("X", x, "channel/item[X]/link")) |> xml_text()
   if (x != new_items[1]) message("IGNORE POST: ", twt_url) else {
      message("TWEET POST: ", twt_url)
      twt = xml_find_all(items, sub("X", x, "channel/item[X]/description")) |>xml_text()
      twt_lim = 240 - nchar(twt_head) - nchar(twt_url)
      while (nchar(twt) > twt_lim) twt = sub("\\s+\\S*$", "...", twt)
      c("TWEET=$(cat << EOF", twt_head, twt, twt_url, "EOF", ")",
         paste("echo ", c("TWEET<<EOF", "$TWEET", "EOF"), " >> $GITHUB_ENV", sep = "\"")
      ) |> paste(collapse = "\n") |> system()
   }
}
## TWEET POST: https://ruevko.github.io/hexagonal/post/2021/07/30-primer-github-action/

El script debe ingresar al workflow como un nuevo paso, usando la sintaxis shell: Rscript {0} que ya discutimos. Ahora, enviar el contenido del tweet al siguiente y último paso no es algo que se pueda hacer directamente, porque cada paso del workflow es una nueva instancia de la terminal. La manera de hacerlo es utilizar el entorno $GITHUB_ENV que existe para este propósito (precisamente eso hacemos al final del script). Por lo tanto debemos modificar el último paso, Send Tweet Action, para que busque el tweet en dicho entorno:

      - if: ${{ env.TWEET != '' }}
        uses: ethomson/send-tweet-action@v1.0.0
        with:
          status: ${{ env.TWEET }}
          consumer-key: ${{ secrets.TWITTER_API_KEY }}
          consumer-secret: ${{ secrets.TWITTER_API_KEY_SECRET }}
          access-token: ${{ secrets.TWITTER_ACCESS_TOKEN }}
          access-token-secret: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }}

¡Eso es todo! He publicado mi primer workflow para demostrar como deben quedar todos los pasos juntos. Es importante respetar la indentación, porque en YAML es la manera de indicar la jerarquía de los elementos. Solo falta que añada el workflow a su propio repositorio y comenzará a ejecutarse.

El resultado

En la pestaña Actions de nuestro repositorio se encuentran los resultados para cada ejecución de my_first_workflow.yml. Una ejecución exitosa luce más o menos así:

Y así luce el tweet que generó:

Para finalizar hay algunas observaciones sobre el evento schedule: que debo añadir:

  • En la pestaña Actions es posible desactivar la ejecución automática del workflow.
  • Según la documentación de GitHub Actions, si un repositorio público lleva más de 60 días sin actividad, los eventos schedule: son desactivados automáticamente.
  • En un blog como el mío, donde publico una vez al mes o menos, es un desperdicio ejecutar a diario. Es más razonable definir, por ejemplo, cron: '0 12 * * 6' (cada sábado al mediodía) pero hay que ajustar el script como corresponda (múltiplicar 86400 por 7 en este caso).
  • Si el intervalo definido en cron: es mayor a una semana, ya no tiene sentido crear un cache, dado que ese es el máximo tiempo que perduran.