Comment créer une GitHub Action pour lancer des tests avec des services ?

Je creuse GitHub Actions depuis que j'ai eu accès à la bêta. GitHub Actions est disponible pour tout le monde depuis le 11 novembre 2019, et je peux vous l'expliquer. 🎉

Une autre version française de l'article est disponible ici.

Photo de l'arborescence d'un projet avec les commits et un graphe Git à trois branches

Photo de Yancy Min sur Unsplash

Qu'est-ce que c'est ?

GitHub Actions est une solution d'intégration continue et de déploiement continu (CI/CD). C'est une nouvelle fonctionnalité de GitHub, qui s'affiche sous forme d'onglet dans votre dépôt. Ses opérations sont déclenchées par des événements Git, comme le push d'un commit. Pour créer une nouvelle séquence d'événements à exécuter lors d'un push, il faut créer un ensemble de commandes qui seront exécutées selon les spécificités du projet. Une fois mis en place, ce service est conçu pour exécuter des processus automatiquement et en continu pour le travail collaboratif sur un dépôt Git. Il faut noter que GitHub Actions repose sur Docker, il est donc recommandé d'avoir quelques connaissances à ce sujet pour l'utiliser.

Écrire son action

Une action contient plusieurs éléments nécessaires à l'exécution d'un processus : l'événement on, qui correspond à l'événement GitHub : push, pull request, etc. Pendant la bêta, seuls les déclencheurs push et pull request sont disponibles.

on: [push, pull_request]

Il est bien sûr possible de définir la portée de votre action, pour qu'elle effectue des tâches précises sur certaines branches de votre dépôt, mais pas sur d'autres. Par exemple, un déploiement automatique sur un serveur depuis la branche staging, mais pas depuis la branche dev.

on:
 push:
  branches:
  - staging
Ensuite, il y a les jobs. En pratique, un job est une série d'étapes exécutées dans l'ordre. Il a 2 éléments importants : l'environnement et les étapes que le service doit réaliser.
Le paramètre runs-on définit l'environnement dans lequel l'action est exécutée :
runs-on: ubuntu-latest

Il existe plusieurs environnements virtuels possibles pour l'exécution de votre action, ce qui peut être utile pour correspondre aux spécificités de chacun. D'Ubuntu 19.10 à Windows 2016, vous avez le choix 😉

Il faut noter que chaque job est exécuté dans une nouvelle instance de l'environnement virtuel.

Donc, si vous voulez effectuer une série d'actions liées entre elles, il vaut peut-être mieux le faire dans le même job. Mais il est possible de créer des dépendances entre jobs avec le paramètre need :

jobs:
 job1:
 job2:
  need: job1

Dans ce cas, job2 ne s'exécutera qu'une fois job1 terminé.

Ensuite, il y a toutes les étapes à réaliser pendant le processus. Dans une optique d'intégration continue, le but est de lancer les tests automatiquement. Mais un certain nombre d'éléments doivent être définis avant de pouvoir lancer les tests !

Cet ensemble est défini par le mot-clé steps. Chaque étape nécessite au moins un élément à exécuter, c'est-à-dire l'un des éléments suivants :

le mot-clé uses, pour utiliser un élément précis :

  • une action publique
  • une action dans le même dépôt
  • une action sur le Docker Hub ou sur le registre public Docker

ou

le mot-clé run, pour lancer une commande en bash (sachant qu'il existe plusieurs façons d'exécuter une commande si nécessaire)

Enfin, on associe le paramètre name pour savoir quelle étape il représente. Un exemple étant toujours plus parlant, voici un job pour tester un projet Django :

jobs:
 tests:
   runs-on: ubuntu-latest

   steps:
   - uses: actions/checkout@v1
   - name: Set up Python 3.5.7
   - uses: actions/setup-python@v1
     with:
      python-version: 3.5.7

   - name: Install dependencies
     run: pip install -r requirements.txt
   - name: Run tests
     run: python manage.py test

Ici, le job tests consiste à :

  • installer Python 3.5.7 sur l'environnement Ubuntu
  • installer les dépendances
  • lancer les tests

Pour tester en conditions réelles, il faut une base de données, d'où la mise en place de services.

Ajouter un service à son action

Comment fait-on ? C'est très simple, c'est similaire à la définition d'un service dans un docker-compose.yml, mais avec beaucoup moins de paramètres.

On utilise le mot-clé services avec :

  • la définition de l'image Docker à utiliser
  • la liste des ports du service à exposer
  • une liste de variables d'environnement
  • une liste de volumes
Tous ces éléments ne sont pas obligatoires, mais c'est bon à savoir.
Concrètement, ça ressemble à ceci, pour un service Postgres :
services:
 image: postgres
  env:
  - POSTGRES_USER: postgres
  - POSTGRES_PASSWORD: postgres
  - POSTGRES_BD: postgres
  ports:
  - 5432/tcp
  options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5

Il nous faut l'image Docker à lancer, le port à exposer et le nom du service. Il est possible de transmettre des variables d'environnement ou des options Docker, comme dans l'exemple ci-dessus. Les valeurs sensibles peuvent être définies avec une clé secrète.

De plus, il est parfois nécessaire d'attendre un retour sur l'état du service pour établir une connexion. On définit donc un élément qui vérifie l'état de santé du service pour savoir quand il est opérationnel (on parle aussi de health check). J'ai repris l'exemple réalisé par Chris Patterson et Mike Coutermarsh pour PostgreSQL.

Si vous n'avez pas de health check disponible, on met en pause l'exécution du processus avec la commande sleep. Oui, ce n'est pas terrible, je vous l'accorde...

Tout ça, c'est très bien, mais il faut faire le lien entre le service et notre application. On le fera via le port, et pour l'hôte, ce sera localhost, puisque le service est exécuté directement dans la machine Ubuntu dans notre exemple. Sinon, ça aurait été le nom du service.

Dans l'exemple précédent, un port libre de la machine est attribué aléatoirement au port 5432, et on y accède comme ceci :

${{ job.services.postgres.ports[5432] }}

Néanmoins, on peut aussi l'attribuer de manière fixe :

ports:
- 5432:5432

Ici, le port 5432 du service est relié au port 5432 de la machine définie dans l'action.

Le workflow suivant est mis en place :

name: Test Workflow

on: push

jobs:
 tests:
   runs-on: ubuntu-latest

   services:
    image: postgres
    env:
    - POSTGRES_USER: postgres
    - POSTGRES_PASSWORD: postgres
    - POSTGRES_BD: postgres
    ports:
    - 5432:5432
    options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5

   steps:
   - uses: actions/checkout@v1
   - name: Set up Python 3.5.7
   - uses: actions/setup-python@v1
     with:
      python-version: 3.5.7
   - name: Install dependencies
     run: pip install -r requirements.txt
   - name: Run tests
     run: python manage.py test

Dans notre exemple, il faut quand même surcharger les valeurs du service dans le settings.py de Django pour la connexion à la base de données PostgreSQL.

Et le tour est joué, il ne reste plus qu'à pousser du code et espérer que ça marche. 😉