En la lección anterior, aprendimos a extender la funcionalidad de GitHub Actions con las acciones compuestas. En esta, vamos a hacerlo con las acciones escritas en JavaScript.

Al finalizar, sabrá:

  • Qué es una acción JavaScript.

  • Cómo desarrollar una acción JavaScript.

Introducción

Una acción JavaScript (JavaScript action) es aquella que se desarrolla en JavaScript. Más concretamente, con Node.js y, generalmente, TypeScript. En las siguientes URLs, puede encontrar algunos ejemplos de acciones muy sencillas desarrolladas con TypeScript que puede utilizar como punto de partida: https://github.com/siacodelabs/upload-to-skynet y https://github.com/siacodelabs/download-from-skynet. En estos repositorios, encontrará todo lo necesario para implementar una acción, incluidas pruebas de unidad y de integración, así como flujos de trabajo.

Archivo action.yaml

Recordemos que toda acción personalizada dispone de un archivo de metadatos action.yaml. Las propiedades son las mismas que las que vimos con las acciones compuestas, salvo la propiedad runs que vamos a presentar a continuación.

Propiedad runs de una acción JavaScript

Toda acción realiza una operación, esta se indica en la propiedad runs del archivo action.yaml. Esta propiedad contiene un objeto que describe qué archivo debe ejecutarse. Más concretamente, debe contener las siguientes propiedades:

Propiedad Descripción
using Versión de Node.js a usar: node12 o node16.
main Archivo principal como, por ejemplo, index.js o dist/cjs/index.js.

Ejemplo:

runs:
  using: node16
  main: dist/cjs/index.js

Implementación de la acción

A continuación, vamos a presentar los elementos claves de la implementación de una acción JavaScript.

Dependencias de una acción JavaScript

Las acciones JavaScript suelen tener las siguientes dependencias:

  • @actions/core, que permite obtener las entradas, fijar las salidas, etc.

  • @actions/github, a través del cual acceder a los contextos de GitHub Actions.

  • También es posible encontrar otras implementadas por el equipo de Github Actions como, por ejemplo, @actions/io y @actions/http-client.

Se deben importar como sigue:

// ESM
import * as core from "@actions/core"
import * as github from "@actions/github"

// CJS
const core = require("@actions/core")
const github = require("@actions/github")

Archivo principal de la acción

El archivo principal (main file) de la acción, indicado en la propiedad main del archivo action.yaml, debe contener el script que tiene que ejecutar GitHub Actions cada vez que se invoque la acción. He aquí un ejemplo ilustrativo extraído de la acción siacodelabs/download-from-skynet:

import {run} from "./run"

run()

En el módulo ./run, encontramos la función que contiene la lógica de la acción:

import * as core from "@actions/core"
import {SkynetClient} from "@skynetlabs/skynet-nodejs"

/**
 * Runs the action.
 */
export async function run(): Promise<void> {
  try {
    // (1) get parameters
    const portal = core.getInput("portal")
    const skylink = core.getInput("skylink")
    const localPath = core.getInput("path")

    // (2) download
    const skynet = new SkynetClient(portal)
    await skynet.downloadFile(localPath, skylink)
  } catch (err: any) {
    core.setFailed(err)
  }
}

Para facilitar las pruebas, se define la función run(), o la que prefiera, en un archivo distinto al principal. Aunque, como puede observar en muchas acciones disponibles como, por ejemplo, actions/setup-python, puede definirlo todo en el propio archivo principal. Personalmente, prefiero mantener las cosas separadas para facilitar, así, las pruebas de unidad.

Valores de las entradas de la acción

Para acceder al valor de una entrada, usaremos las siguientes funciones del paquete @actions/core:

/**
 * Devuelve el valor de una entrada en texto.
 * 
 * @param name - Nombre de la entrada a devolver.
 * @returns Valor de la entrada.
 */
function getInput(name: string): string

/**
 * Devuelve el valor de una entrada multilínea.
 * 
 * @param name - Nombre de la entrada a devolver.
 * @returns Array con las distintas líneas de la entrada.
 */
function getMultilineInput(name: string): string[]

/**
 * Devuelve el valor de una entrada booleana.
 * 
 * Los siguientes valores se consideran válidos:
 * true, True, TRUE, false, False y FALSE.
 * 
 * @param name - Nombre de la entrada a devolver.
 * @returns Valor como un booleano.
 * @throws {@link TypeError} Si el valor de la entrada no es válido.
 */
function getBooleanInput(name: string): bool

Ejemplo:

const portal = core.getInput("portal")

Valores de las salidas de la acción

Para fijar el valor de una salida, usaremos la función setOutput() del paquete @actions/core:

/**
 * Fija el valor de una salida de la acción.
 * 
 * @param name - Nombre de la salida a fijar.
 * @param value - Valor a fijar.
 */
function setOutput(name: string, value: any): void

Ejemplo:

core.setOutput("skylink", skylink)

Código de salida de la acción

Toda acción devuelve un código de salida (exit code) con el que indica cómo terminó: con éxito o con fallo. De manera predeterminada, la acción devuelve el código de salida cero (0), o sea, todo ha ido bien. Si necesitamos indicar que algo ha ido mal, hay que devolver el código de salida uno, así como un mensaje de error. Para este segundo caso, podemos usar la función setFailed() del paquete @actions/core:

/**
 * Notifica al ejecutor que se ha producido algún
 * tipo de error, el cual se describe con el
 * mensaje dado.
 * 
 * @param message - Mensaje de error.
 */
function setFailed(message: string | Error): void

Ejemplo:

core.setFailed(err)

Impresión de mensajes de una acción

Las acciones JavaScript pueden utilizar varias funciones del paquete @actions/core para mostrar mensajes de log:

/**
 * Muestra un mensaje de información.
 * 
 * @param message - Mensaje de información a mostrar.
 */
function info(message: string): void

/**
 * Muestra un mensaje de depuración.
 *
 * @param message - Mensaje de depuración a mostrar.
 */
function debug(message: string): void

/**
 * Muestra un mensaje de aviso.
 * 
 * @param message - Mensaje de aviso o error a mostrar.
 */
function warning(message: string | Error): void

/**
 * Mensaje de error a mostrar.
 * 
 * @param message - Mensaje de error a mostrar.
 */
function error(message: string | Error): void

Empaquetado de una acción JavaScript

Las acciones JavaScript deben empaquetarse, como mínimo, con los siguientes archivos: action.yaml, package.json, package-lock.json, README.md y el archivo principal, además del contenido de la carpeta node_modules.

Pruebas automatizadas

Las acciones JavaScript deben automatizar sus pruebas de unidad, de integración y sistema. Esto es inherente a todo software que desarrollemos. Puede utilizar cualquier biblioteca de pruebas como, por ejemplo, @akromio/expected, @akromio/doubles y Mocha.

Con las pruebas de integración, es importante tener en cuenta lo siguiente:

  • Las entradas se deben pasar como variables de entorno. Antes de invocar la acción a probar, debemos configurar las variables de entorno relacionadas con las pruebas. Veámoslo mediante un sencillo ejemplo:

    test("if skylink exists, this must be downloaded", async () => {
      // (1) arrange
      const uploadLocalPath = path.join(__dirname, "../../tests/data/hello-world.txt")
      const skynet = new SkynetClient(portal)
      const skylink = await skynet.uploadFile(uploadLocalPath)
    
      process.env.INPUT_PORTAL = portal
      process.env.INPUT_PATH = localPath
      process.env.INPUT_SKYLINK = skylink
    
      // (2) act
      await run()
    
      // (3) assessment
      expected.file(localPath).equalToFile(uploadLocalPath)
    })
    

    Las variables de entorno relacionadas con los valores de las entradas tienen un nombre con el formato INPUT_nombreEntrada. El nombre de la entrada en mayúsculas. En el ejemplo anterior, las entradas son portal, path y skylink, cuyos valores se pasan mediante las variables de entorno INPUT_PORTAL, INPUT_PATH e INPUT_SKYLINK, respectivamente.

  • Recuerde que las acciones tienen un código de salida, el cual puede ser cero o uno. Para comprobar este código de salida, hay que utilizar la propiedad exitCode del objeto process. Ejemplo:

     test("if skylink doesn't exist, process.exitCode must be set to 1", async () => {
      // (1) arrange
      process.env.INPUT_PORTAL = portal
      process.env.INPUT_PATH = localPath
      process.env.INPUT_SKYLINK = "sia://unknown"
    
      // (2) act
      await run()
    
      // (3) assessment
      expected(process.exitCode).equalTo(1)
    })
    

Flujos de trabajo

En una acción JavaScript, las cosas no son muy diferentes a las de una acción compuesta. En primer lugar, hay que probar el código JavaScript mediante pruebas de unidad y, en segundo lugar, la propia acción en su conjunto. En este segundo caso, la acción la invocaremos como hemos visto con las compuestas, mediante ./. Como ejemplo, eche un vistazo a los flujos de trabajo de la acción siacodelabs/download-from-skynet, https://github.com/siacodelabs/download-from-skynet.