Mi primer GitHub Action
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
= read_xml(feed_url)
items = xml_find_all(items, "channel/item/pubDate") |> xml_text() |>
items_dates 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) {
= xml_find_all(items, sub("X", x, "channel/item[X]/link")) |> xml_text()
twt_url if (x != new_items[1]) message("IGNORE POST: ", twt_url) else {
message("TWEET POST: ", twt_url)
= xml_find_all(items, sub("X", x, "channel/item[X]/description")) |>xml_text()
twt = 240 - nchar(twt_head) - nchar(twt_url)
twt_lim 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ó:
Nuevo artículo en mi blog:
— Rafael Castro (@ruevko) August 1, 2021
GitHub Actions es un servicio que permite automatizar tareas de CI/CD. En este tutorial, sin embargo, lo utilizaré para compartir un tweet...https://t.co/mnKo6lfJhG
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.