27 abril 2023

Cómo crear tus propias imágenes de Docker

 


En un artículo anterior ya vimos las bases de cómo usar imágenes de Docker. Pero en aquel caso usamos imágenes construidas por otros. Eso está muy bien si encuentras la imagen que estás buscando, pero qué pasa si ninguna cubre tus necesidades?

En este artículo te voy a explicar cómo crear tus propias imágenes y cómo subirlas a Docker Hub de manera que puedas descargarlas fácilmente para tus proyectos, así como compartirlas con otros desarrolladores. 


Para cocinar necesitas una receta

Partiendo de que hayas seguido el tutorial que enlazaba al principio, ya tendrás Docker instalado en tu Linux. Con eso, necesitarás una receta para decirle a Docker los pasos para "cocinar" tu imagen. Esa receta es un fichero llamado Dockerfile que hay que crear en la carpeta que más te convenga en la que habrás de incluir cualquier fichero que quiera meter en tu imagen.

¿Qué cocinas con esa receta? una imagen, ¿pero qué es una imagen de Docker?. Una imagen de docker es un sistema operativo virtual con una configuración específica y un conjunto concreto de dependencias instaladas. Cómo utilices esa imagen depende de ti. Algunas imágenes están pensadas para ser usadas como base en las que instalar nuestra aplicación para que se ejecute dentro. Otras imágenes son más específicas y ya traen una aplicación instalada para ser ejecutada en cuanto se arranque la imagen. Haciendo una analogía con la Programación Orientada a Objetos (OOP en inglés), una imagen de Docker sería una clase, mientras que un contenedor sería una instancia de esa clase (también denominada "objeto" en OOP). No es raro encontrar múltiples contenedores ejecutándose simultáneamente después de ser iniciados de la misma imagen.

Para entender cómo funciona un Dockerfile, vamos a analizar algunos ejemplos.

Vamos a empezar con este Dockerfile del  proyecto vdist. La imagen que se crea con ese Dockerfile está pensada para compilar una distribución de Python y ejecutar una aplicación llamada fpm sobre una carpeta creada sobre la marcha cl arrancar el contenedor basado en la imagen. Por lo tanto, este Dockerfile se encarga de instalar todas las dependencias necesarias para que la imagen quede preparada para que sus contenedores puedan compilar la distribución de Python y ejecutar fpm.

El primer paso en todos los Dockerfile es definir qué imagen vamos a usar como punto de partida. Continuando la metáfora de la OOP, si nuestra imagen es una clase, en este punto definimos de qué otra clase hereda la nuestra.

En este caso, nuestra imagen se deriva de la imagen ubuntu:latest, pero para tus imágenes puedes usar cualquier otra imagen disponible en Docker Hub. Sólo asegúrate de echarle un ojo a la página de la imagen en Docker Hub para averiguar cual es la etiqueta que más se ajuste a lo que necesites. Una vez que subas tu imagen a Docker Hub otros podrán usarla como punto de partida para sus respectivas imágenes.

Todos las obras de arte deberían firmarse. A tu imagen le pasa lo mismo, por lo que debemos definir metadatos para hacerlo.

La miga empieza realmente con los comandos RUN. Esos son lo que usas para configurar tu imagen.

Algunas personas que crean una imagen por primera vez confunden lo que hacen realmente los comandos RUN. Estos comandos no se ejecutan cuando se crea un contenedor de una imagen, con el comando "docker run". En vez de eso, se ejecutan sólo una vez por parte del comando "docker build", para crear una imagen de un Dockerfile.

El conjunto exacto de comandos RUN para una imagen dependerá de los que necesite el proyecto respectivo. En este ejemplo, los comando RUN comprueban que la lista de fuentes de apt son las correctas, actualizan la base de datos de apt e instalan un  conjunto de dependencias usando tanto "apt-get" como "gem install". 

Te recomiendo que comiences el apartado de los comandos RUN con un "RUN set -e" de manera que el proceso de construcción finalice con un error si cualquiera de los comandos RUN devuelve un error. Esto puede parecer una medida extrema, pero es la mejor para asegurar que no subas una imagen con un error inadvertido.

Además de eso, cuando revises Dockerfiles de otros proyectos te darás cuenta que muchos de ellos incluyen varios comandos de shell dentro del mismo comando RUN, como pasa en las líneas 14 a 16 de nuestro ejemplo. La gente de Docker recomienda juntar dentro de un mismo comando RUN aquellos comandos de shell que están relacionados entre si (es decir, si dos comandos no tienen sentido separados, siendo ejecutados sin el otro, deberían ser incluidos dentro del mismo comando RUN). Esto se debe a la estructura en capas con la que se construyen las imágenes de Docker, en las que cada comando RUN conforma una capa separada. Si sigues el consejo de la gente de Docker, tus imágenes serán más rápidas de reconstruir cuando realices un cambio sobre su Dockerfile. Para incluir varios comandos de shell en el mismo comando RUN, recuerda separar esos comandos en una línea por cada comando de shell y pon al final de cada línea un "&& \" para encadenar las sucesivas líneas (excepto en la última, como se puede ver en el ejemplo).

Aparte de los comandos RUN, hay otros que deberías conocer. Vamos a analizar el Dockerfile de mi proyecto markdown2man. El propósito de esa imagen es ejecutar un script de Python para hacer que Pandoc realice la conversión de un fichero usando los argumentos pasados por el usuario al arrancar el contenedor con "docker run". Para ello, verás que algunos de los comandos empleados en el Dockerfile ya resultan familiares:


Pero a partir de ahí, aparecen nuevos comando que vuelven interesante la cosa.

Con los comandos ENV puedes crear variables de entorno para ser usadas en los comandos del Dockerfile, de manera que no tengas que repetir una y otra vez las mismas cadenas y además facilites las modificaciones.


Sin embargo, ten en cuenta que las variables de entorno creadas con los comandos ENV van más allá de la fase de construcción y persisten en el contenedor que se cree de esa imagen. Eso puede provocar colisiones con otras variables de entorno creadas al ejecutar el contenedor de la imagen. Si sólo necesitas una variable de entorno durante la fase de construcción y quieres que se borre cuando se ejecute el contenedor, entonces usa el comando ARG en vez de los comandos ENV.

Para incluir los ficheros de tu aplicación dentro de la imagen, usa el comando COPY:


Estos comandos copian un fichero de origen, relativos a la ubicación del Dockerfile, desde el equipo donde estés construyendo la imagen, a una ruta dentro de esa imagen. En este ejemplo, copiamos un fichero requirements.txt, que está ubicado en la misma carpeta que el Dockerfile, hacia una carpeta llamada /script (tal y como se define en la variable de entorno SCRIPT_PATH) dentro de la imagen.

Por último, pero no por ello menos importante, tenemos otro comando nuevo: ENTRYPOINT.


Un ENTRYPOINT define qué comando ejecutar cuando se inicie una contenedor a partir de esta imagen, de manera que los comandos que se pasan a "docker run" en realidad se pasan al comando definido en ENTRYPOINT. El contenedor se mantendrá en marcha hasta que el comando ejecutado en el ENTRYPOINT finalice.

Los ENTRYPOINT son la clave para poder usar contenedores de docker como medio para ejecutar programas sin necesitar de instalarlos en nuestro equipos y "contaminar" nuestro sistema de paquetes con las dependencias de ese programa.

Hora de cocinar

Una vez que nuestra receta esté lista, será hora de cocinar algo con ella.

Cuando nuestro Dockerfile esté listo, será el momento de usar el comando "docker build" para creau una imagen. Partiendo de que estemos en la misma carpeta que nuestro Dockerfile:

dante@Camelot:~/Projects/markdown2man$ docker build -t dantesignal31/markdown2man:latest .
Sending build context to Docker daemon  20.42MB
Step 1/14 : FROM python:3.8
 ---> 67ec76d9f73b
Step 2/14 : LABEL maintainer="dante-signal31 (dante.signal31@gmail.com)"
 ---> Using cache
 ---> ca94c01e56af
Step 3/14 : LABEL description="Image to run markdown2man GitHub Action."
 ---> Using cache
 ---> b749bd5d4bab
Step 4/14 : LABEL homepage="https://github.com/dante-signal31/markdown2man"
 ---> Using cache
 ---> 0869d30775e0
Step 5/14 : RUN set -e
 ---> Using cache
 ---> 381750ae4a4f
Step 6/14 : RUN apt-get update     && apt-get install pandoc -y
 ---> Using cache
 ---> 8538fe6f0c06
Step 7/14 : ENV SCRIPT_PATH /script
 ---> Using cache
 ---> 25b4b27451c6
Step 8/14 : COPY requirements.txt $SCRIPT_PATH/
 ---> Using cache
 ---> 03c97cc6fce4
Step 9/14 : RUN pip install --no-cache-dir -r $SCRIPT_PATH/requirements.txt
 ---> Using cache
 ---> ccb0ee22664d
Step 10/14 : COPY src/lib/* $SCRIPT_PATH/lib/
 ---> d447ceaa00db
Step 11/14 : COPY src/markdown2man.py $SCRIPT_PATH/
 ---> 923dd9c2c1d0
Step 12/14 : RUN chmod 755 $SCRIPT_PATH/markdown2man.py
 ---> Running in 30d8cf7e0586
Removing intermediate container 30d8cf7e0586
 ---> f8386844eab5
Step 13/14 : RUN ln -s $SCRIPT_PATH/markdown2man.py /usr/bin/markdown2man
 ---> Running in aa612bf91a2a
Removing intermediate container aa612bf91a2a
 ---> 40da567a99b9
Step 14/14 : ENTRYPOINT ["markdown2man"]
 ---> Running in aaa4774f9a1a
Removing intermediate container aaa4774f9a1a
 ---> 16baba45e7aa
Successfully built 16baba45e7aa
Successfully tagged dantesignal31/markdown2man:latest

dante@Camelot:~$

Si no estuviésemos en la misma carpeta que en el Dockerfile, deberíamos reemplazar ese "." al final del comando con una ruta hacia la carpeta del Dockerfile.

El  parámetro "-t" se usa para darle un nombre adecuado (también llamado "tag") a nuestra imagen. Si quieres subir tu imagen a Docker Hub te aconsejo que intentes seguir sus convenciones de nombrado. Para que una imagen pueda ser subida a Docker su nombre se debe estructurar de la siguiente forma: <docker-hub-user>/<repository>:<version>. Como puedes ver en el último ejemplo, el parámetro docker-hub-user era dantesignal31 mientras que el repositorio era markdown2man y la versión era latest.

Subir tu imagen a Docker Hub

Si el proceso de construcción finaliza correctamente debería ver cómo se registra tu imagen en el sistema:

dante@Camelot:~/Projects/markdown2man$ docker images
REPOSITORY                   TAG                 IMAGE ID       CREATED          SIZE
dantesignal31/markdown2man   latest              16baba45e7aa   15 minutes ago   1.11GB


dante@Camelot:~$

Pero una imagen que sólo esté disponible localmente tiene una utilidad limitada. Para que esté globalmente disponible deberíassubirla a Docker Hub, pero para hacerlo primero necesitas una cuenta en Docker Hub. El proceso para registrar una nueva cuenta es similar al de cualquier otro servicio online.

Hecho el registro, accede con tu nueva cuenta a Docker Hub. Una vez dentro, crea un nuevo repositorio. Recuerda que, sea cual sea el nombre que le des al repositorio, se le pondrá un prefijo con tu nombre de usuario para conformar el nombre completo del repositorio.


En este ejemplo, el nombre completo del repositorio sería mobythewhale/my-private-repo. A menos que estés usando una cuenta de pago, probablemente seleccionarás un repositorio "Public".

Recuerda nombrar a tu imagen con un nombre de repositorio que concuerde con el que creaste en Docker Hub.

Antes de que puedas subir tu imagen, tendrás que hacer login a Docker Hub desde la consola con "docker login":

dante@Camelot:~/Projects/markdown2man$ docker login
Authenticating with existing credentials...
WARNING! Your password will be stored unencrypted in /home/dante/.docker/config.json.
Configure a credential helper to remove this warning. See
https://docs.docker.com/engine/reference/commandline/login/#credentials-store

Login Succeeded
dante@Camelot:~$

La primera vez que hagas login se te pedirá tu usuario y contraseña.

Una vez logado ya puedes subir tu imagen con "docker push":

dante@Camelot:~/Projects/markdown2man$ docker push dantesignal31/markdown2man:latest
The push refers to repository [docker.io/dantesignal31/markdown2man]
05271d7777b6: Pushed 
67b7e520b6c7: Pushed 
90ccec97ca8c: Pushed 
f8ffd19ea9bf: Pushed 
d637246b9604: Pushed 
16c591e22029: Pushed 
1a4ca4e12765: Pushed 
e9df9d3bdd45: Mounted from library/python 
1271cc224a6b: Mounted from library/python 
740ef99eafe1: Mounted from library/python 
b7b662b31e70: Mounted from library/python 
6f5234c0aacd: Mounted from library/python 
8a5844586fdb: Mounted from library/python 
a4aba4e59b40: Mounted from library/python 
5499f2905579: Mounted from library/python 
a36ba9e322f7: Mounted from library/debian 
latest: digest: sha256:3e2a65f043306cc33e4504a5acb2728f675713c2c24de0b1a4bc970ae79b9ec8 size: 3680

dante@Camelot:~$

A partir de ahí tu imagen estará disponible en Docker Hub y lista para ser usada por cualquiera.

Conclusión

Hemos revisado los fundamentos básicos de como construir una imagen de Docker. A partir de aquí, ya sólo te queda practicar creando imágenes cada vez más complejas, a partir de otras más sencillas. Afortunadamente Docker tiene una gran documentación por lo que debería resultarte fácil resolver cualquier problema con el que puedas encontrarte.

Comprendido lo básico, puedes sacarle mucho partido a herramientas como Docker Desktop que pueden simplificarte enormemente el proceso.

06 abril 2023

Cómo crear tus propias Actions para GitHub Actions

En  mi artículo sobre GitHub Actions vimos que se pueden crear flujos de trabajo completo simplemente usando unidades ya hechas, denominadas Actions, que se pueden encontrar en el GitHub Marketplace

En ese marketplace puedes encontrar muchísimas Actions listas para ser usadas, pero probablemente en algún momento te veas en la necesidad de hacer algo para lo que no haya aún Action alguna en el marketplace. En esos caso podrías usar tus propios scripts. De hecho, en aquel artículo ya utilicé un script (./ci_scripts/get_version.py) en la sección "Compartir datos entre pasos y flujos". El problema con ese enfoque es que si quieres hacer las mismas tareas en otro proyecto tendrás que copiar los scripts entre proyectos y adaptarlo. Lo suyo en realidad es transformar los scripts en Actios que puedan ser fácilmente reutilizables no sólo en tus proyectos sino también en los de otros desarrolladores.

Todas las Actions que se pueden encontrar en el marketplace se han desarrollado usando uno de los métodos que voy a explicar aquí. En realidad, si entrar en la página del marketplace de cualquier Action encontrarás un enlace, a mano derecha, al repositorio de git de esa Action, por lo que puedes analizar su código y ver cómo funciona.

Hay tres métodos principales para crear tu propio Action para GitHub:

  • Composite Actions: Son las más sencillas y rápidas de desarrollar, pero tienen que cumplir la condición de que se basen en un script autosuficiente que no requiera que se instalen dependencias adicionales. Es decir, deben ser capaces de funcionar con lo que un Linux estándar ofrezca de base.
  • Docker Actions: Si tu Action va a necesitar la instalación de alguna dependencia para funcionar, entonces tendrás que usar esta opción.
  • Javascript Actions: Bueno... es cierto que también puedes desarrollar tus Actions usando javascript, pero aborrezco ese lenguaje, así que no voy a desarrollar esta opción en este artículo.
El problema con las dependencias de las Actions es que pueden contaminar el entorno donde se vaya a usar la Action. Si no tienes cuidado, y optas por una Composite Action instalando dependencias, estas pueden colisionar con la de la app que quieras compilar con la Action. Esa es la razón por la que resulta imprescindible encapsular nuestra Action y sus dependencias para que sean independientes del entorno en el que compilemos la app. Por supuesto, esto no será un problema si el propósito explícito de la Action es precisamente instalar algo para preparar el entorno. Hay Actions, por ejemplo, para instalar y configurar Pandoc para usarlo en el workflow. El problema surge cuando se supone que el propósito explícito de tu Action es algo ajeno a la instalación de paquete alguno (por ejemplo, copiando ficheros) y sin embargo acaba instalando algo de manera inadvertida comprometiendo el entorno del flujo de trabajo. Por eso, lo mejor es que cuando necesites instalar algo para tu Action lo hagas en un contenedor de Docker y hagas que el script de tu Action se ejecute en ese contenedor de Docker, de manera que sus dependencias sean completamente independientes de los paquetes instalados en el entorno del flujo.  

Composite Actions

Si a tu Action le basta con un puñado de comandos de Bash o con un script de Python que utilice exclusivamente la librería estándar, entonces las Composite Actions son tu opción.

Como ejemplo de una Composite Action, vamos a revisar cómo funciona mi Action rust-app-version. Esa Action busca un fichero de configuración Cargo.toml y lee qué versión consta en él para la aplicación de Rust. La cadena de esa versión es precisamente la salida del Action, de manera que puedas usar esa cadena en otros puntos de tu flujo, por ejemplo para etiquetar una nueva release en GitHub. Esta Action en concreto sólo usa módulos disponibles en la distribución estándar de Python. Es decir, que no tiene que instalar nada en la máquina virtual de GitHub Actions (el runner). Es cierto que si analizas el repositorio de rust-app-version verás un fichero requierements.txt pero este sólo es para instalar paquetes necesarios para el testeo unitario de la Action (recuerda que una Action no deja de ser una app más).

Para tener tu propia Action, lo primero es crear un repositorio de GitHub para albergarlo. Allí podrás ubicar los poquitos ficheros que se necesitan en realidad para hacer funcionar tu Action.

En la raíz del repositorio debes crear un fichero que se llame "action.yml". Este fichero es realmente importante ya que es el que le da forma a tu Action. Debes conseguir que tus usuarios puedan pensar en tu Action como si fuera una caja negra, como si fuera un módulo en el que metes entradas (inputs) y recibes salidas (outputs). Precisamente esos inputs y outputs se definen en el fichero action.yml.

Si lees el fichero action.yml file de rust-app-version verás que esa Action sólo necesita un input denominado "cargo_toml_folder" y que en realidad el input es opcional, ya que si lo dejamos vacío asumirá el valor de ".":



Los outputs son algo diferentes, dado a que deben referirse al output de un caso específico de tu Action:


En la última sección especificamos que esta Action tendrá un único output llamado "app_version" y que ese output será el output llamado "version" de un paso con el valor de id "get-version".

Esos inputs y outputs definen lo que tu Action consume y ofrece, es decir, lo que tu Action hace. El cómo lo hace tu Action se define bajo la etiqueta "runs:". Ahí es donde defines que tu Action es una composite, en la que vas a llamar a una secuencia de pasos. Este ejemplo en particular sólo tiene un paso, pero puedes tener todos los que necesites:



Fíjate que en la línea 22 es donde el paso recibe el id "get-version". Ese nombre es importante para poder referirse a este paso en particular desde la configuración de los outputs.

La línea 24 es donde se ejecuta el comando. Aquí sólo he ejecutado un comando, pero si necesitases ejecutar varios comandos dentro del mismo paso bastaría con usar una barra después de la etiqueta run: "run: |". De esa manera, señalas que las siguientes líneas, que se indenten bajo la etiqueta "run:", son líneas separadas de comandos que deben ser ejecutadas secuencialmente.

El comando de la línea 24 también es interesante por 3 cosas:
  • Llama a un script situado en el repositorio de nuestro Action. Para referirse a la raíz del repositorio del Action hay que usar la variable de entorno github.action_path. Lo bueno es que aunque nuestro script esté en el repositorio del Action GitHub lo ejecuta de manera que tenga visibilidad del repositorio del flujo de trabajo desde el que se llama a la Action. Nuestro script (el del Action) verá los ficheros del repositorio del flujo como si hubiese sido ejecutado desde su raíz.
  • Al final de la línea se puede ver cómo se pueden utilizar los inputs, a través del contexto inputs.
  • Lo más raro de esa línea es probablemente como configuras el output del paso. Para fijar el output de un paso de bash debes hacer echo "::set-output name=<ouput_name>::<output_value>". En este caso, name es version y su valor es lo que  get_version.py imprime en la consola. Ten en cuenta que output_name se usa para recuperar el output, una vez que el paso finaliza, mediante ${{ steps.<id>.outputs.<output_name> }}, en este caso ${{ steps.get_version.outputs.version }}
Aparte de eso, no hay más que configurar los metadatos del Action. Para eso, no se necesitan más que unas pocas líneas:


Ten en cuenta que el valor de "name:" es el nombre que tendrá tu Action en el marketplace. El otro parámetro "description:" es la explicación corta que se mostrará junto al nombre en los resultados de las búsquedas del marketplace. Por último, la etiqueta "branding:" sirve para configurar el icono (de los disponibles en la suite Feather icon) y el color de este que representarán a nuestra Action en el marketplace.

Con esas 24 líneas en el fichero action.yml y tu script en su ruta respectiva ( en este caso la subcarpeta 
 rust_app_version/ ), ya puedes usar tu Action. Sólo necesitas pulsar el botón que aparecerá en tu repositorio para publicar el Action en el marketplace. De todos modos, antes de lanzarte a hacerlo te recomiendo que te leas este artículo hasta el final porque tengo algunos consejos que sospecho que te serán útiles.

Una vez publicada, tu Action ya será visible para el resto de los usuarios de GitHub. Además, se creará automáticamente la página de tu Action en el marketplace. Para usar una Action como esta lo único que tienes que hacer es incluir en tu flujo una configuración como la siguiente:



Docker actions

Si tu Action necesitase instalar alguna dependencia entonces deberías empaquetarla dentro de un contenedor de docker. De esa manera las dependencias de la Action no se mezclarán con las dependencias del flujo desde el que se llame a la Action.

Como ejemplo de una Docker Action, vamos a revisar cómo funciona mi Action markdown2man. Esta Action coge un fichero README.md y lo convierte en un manpage. Con esta Action no necesitas mantener dos fuentes diferentes para la documentación de cómo usar tu aplicación de consola. En vez de eso, puedes usar un único fichero README.md para documentar el uso de tu aplicación y generar automáticamente a partir de él el manpage.

Para hacer esa conversión markdwon2man necesita que se instale el paquete Pandoc. Además, Pandoc tiene también sus respectivas dependencias por lo que instalar todo eso en el runner del usuario puede romper su flujo de trabajo. En vez de eso, lo que haremos será instalar esas dependencias en una imagen de docker y ejecutar nuestro script desde esa imagen. Recuerda que docker te permite ejecutar scripts desde el contenedor interactuando con los ficheros del anfitrión del contenedor.

Igual que en el caso de las Composite Action, tendremos que crear un fichero action.yml en la raiz del repositorio del Action. Allí configuraremos nuestros metadatos, los inputs y los outputs tal y como hicimos para las Composite Actions. La diferencia en este caso es que concretamente markdown2man Action no emite output alguno, por lo que esa sección se omite. La sección para "runs:" también es diferente:


En esa sección definimos que esta Action es del tipo docker (en "using:"). Hay dos maneras de usar una imagen de docker en tu Action: 
  • Generar una imagen específicamente para esa Action y guardarla en el registro de docker de GitHub. En ese caso usaríamos la etiqueta "image: Dockerfile".
  • Usar una imagen del registro de DockerHub registry. Para hacer eso hay que usar la etiqueta "image: <dockerhub_user>:<docker-image-tag>".
Si la imagen que vamos a construir estuviera pensada para ser usada sólo en una Action de GitHub yo iría por el primer enfoque (el del Dockerfile). En el caso de markdwon2man elegí la opción del Dockerfile. Cada vez que actualicemos nuestro Dockerfile, GitHub se encargará de de construir una nueva imagen y guardarla en su registro para poder ofrecerla rápidamente a los Actions que hagan uso de esa imagen. Recuerda que un Dockerfile es una especie de receta para "cocinar" una imagen, por lo que los comandos que se incluyan en él solo se ejecutarán cuando se construya la imagen (cuando se "cocine"). Una vez construida la imagen, el único comando que se ejecutará es el que fijemos en la etiqueta "entrypoint" al llamar al contenedor (y pasarle argumentos) con el comando "docker run".

La etiqueta "args:" define los parámetros que se pasarán al script de nuestro contenedor. Probablemente uses alguno de los input de tu Action para pasárselo como parámetro al script del contenedor. Ten en cuenta que, como pasaba con las Composite Actions, los ficheros del repositorio del usuario que llama al Action también son visibles para el contenedor de dicho Action.

Al tener que definir un Dockerfile, las Docker Actions pueden parecer más complejas que las Composite Actions. Sin embargo, el fichero Dockerfile de markdown2man es bastante sencillo. Como nuestro script markdown2man está escrito en python, hice que la imagen se basase en la imagen de docker oficial para python 3.8:



Después, fijé los metadatos de la imagen:


Para configurar tu image, por ejemplo para instalar cosas, puedes usar los comandos RUN:


El comando ENV genera variables de entorno que pueden ser utilizados en otros comandos del Dockerfile:


Puedes usar el comando COPY para introducir ficheros de tu repositorio en la imagen. En este caso lo utilicé para copiar el fichero requirements.txt desde el repositorio a dentro de la imagen. Tus scripts se pueden incluir dentro de imagen, desde el repositorio del Action, de la misma manera:


Una vez que copié el script dentro de la imagen, lo hice ejecutable y lo enlacé desde la carpeta /usr/bin para incluirlo en el path del sistema:


Después de eso, se configura el script como el entrypoint de la imagen, de manera que se ejecute en cuanto se arranque la imagen con los argumentos mencionados en la etiqueta "args:" del fichero action.yml.



Puedes probar esa imagen en tu ordenador construyendo la imagen desde el Dockerfile y ejecutándola como un contenedor:

dante@Camelot:~/$ docker run -ti -v ~/your_project/:/work/ dantesignal31/markdown2man:latest /work/README.md mancifra


dante@Camelot:~$

Si el script del contenedor procesa ficheros del repositorio, nos vendrá bien probarlo localmente montando una carpeta de nuestro ordenador con el flag -v. Los dos últimos argumentos del ejemplo  (work/README.md y mancifra) son los argumentos que se le pasan al entrypoint.

Y eso es todo. Una vez que lo hayas probado todo ya puedes publicar tu Action y usarla en tus flujos:


Con una llamada como esa, se creará un fichero man llamado cifra.2.gz en una carpeta llamada man. Si la carpeta manpage_folder no existiese, entonces markdown2man la crearía para ti.

Tus Action son código de primera clase

Aunque tus Actions probablemente sean pequeñas, deberías tratarlas como lo harías con una app completa. Ten en cuenta que mucha gente encontrará tus Action a través del marketplace y los incluirá en sus flujos. Un error en tu Action puede romper los flujos de mucha gente, por eso hay que ser diligente y probar tu Action como harías con cualquier otra app.

Por eso, con mis Actions sigo la misma aproximación que en otros proyectos y configuro un flujo de GitHub para ejecutar tests contra cualquier push en la rama staging del repositorio de mi Action. Solo cuando los tests se ejecutan con éxito, se produce el merge automático de la rama staging con la principal y se genera una nueva release de la Action.

Vamos a usar el flujo de markdown2man como ejemplo. En él puedes ver que hay dos tipos de tests:

  • Tests unitarios: Comprueban el script de python en el que se basa markdown2man.

  • Tests de integración: Comprueban el comportamiento de markdown2man como Action. Aunque tu Action no se haya publicado aún, la puedes instalar desde un flujo del mismo repositorio (como puedes ver en las líneas 42 a 48). Así que lo que yo hago es llamar al Action precisamente desde la rama staging que estoy probando y uso ese Action con un fichero markdown que tengo preparado en la carpeta de test. si se genera un fichero manpage, entonces el test funcional tiene éxito (línea 53). Tener la oportunidad de probar tu Action contra tu propio repositorio está muy bien, ya que te permite comprobar cómo utilizaría la gente tu Action, sin necesidad de publicarlo.

Además de probarlo, deberías escribir un fichero README.md para tu Action, con el fin de explicar con detalle como usarlo. En ese documento deberían incluir al menos la siguiente información: 
  • Una descripción de los que hace el Action.
  • Los input y outputs obligatorios.
  • Los inputs y outputs opcionales.
  • Cualquier contraseña que el Action pudiera necesitar que se le facilite.
  • Que variables de entorno utiliza el Action.
  • Un ejemplo de cómo usar tu Action en un flujo.
Por último, yo añadiría también un fichero LICENSE, explicando los términos legales de uso de tu Action.

Conclusión

El punto fuerte de GitHub Actions es el alto grado de reusabilidad y compartición que promueve. Cada vez que te encuentres repitiendo el mismo conjunto de comandos, será el momento de hacer un Action con esos comandos y compartirlos a través del Marketplace. Si lo haces así obtendrás una pieza de funcionalidad que será mucho más fácil de usar en tus flujos que tirando de copia-pegas de montones de comandos y además estarás contribuyendo a mejorar el marketplace permitiendo que otros se puedan beneficiar del Action que hayas desarrollado.

Gracias a esa filosofía, el GitHub Marketplace ha crecido hasta hospedar una cantidad enorme de Actions, listos para ser usados y para salvarte de implementar una y otra vez la misma funcionalidad por ti mismo.