Gonzalo D'Elia

Introducción a Github Actions

Nota: Es recomendable tener una noción básica de la sintáxis YAML. Si no la usaste, podés mirar este post hasta que yo escriba alguno sobre yaml (?)

En mi trabajo, un compañero de equipo tenía asignado semanalmente subir un archivo de textos a una plataforma de traducciones, por lo que todos los viernes se encargaba de loguearse y subir dicho archivo. Ahora que se fue, y me delegaron la tarea a mi, aproveché la oportunidad para automatizarla. Como el archivo a subir formaba parte de nuestro repositorio de trabajo, era una buena oportunidad para probar las GitHub Actions.

Conociendo las GH Actions

Con GitHub actions podemos configurar tareas, en forma declarativa, a ejecutarse como respuesta a determinados eventos, por ejemplo:

  • Pusheando a un branch
  • Abriendo un PR
  • Cada cierto tiempo (símil cron job)

y muchos otros más.

Dado lo cercanas a nuestros repositorios, es muy común que se utilicen para tareas comunes en PRs como ejecutar los tests, correr validaciones (por ej. Eslint), compilaciones, chequeos de coverage, o incluso deployar en algun ambiente tras mergear.

Cada conjunto de tareas (que puede implicar varios pasos) que se agrupan para reaccionar ante un conjunto de eventos se denominan workflows. Los workflows se ejecutan en un runner, que es un entorno que provee Github (GitHub-hosted). Normalmente, estas máquinas se inicializarán "vacías" (nada mas allá del SO), aunque esto es configurable.

Veamos un ejemplo sencillo. Los worfklows se configuran en yml files bajo la carpeta .github/workflows . El siguiente file se llama ci.yml y se encuentra en dicha carpeta en un proyecto pequeño mio, y lo utilizo para correr los tests cuando abro un PR.

name: CI checks

on:
  pull_request:
    types: [opened, synchronize]

jobs:
  build_check:
    name: Checks typings and building
    runs-on: ubuntu-latest
    env:
      CI: true
    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Use Node.js 12.x
        uses: actions/setup-node@v1
        with:
          node-version: '12.x'
          registry-url: 'https://npm.pkg.github.com'
      
      - name: Install deps
        run: npm ci

      - name: run tests
        run: npm test

Analicemos en mas detalle lo que pasa en cada parte:

name: CI checks

El campo name se utiliza para identificar un workflow, y aparecerá en la seccion de Actions de tu repo.

lista de acciones

on:
  pull_request:
    types: [opened, synchronize]

on es la key que nos permite determinar que eventos causarán la ejecucion de este workflow. La lista es bastante larga, pero en este ejemplo en particular determinamos que queremos ejecutar el workflow solo en los pull_request y en types especificamos opened, es decir, cuando abrimos un PR, y synchronize, o sea, cuando actualicemos un PR (por ej., lo rebaseemos contra el target branch o cuando agreguemos nuevos commits). Otras opciones (dentro de pull_request) son por ejemplo assigned (cuando se le asigna a un usuario), closed (cuando se cierra), labeled (cuando se le agrega un label) entre otras.
También existen otras opciones fuera de pull_request, por ejemplo:

on:
  push:
    branches:
      - main
      - develop

Esta opción nos permite correr acciones al pushear contra el branch main o develop. Podés listar cualquiera de los branches que te interesan.

También se pueden configurar Cron jobs (ejecución periódica) usando la sintaxis de cron POSIX

Por ejemplo:

on:
  schedule:
    # * es un caracter especial en YAML, asique se usa un string
    - cron:  '*/15 * * * *'

ejecuta este workflow cada 15 minutos. Esta fue la opción que configuramos en el caso mencionado el principio, para subir un archivo a una plataforma una vez por semana.

(recomiendo usar alguna página que traduzca el periodo que queremos a estas expresiones, como crontab generator)

Jobs

Dentro de un workflow nos encontramos con el concepto de jobs. Un job es una unidad lógica que agrupa una serie de pasos a ejecutar. Los jobs se pueden ejecutar en paralelo, asi que si definieramos N jobs, se correrían los N en paralelo.

Los jobs como unidad lógica pueden ejecutarse correctamente o fallar. Por ese motivo tienen status de ejecución que indican si se ejecutó exitosamente o si falló algo en el medio. Esto es útil para condicionar acciones mediante los resultados de los jobs, por ejemplo, evitar que un PR se mergee si los tests no pasan.

Veamos como crear un job:

jobs:
  build_check:
    name: Checks typings and building
    runs-on: ubuntu-latest
    env:
      CI: true
    steps: # ver en la sección de steps

Cada key que esté directamente debajo de jobs es un job_id - una etiqueta que identifica de manera única a un job. Tiene que ser única por workflow y sirve si, por ejemplo, tuvieras que usar la API de GitHub para identificar un job. Los identificadores solo pueden usar caracteres alfanuméricos, - o _.

Cada job tiene distintas propiedades:

  • name es para darle un nombre legible y descriptivo al job. Este aparecerá en el listado de jobs al ver una acción

Section actions abierta con el listado de workflows

y también ayudará a identificar los jobs asociados a un PR

PR abierto con el listado de workflows que están corriendo

una vez finalizada la ejecución de los workflow, se verán asi en los PRs

PR abierto con el listado de workflows que ejecutaron correctamente

  • runs-on nos permite determinar en qué tipo de máquina queremos correr nuestro job. Por defecto, GitHub provee runners (que ellos mismos hostean), aunque uno pudiera usar los suyos. Las configuraciones actuales (julio 2020) son
Ambiente YAML value
Windows Server 2019 windows-latest o windows-2019
Ubuntu 20.04 ubuntu-20.04
Ubuntu 18.04 ubuntu-latest o ubuntu-18.04
Ubuntu 16.04 ubuntu16.04
macOS Catalina 10.15 macos-latest o macos-10.15

por las dudas recomiendo la documentación ya que esta tabla en un futuro seguramente esté desactualizada.

  • env nos permite agregar variables de entorno que estarán disponibles en todas los pasos de este job. Dependerá más que nada de qué hagan en dichos jobs.

Aunque no eran necesarias en mi caso, existen otras opciones útiles, como por ejemplo timeout-minutes para indicar el máximo tiempo de ejecución en minutos.

steps

Habíamos mencionado que un job agrupa lógicamente una serie de pasos a ejecutarse en conjunto. Para declarar esas tareas, usamos steps

steps:
  - name: Checkout
    uses: actions/checkout@v2

  - name: Use Node.js 12.x
    uses: actions/setup-node@v1
    with:
      node-version: '12.x'
      registry-url: 'https://npm.pkg.github.com'
      
  - name: Install deps
    run: npm ci

  - name: run tests
    run: npm test

Con steps declaramos una lista que se ejecutará de manera secuencial. Cada ítem de la lista tendrá un name con el cual se lo identificará dentro de un job, y una opción run en la cual se determinará qué comando correr: por ejemplo, si es un proyecto de javascript y usamos npm, podemos usar npm ci para instalar las dependencias en un step, y luego, en el otro, correr los tests con npm test (que es lo que hace el ejemplo más arriba).

Lo interesante acá es que al abrirnos la puerta de ejecución a código, nos da una enorme flexibilidad para realizar un montón de tareas, como compilar nuestro proyecto, hacer deploys a ambientes de prueba, o incluso salidas a producción (por ejemplo al mergear contra nuestro branch productivo).

Debido a eso, es natural que muchas tareas sean repetitivas (por ej., instalar algún programa como node o hacer un checkout de nuestro repositorio en dicha vm), por lo que las acciones se pueden publicar en un repo y reutilizar en varios repositorios (incluso se pueden publicar en el marketplace de GitHub).

GitHub provee algunas acciones comunes, como checkout que permite hacer checkout de nuestro repositorio y que esté disponible en el runner que corre nuestro job.
Otra acción interesante es setup-node, que instala nodejs en nuestro repo, y nos permite correr los comandos de npm que tengamos configurado.

Para usar una action que esté publicada en algún repositorio o en el marketplace, existe el comando uses, cuyo valor es <repo-owner>/<repo-name>@<version, tag o commit sha>. repo-owner se refiere a qué usuario/organización tiene el repositorio, siendo repo-name el nombre del mismo. Para el último valor, veamos unos ejemplos:

steps:    
  # 74bc508 es el hash de un commit especifico
  - uses: actions/setup-node@74bc508
  # refiere al tag/release v1
  - uses: actions/setup-node@v1
  # refiere al tag/release v1.2
  - uses: actions/[email protected]
  # refiere al branch master
  - uses: actions/setup-node@master

De esta manera, podemos especificar a qué versión/branch/commit queremos apuntar. Normalmente la documentación de la action debiera decir qué release es estable; recomendamos usar ese.

Si la acción requiere algún tipo de parámetro (por ejemplo, alguna key) se puede usar la opción with, que permite pasar valores de configuración a la action. Estos valores son parámetros que la acción por dentro obtendrá para realizar alguna acción, pero no confundir con las variables de entorno.

Por ejemplo, en el caso de este step

steps:
  - name: Use Node.js 12.x
    uses: actions/setup-node@v1
    with:
      node-version: '12.x'
      registry-url: 'https://npm.pkg.github.com'

se está usando la acción setup-node en su v1, que si vemos su documentación nos muestra que ofrece los parámetros node-version y registry-url para configurarla.

Volviendo a los comandos run, normalmente recomiendo usar comandos cortos; si un comando se vuelve muy largo es más fácil moverlo a algún archivo js, o incluso bash (cualquiera que sea ejecutable) y correrlo desde ahí.
Si por algún motivo requerimos 2 o 3 comandos juntos, se pueden ejecutar comandos multilínea (con la ayuda del |) de la siguiente manera:

steps:
  - name: Mi comando multiple
    run: |
       npm run build
       npm run publish

Los steps tienen varias opciones interesantes, como working-directory que nos permite especificar en qué directorio se va a correr el comando (útil para mono-repos, por ejemplo)

Resolviendo nuestro caso de uso

Con esta introducción podemos pensar a alto nivel cómo solucionar el problema de periódicamente subir el archivo a la plataforma que me fue heredado. Expandiendo un poquito más en el scope, era para subir archivos de texto a la plataforma OneSky, donde luego se pueden solicitar traducciones y descargarlas. Con esto en mente, a alto nivel podemos pensar algo así:

  1. Creamos un workflow que use el evento schedule, con una expresión POSIX cron para ejecutarse periódicamente.

  2. Creamos un job (llamémosle upload) que corra en ubuntu-latest y tenga una serie de steps.

  3. El primer step va a ser checkoutear nuestro repo, donde se encuentra el file uses: actions/checkout@v2 y luego instalar node con el action hecho mas arriba.

  4. Por último, bien podríamos correr un comando con npm run <mi-comando> y resolver el upload del file en js.

Pensando que podía ser un problema general terminé creando una GitHub action para poder consumirla desde cualquier workflow, a la cual le pasamos las API Keys de la plataforma (y un par de configuraciones más), y se encarga de subir el archivo por nosotros. Esto resulta útil para cualquier proyecto que use OneSky y esté en GitHub. En este caso, la forma de usarla es similar a los anteriores

- name: Subir el archivo a OneSky
  uses: gndelia/[email protected]
  with:
    projectId: 123456
    privateKey: ${{ secrets.PRIVATE_KEY }}
    filepath: path/to/folder/where/the/file/is
    filename: my-file.json
    locale: en-US
    # van mas configuraciones - las saqué por claridad

Esa notación que ven ${{ secrets.PUBLIC_KEY }} permite acceder a los GitHub secrets - básicamente para guardar cosas privadas que normalmente no queremos hardcodeadas (por ejemplo, claves privadas) y hacerlas accesibles a nuestros workflows

Naturalmente, si se puede automatizar la subida de los files, también se puede hacer la descarga una vez que los archivos que subimos fueron traducidos, y aprovechando la API de OneSky se podría crear una acción similar.

Resumen

Las GitHub actions son una manera muy versátil de ejecutar código ante determinados eventos que nosotros podemos definir. Ya sean tareas repetitivas, o para validaciones de nuestro repo, son muy sencillas de configurar y al permitirnos ejecutar código libremente son una herramienta muy potente.