Github Actions permiten la creación, automatización y ejecución de flujos de trabajo en un repositorio Github, respetando los principios de Integración Continua para proveer todo lo necesario para probar e integrar cambios desarrollados sobre un proyecto.
La manera de crear estos flujos de trabajo o workflows a través de Github Actions es usando archivos YAML dentro del directorio .github/workflows. Los diferentes eventos dentro del repositorio son los que activan la ejecución de un workflow dado.
Cada workflow está compuesto de jobs, cada job corre concurrentemente y representa una parte diferente del flujo. Por ejemplo, lo recomendado sería crear un job para lanzas las pruebas, uno para lanzar el software y otro para desplegar el ambiente de producción. Los jobs se pueden configurar para que dependan del éxito o fallo de otros jobs.
Cada Job contiene una lista de pasos o steps, que se ejecutan secuencialmente. Un step puede ser una serie de comandos o acciones que están pre-construidas. Algunas acciones están disponibles gracias al equipo de GitHub, mientras que la comunidad tiene otras en formato open-source.
Entonces, como vimos en el apartado anterior: Workflow > Job > Step. Vamos a diseñar nuestro primer Workflow en un repositorio de prueba en un archivo llamado mi-primer-workflow.yml y lo pondremos en el directorio .github/workflows.
La idea es que este workflow se ejecute cuando hagamos un push sobre la rama master, la consola de Github Actions nos muestre un clásico mensaje de "Hola mundo". Empecemos entonces con la estructura inicial:
name: Mi primer workflow
on:
push:
branches: [ master ]
El nombre que definimos es el que aparecerá y la instrucción on
se emplea como el desencadenante (o trigger en inglés) del workflow. En este caso un push.
La palabra clave branches
permite especificar la rama o ramas específicas donde se espera que el evento ocurra.
Ahora vamos a crear el job inicial que va dentro de este workflow:
name: Hola mundo
on:
push:
branches: [ master ]
jobs:
Mi-primer-job:
name: Mi primer job
runs-on: ubuntu-latest
Bien, hasta aquí no hay nada nuevo. Creamos el job, Mi primer job, y definimos el SO en el que debe correr para las Github actions. Ahora vamos a añadir un paso o step en donde realizaremos una impresión simple por pantalla.
name: Hola mundo
on:
push:
branches: [ master ]
jobs:
Mi-primer-job:
name: Mi primer job
runs-on: ubuntu-latest
steps:
- name: Imprimir
env:
MI_VARIABLE: Hola mundo
run: |
echo $MI_VARIABLE.
Con esto, podemos actualizar nuestro repositorio y la próxima vez que hagamos un push a la rama master, se ejecutará nuestro workflow Hola mundo, y se ejecuta sólo un paso: Imprimir.
También es posible configurar jobs que dependan de la ejecución de otros. Como regla general, varios jobs definidos se ejecutarán concurrentemente, salvo que indiquemos que uno es necesario para ejecutar el otro. Vamos a modificar ligeramente nuestro anterior workflow para crear dos jobs, y además vamos a hacer que se comuniquen entre sí usando las salidas o outputs.
Primero, modifiquemos ligeramente nuestro Workflow:
name: Hola mundo
on:
push:
branches: [ master ]
jobs:
Mi-primer-job:
name: Mi primer job
runs-on: ubuntu-latest
outputs:
salida: ${{ steps.imprimir.outputs.respuesta }}
steps:
- name: Imprimir
id: imprimir
env:
MI_VARIABLE: Hola mundo
run: echo "::set-output name=respuesta::${MI_VARIABLE}"
Primero, con la categoría outputs
estamos definiendo que el job tendrá justamente un output de nombre salida, que para el paso Imprimir (al que hemos agregado el id imprimir) se llamará internamente respuesta. Esto es lo que significa ${{ steps.imprimir.outputs.respuesta }}
Ahora al correr nuestro paso, lo que hacemos con la instrucción echo "::set-output name=respuesta::${MI_VARIABLE}"
es definir la variable de salida interna respuesta con el valor de MI_VARIABLE, es decir, "Hola mundo".
Bien, ahora agreguemos el segundo job:
name: Hola mundo
on:
push:
branches: [ master ]
jobs:
Mi-primer-job:
name: Mi primer job
runs-on: ubuntu-latest
outputs:
salida: ${{ steps.imprimir.outputs.respuesta }}
steps:
- name: Imprimir
id: imprimir
env:
MI_VARIABLE: Hola mundo
run: echo "::set-output name=respuesta::${MI_VARIABLE}"
Mi-segundo-job:
needs: Mi-primer-job
name: Mi segundo job
runs-on: ubuntu-latest
steps:
- name: Imprimir
id: imprimir
run: echo ${{needs.Mi-primer-job.outputs.salida}}
Notemos entonces cómo debemos definir que Mi-segundo-job NECESITA a Mi-primer-job a través de la instrucción needs: Mi-primer-job.
Ahora, al correr el job, llamamos los needs (que puede ser uno o varios), y de los outputs de Mi-primer-job llamamos el que bautizamos como salida. Si hemos configurado bien nuestro workflow, el primer job va a asignar el valor "Hola mundo" a una variable de salida, y el segundo job va a imprimirlo ejecutándose después del primero. Hagamos un commit y probemos.
En efecto, eso es lo que ha pasado. ¡Muy bien! Ya estamos listos para usar Github Actions para crear un flujo de Integración Continua.
Vamos entonces a utilizar lo que sabemos de Github Actions para crear un workflow que corra un conjunto de pruebas cada vez que hagamos un push. Para esto, nos vamos a basar en el tutorial de unittest aquí.
Vamos a crear un proyecto simple con un módulo que tiene el siguiente código:
import datetime
class Persona:
def __init__(self, nombre, edad):
self.__nombre = nombre
self.__edad = edad
def asignar_edad(self, edad):
self.__edad = edad
def asignar_nombre(self, nombre):
self.__nombre = nombre
def dar_edad(self):
return(self.__edad)
def dar_nombre(self):
return(self.__nombre)
def calcular_anio_nacimiento(self, ya_cumplio_anios):
anio_actual = datetime.datetime.now().year
if ya_cumplio_anios:
return (anio_actual - self.__edad)
else:
return (anio_actual - self.__edad + 1)
Y nuestro archivo de pruebas se verá así:
import unittest
import datetime
from src.mi_proyecto.persona import Persona
class PersonaTestCase(unittest.TestCase):
def setUp(self):
self.persona1 = Persona(nombre='Alejandra', edad=25)
self.persona2 = Persona(nombre='Diego', edad=22)
self.persona3 = Persona(nombre='Alejandra', edad=25)
self.persona4 = Persona(nombre='Diana', edad=25)
self.grupo = [self.persona1, self.persona2, self.persona3]
def test_constructor(self):
self.assertEqual(self.persona1.dar_nombre(), 'Alejandra')
self.assertEqual(self.persona1.dar_edad(), 25)
def test_anio_nacimiento(self):
self.assertEqual(self.persona1.calcular_anio_nacimiento(True), datetime.datetime.now().year - 25)
self.assertNotEqual(self.persona1.calcular_anio_nacimiento(False), datetime.datetime.now().year - 25)
self.assertEqual(self.persona1.calcular_anio_nacimiento(False), datetime.datetime.now().year - 25 + 1)
self.assertNotEqual(self.persona1.calcular_anio_nacimiento(True), datetime.datetime.now().year - 25 + 1)
def test_asingacion(self):
self.persona2.asignar_edad(28)
self.persona2.asignar_nombre("Felipe")
self.assertFalse(self.persona2.dar_nombre()=='Diego')
self.assertFalse(self.persona2.dar_edad()==22)
self.assertTrue(self.persona2.dar_nombre()=='Felipe')
self.assertTrue(self.persona2.dar_edad()==28)
def test_objetos_iguales(self):
persona_nueva = self.persona1
self.assertIsNot(self.persona1, self.persona3)
self.assertIs(self.persona1, persona_nueva)
def test_elemento_en_conjunto(self):
self.assertIn(self.persona3, self.grupo)
self.assertNotIn(self.persona4, self.grupo)
def test_instancia_clase(self):
self.assertIsInstance(self.persona1, Persona)
self.assertNotIsInstance(self.grupo, Persona)
Muy bien, si corriéramos nuestras pruebas desde la línea de comando usando la instrucción python -m unittest -v tests/test_persona.py
veríamos que nuestros tests pasan. Pero lo que queremos es que estos mismos tests sean los que corran al hacer push en nuestro repositorio.
Así que creemos en la carpeta .github/workflows nuestro workflow que llamaremos pruebas.yml:
name: Pruebas unitarias
on:
push:
branches: [ master ]
jobs:
job1:
name: Pruebas
runs-on: ubuntu-latest
steps:
- name: Checkout de repositorio
uses: actions/checkout@v2
- name: Configuración de entorno de python
uses: actions/setup-python@v2
with:
python-version: '3.7'
- name: Correr pruebas
id: correr-pruebas
run: python -m unittest -v tests/test_persona.py
De nuevo, el evento será push sobre la rama master. El job1 que ejecuta las pruebas tendrá 4 pasos:
Paso 1. Checkout de repositorio. La máquina en donde se ejecuta el workflow va a hacer checkout de nuestro repo. Por lo que usamos la acción predefinida actions/checkout@v2
para este repositorio (del repositorio de acciones de github).
Paso 2. Configuración de entorno de python. Vamos a usar una instalación básica de python 3.7, presente en actions/setup-python@v2
y a la que tenemos que indicarle la versión con la instrucción with
.
Paso 3. Correr pruebas. Una vez configurado el entorno básico, sólo debemos darle la instrucción que queremos que corra para lanzar nuestras pruebas unitarias.
El resultado en la pestaña de github actions será el siguiente:
Nuestras pruebas se ejecutaron con éxito y la acción dio resultado positivo.
También podemos añadir un paso adicional para correr el cubrimiento de las pruebas de nuestro código y ver el reporte al final de hacer el push:
name: Pruebas unitarias
on:
push:
branches: [ master ]
jobs:
job1:
name: Pruebas
runs-on: ubuntu-latest
steps:
- name: Checkout de repositorio
uses: actions/checkout@v2
- name: Configuración de entorno de python
uses: actions/setup-python@v2
with:
python-version: '3.7'
- name: Instalación de librerías y dependencias
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Correr pruebas
id: correr-pruebas
run: python -m unittest -v tests/test_persona.py
- name: Cálculo de cubrimiento
id: cubrimiento
run: |
coverage run -m unittest tests/test_persona.py
coverage report -m
No olvidemos añadir en requirements.txt que necesitamos la librería coverage de python y al hacer el push tendremos también automatizado el cubrimiento:
GitHub Actions permite agregar validaciones en forma de condicionales para ejecutar ciertas acciones dependiendo del estado de los check en el flujo de trabajo.
name: Pruebas unitarias
on:
push:
branches: [ master ]
jobs:
job1:
name: Pruebas
runs-on: ubuntu-latest
outputs:
salida: ${{ steps.imprimir.outputs.respuesta }}
steps:
- name: Checkout de repositorio
uses: actions/checkout@v2
- name: Configuración de entorno de python
uses: actions/setup-python@v2
with:
python-version: '3.7'
- name: Instalación de librerías y dependencias
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Correr pruebas
id: correr-pruebas
run: python -m unittest -v tests/test_persona.py
- name: Validar resultado con errores
if: ${{ failure() }}
env:
MENSAJE_ERROR: Una o varias pruebas fallaron
run: echo "::set-output name=respuesta::${MENSAJE_ERROR}"
- name: Validar resultado sin errores
if: ${{ success() }}
env:
MENSAJE_EXITO: Todas las pruebas fueron exitosas
run: echo "::set-output name=respuesta::${MENSAJE_EXITO}"
- name: Cálculo de cubrimiento
id: cubrimiento
run: |
coverage run -m unittest tests/test_persona.py
coverage report -m
En el ejemplo anterior, validamos los resultados obtenidos mediante success()
y failure()
. De esta forma, si las pruebas fallan se imprime un mensaje de error y en caso contrario se imprime un mensaje de éxito.
Para más información consulta el siguiente enlace: https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#job-status-check-functions
Lo anterior funcionará bien si sólo estamos usando las librerías base de python y nada más. Cuando configuremos nuestros workflows tenemos que añadir un paso para instalar las librerías necesarias. Podemos hacer un experimento simple y añadir un import numpy
en nuestro archivo de pruebas. Ahora al intentar correrlo veremos que las pruebas fallan.
Aquí es donde debemos usar el archivo requirements.txt que indica las dependencias de nuestro proyecto. En nuestro caso añadiremos numpy y su documentación:
numpy==1.18.1
numpydoc==0.9.2
Y ahora añadimos un nuevo paso a nuestro job para actualizar pip e instalar las librerías indicadas en el archivo de requirements.txt.
name: Pruebas unitarias
on:
push:
branches: [ master ]
jobs:
job1:
name: Pruebas
runs-on: ubuntu-latest
steps:
- name: Checkout de repositorio
uses: actions/checkout@v2
- name: Configuración de entorno de python
uses: actions/setup-python@v2
with:
python-version: '3.7'
- name: Instalación de librerías y dependencias
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Correr pruebas
id: correr-pruebas
run: python -m unittest -v tests/test_persona.py
Y con esto nuestro flujo vuelve a correr exitosamente.
Vamos a crear ahora un tipo diferente de workflow que se ejecute cuando nuestro repositorio reciba un pull-request. Vamos a hacer dos jobs, el primero ejecutará las pruebas y el segundo realizará un merge automático de la rama siempre que las pruebas pasen exitosamente.
Entonces creemos el workflow en el archivo pull-requests.yml. Vamos a crear la configuración del workflow para que corra en caso de un pull_request:
name: Automerge
on:
pull_request:
types:
- labeled
- unlabeled
- synchronize
- opened
- edited
- ready_for_review
- reopened
- unlocked
branches: [ master ]
Primero, configuramos para incluir los diferentes tipos de pull_requests posibles. Ahora vamos a crear nuestro primer job, que es idéntico al que configuramos anteriormente y que corre las pruebas y el cálculo del cubrimiento
name: Automerge
on:
pull_request:
types:
- labeled
- unlabeled
- synchronize
- opened
- edited
- ready_for_review
- reopened
- unlocked
branches: [ master ]
jobs:
job1:
name: Pruebas
runs-on: ubuntu-latest
steps:
- name: Checkout de repositorio
uses: actions/checkout@v2
- name: Configuración de entorno de python
uses: actions/setup-python@v2
with:
python-version: '3.7'
- name: Instalación de librerías y dependencias
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Correr pruebas
id: correr-pruebas
run: python -m unittest -v tests/test_persona.py
- name: Cálculo de cubrimiento
id: cubrimiento
run: |
coverage run -m unittest tests/test_persona.py
coverage report -m
Ahora vamos a añadir el segundo job que hace el merge automático usando una de las github actions creadas por la comunidad aquí: https://github.com/pascalgn/automerge-action#configuration
Este nuevo job necesitará la ejecución exitosa de las pruebas. El resultado es el siguiente:
name: Automerge
on:
pull_request:
types:
- labeled
- unlabeled
- synchronize
- opened
- edited
- ready_for_review
- reopened
- unlocked
branches: [ master ]
jobs:
job1:
name: Pruebas
runs-on: ubuntu-latest
steps:
- name: Checkout de repositorio
uses: actions/checkout@v2
- name: Configuración de entorno de python
uses: actions/setup-python@v2
with:
python-version: '3.7'
- name: Instalación de librerías y dependencias
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Correr pruebas
id: correr-pruebas
run: python -m unittest -v tests/test_persona.py
- name: Cálculo de cubrimiento
id: cubrimiento
run: |
coverage run -m unittest tests/test_persona.py
coverage report -m
automerge:
name: Automerge
needs: job1
runs-on: ubuntu-latest
steps:
- name: automerge
uses: "pascalgn/automerge-action@4536e8847eb62fe2f0ee52c8fa92d17aa97f932f"
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
MERGE_LABELS: ""
Cuando hacemos pascalgn/automerge-action@4536e8847eb62fe2f0ee52c8fa92d17aa97f932f
estamos referenciando el automerge. La configuración del entorno por defecto debe usar ambos parámetros, el token (que puede modificarse para restringir este workflow a ciertos usuarios) y las etiquetas con que quedarán los merge.
Bien, con esto podemos probar nuestro nuevo workflow. Creamos una rama, realizamos una modificación (un comentario o algo simple) y hacemos push de los cambios. Luego desde github podemos crear un nuevo pull-request de la rama y ¡listo! Nuestro workflow ejecutará las pruebas y hará merge automático del pull-request si las pasa. Luego de la ejecución podremos verificar que no solamente se cerró el pull-request, sino que los cambios quedaron integrados al master.
También podemos configurar matrices de jobs en paralelo para correr múltiples veces el mismo job bajo circunstancias diferentes. Por ejemplo, si queremos verificar que nuestros desarrollos corren en diferentes sistemas operativos o con diferentes versiones de Python. Para esto, debemos añadir la propiedad strategy
en la configuración inicial de nuestro job y luego un arreglo con los elementos que queremos usar para paralelizar.
Supongamos que queremos verificar que los tests corren en Windows, Mac y Ubuntu. Entonces, creamos la propiedad y la subpropiedad matrix
que representa la matriz de jobs que correrán en paralelo para cada os en la lista que indicamos:
name: Pruebas unitarias
on:
push:
branches: [ master ]
jobs:
job1:
name: Pruebas
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-latest, windows-latest, ubuntu-18.04]
Entonces, la propiedad runs-on
nos servía antes para indicar el SO en el que queríamos correr nuestras pruebas. Como ahora usaremos la matriz, entonces le decimos que tome la lista de os y así mismo dividirá el job1 en tantos elementos como hayamos indicado en la matriz. Si dejamos el código de las secciones anteriores para correr pruebas y hacemos un nuevo push, veremos 3 jobs corriendo en el workflow original:
En cada caso, se desplegará un contenedor del SO indicado en donde se instalará el ambiente, se correrán las pruebas y en caso exitoso, se dará por completado.
Github Actions es una herramienta poderosa. Si bien acá te explicamos los fundamentos, hay muchos más eventos, propiedades y ejemplos que puedes utilizar para automatizar tus flujos de trabajo y el desarrollo de proyectos usando integración continua. Para más información te invitamos a leer la documentación en este enlace: https://docs.github.com/en/actions