GitHub Actions en… action !

Pendant la conférence Cloud Nord en novembre 2020, j’ai découvert le dernier né du monde de la CI/CD : GitHub Actions. Le but de cet article est de présenter l’outil via un exemple concret de déploiement d’une application Java sur AWS dans un monorepo qui contiendra :

  • une application Java Hello World déployée dans un container Docker ;
  • le code Terraform pour la gestion de l’infrastructure sur AWS ;
  • les fichiers de configuration GitHub Actions permettant de garantir la qualité du code, de lancer les tests et d’orchestrer le déploiement.

Création d’un premier workflow pour créer l’infrastructure sur AWS

L’ensemble du code présenté ci-dessous se trouve sur ce repository GitHub.

Le premier module de notre monorepo se prénomme base-infrastructure. Il contient le code Terraform décrivant notre infrastructure de base : un Virtual Private Cloud (VPC), un Application Load Balancer (ALB) et un cluster Elastic Container Service (ECS) Fargate.

Le workflow, c’est le point d’entrée dans GitHub Actions. C’est une procédure automatisée qui définit plusieurs étapes. Un workflow doit être mis dans le dossier .github/workflows/ d’un projet. Un projet peut avoir plusieurs workflows. Ces derniers seront lancés en parallèle.

Commençons donc par créer un premier workflow base-infrastructure.yml :

name: base-infrastructure

on:
  push:
    branches:
      - main
  pull_request:

defaults:
  run:
    shell: bash

env:
  AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
  AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}


Après avoir nommé notre workflow, nous définissons sur quels évènements (events) il se déclenche. Ils peuvent être :

  • liés au cycle de vie du code : push, création d’une release, création/fermeture d’une Pull Request (PR), création d’un commentaire sur une PR, ajout d’un label...
  • liés au cycle de vie d’un projet GitHub : création d’une issue, ajout d’un commentaire sur une issue, création d’un label, création d’un projet GitHub…
  • programmés via un cron.

La liste de ces événements est présente sur la documentation. Dans cet exemple, le workflow se déclenche sur deux choses :

  • un push sur la branche main
  • n’importe quel event de type pull_request

On définit ensuite des attributs globaux à l’ensemble du workflow, on indique le shell par défaut à utiliser dans les jobs. Un job est un ensemble d’étapes (steps) qui sont lancées sur un même runner (machine virtuelle).

On utilise également la directive env pour injecter des variables d’environnement. On y insère ici des secrets du repository préalablement enregistrés dans les paramètres du projet GitHub, à savoir les credentials AWS. Pour accéder à des informations du contexte GitHub de votre workflow, il faut utiliser une syntaxe spécifique pour évaluer des expressions, à savoir ${{ <expression> }} (les noms des contextes sont nombreux, n’hésitez pas à aller les consulter).

Passons maintenant au premier job :

jobs:
  terraform:
    defaults:
      run:
        working-directory: base-infrastructure/.cloud/terraform
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        id: checkout
        uses: actions/checkout@v2
      - name: Setup Terraform
        id: setup
        uses: hashicorp/setup-terraform@v1
        with:
          terraform_version: 0.14.7
          terraform_wrapper: false
      - name: Terraform Format
        id: fmt
        run: terraform fmt -check -recursive
      - name: Terraform init without backend
        id: init-without-backend
        run: terraform init -backend=false
      - name: Terraform validate
        id: validate
        run: terraform validate

Ce premier job s’appelle terraform, il sera lancé sur un runner Ubuntu avec la version la plus récente et le dossier de travail par défaut est base-infrastructure/.cloud/terraform. Arrivent ensuite les différentes étapes du job.

C’est une bonne pratique de spécifier un name et un id pour chacune des étapes (steps) d’un job. Le name permettra de faciliter la lecture dans la sortie console de GitHub Actions et l’id permettra d’identifier un step de façon unique.

Deux des steps font appel à des actions et les trois autres lancent des commandes Terraform via le shell de l’Operating System (OS) du runner.

Les actions sont des modules externes (publiés sur le GitHub Marketplace) qui peuvent être de différents types : Docker,  JavaScript ou encore Composite run steps. Nous ne rentrerons pas dans les détails de tous les types d’action, la documentation GitHub le fait très bien. Les deux actions que nous appelons ici sont des actions JavaScript. La première action (uses: actions/checkout@v2) va permettre de récupérer le code du projet et la seconde (uses: hashicorp/setup-terraform@v1) va configurer Terraform à l’endroit où s’exécute notre job (cela peut être le host GitHub Actions ou bien un container Docker).

Continuons avec les étapes suivantes :

      - name: Terraform init
        id: init
        run: terraform init
      - name: Terraform plan
        id: plan
        run: terraform plan -no-color -out=tfplan.out
      - name: Terraform apply
        id: apply
        if: github.ref == 'refs/heads/main'
        run: terraform apply -input=false tfplan.out
        continue-on-error: true

L’étape apply nous amène la directive if: github.ref == 'refs/heads/main'. Elle permet de lancer une étape sur une condition donnée. Ici, on limite les terraform apply à la branche git main.

La directive continue-on-error: true indique que l’étape peut être en échec sans mettre l’ensemble du workflow en échec.

Passons aux étapes suivantes :

 - name: Create a new GitHub issue if the apply failed
        id: create-github-issue-if-apply-failed
        uses: actions/github-script@v3
        if: github.ref == 'refs/heads/main' && steps.apply.outcome == 'failure'
        env:
          PLAN: "terraform\n${{ steps.plan.outputs.stdout }}"
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const output = `#### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\`
            #### Terraform plan which provoked a Terraform apply failure 📖 \`${{ steps.plan.outcome }}\`
            <details><summary>Show Plan</summary>\n
            \`\`\`${process.env.PLAN}\`\`\`
            </details>\n
            *Pusher: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*`;

            github.issues.create({
              title: 'Terraform apply failed on main branch',
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output,
              labels: ['Triage', 'Bug']
            })
      - name: Terraform apply status
        id: apply-status
        if: github.ref == 'refs/heads/main' && steps.apply.outcome == 'failure'
        run: exit 1


L’étape create-github-issue-if-apply-failed est un cas d’utilisation intéressant d'interaction avec l’API GitHub. Cette étape est déclenchée si et seulement si on est sur la branche main et si l’étape apply précédemment jouée a échoué. Pour cela, on fait appel à l’action actions/github-script@v3 qui prend en paramètres le token GitHub ainsi qu’un script écrit en JavaScript (qui a dit que les consultants pratiquant le DevOps ne faisaient que du YAML ?) afin de créer une issue GitHub contenant le plan Terraform ayant fait échouer le workflow de la branche main. Enfin, l’étape apply-status permet de faire échouer le workflow dans le cas où l’étape apply aurait échoué.

Et voilà le résultat du workflow dans GitHub Actions :

Création de notre première action

Le second module du monorepo s’appelle gateway. Il contient le code Java de notre application Hello World et deux autres sous-modules avec du code Terraform. Le premier sert à créer un repository Elastic Container Registry (ECR) pour héberger l’image Docker de l’application Java. Le second permet de déployer cette même image sur le cluster ECS Fargate.

Le premier workflow GitHub présenté précédemment contenait des tâches assez génériques pour valider et appliquer du code Terraform. Comment pourrions-nous rendre ce code réutilisable pour l’utiliser dans un nouveau workflow pour le second module gateway ?  Créons une action de type Composite run steps pour résoudre cette problématique.

Comme nous ne souhaitons pas dans un premier temps partager cette action à la communauté, nous allons placer cette dernière dans le dossier .github/actions/ du repository de code. Ceci étant, si vous souhaitez partager votre action à la communauté, c’est tout à fait possible. Il faudra créer un nouveau repository sur GitHub afin de pouvoir versionner votre action.

Voici notre première action .github/actions/terraform-workflow/action.yml:

name: 'Terraform workflow'
author: Timothée Aufort
description: 'A common workflow of Terraform commands'
inputs:
  working-directory:
    description: 'Working directory for this action'
    required: false
    default: '.'
  apply:
    description: 'Do you want to apply the Terraform plan at the end of the process?'
    required: false
    default: 'true'
runs:
  using: "composite"
  steps:
    - name: Terraform Format
      id: fmt
      run: terraform fmt -check -recursive
      shell: bash
      working-directory: ${{ inputs.working-directory }}
    - name: Terraform init without backend
      id: init-without-backend
      run: terraform init -backend=false
      shell: bash
      working-directory: ${{ inputs.working-directory }}
    - name: Terraform validate
      id: validate
      run: terraform validate
      shell: bash
      working-directory: ${{ inputs.working-directory }}
    - name: Terraform init
      id: init
      run: terraform init
      shell: bash
      working-directory: ${{ inputs.working-directory }}
    - name: Terraform plan
      id: plan
      run: terraform plan -no-color -out=tfplan.out
      shell: bash
      working-directory: ${{ inputs.working-directory }}
    - name: Terraform apply
      id: apply
      run: |
        if [ ${{ inputs.apply }} = "true" ]; then
          terraform apply -input=false tfplan.out
        else
          echo 'User chose not to apply the Terraform plan stored in tfplan.out'
        fi
      shell: bash
      working-directory: ${{ inputs.working-directory }}

Cette action est composée de plusieurs steps en grande partie repris de notre premier workflow. Le dernier step qui permet de lancer un terraform apply diffère un peu, on a rendu cette étape conditionnelle grâce à un if...then...else en bash.

Les actions peuvent avoir plusieurs inputs et outputs dans leur définition.

Notre action possède 2 inputs optionnels : working-directory et apply. Le premier permet de définir le dossier de travail dans lequel chaque step sera exécuté et le deuxième permet de rendre l’apply Terraform optionnel.

Les inputs des actions sont nécessairement de type string au moment de l’écriture de cet article.

Ce type d’action a, à mon sens, plusieurs défauts à l’heure actuelle :

  • les steps de l’action ne peuvent pas utiliser les mêmes directives GitHub Actions que les steps d’un workflow. On ne pourra pas par exemple utiliser la directive if ou encore continue-on-error comme dans notre premier workflow. De la même façon, on ne peut pas appeler une autre action avec la directive uses ;
  • on est obligé de spécifier la directive shell à chaque step, ce qui est dommage car le shell à utiliser peut être défini dans le workflow appelant l’action et pourrait donc être implicitement utilisé ;
  • on ne peut pas définir un working-directory pour l’ensemble de l’action. Par défaut,  chaque step s’exécutera à la racine de notre projet malgré le fait que le workflow appelant l’action définisse un espace de travail différent. On est donc obligé de surcharger ce paramètre pour chaque step.

Gestion du cycle de vie de notre application

Il est temps d’utiliser notre action dans un nouveau workflow .github/workflows/gateway.yml et dans un premier job gateway-ecr qui nous permettra de créer le repository ECR sur AWS :

jobs:
  gateway-ecr:
    defaults:
      run:
        working-directory: gateway/.cloud/terraform/10_ecr
    runs-on: ubuntu-latest
    outputs:
      gateway-ecr-repository-url-output: ${{ steps.terraform-output.outputs.gateway-ecr-repository-url }}
    steps:
      ...
      - name: Run Terraform workflow
        uses: ./.github/actions/terraform-workflow
        with:
          working-directory: 'gateway/.cloud/terraform/10_ecr'
      - name: Store ECR repository URL in an output variable
        id: terraform-output
        run: echo "::set-output name=gateway-ecr-repository-url::$(terraform output gateway_ecr_repository_url)"

Le step terraform-output permet d’exporter l’URL du repository Docker ECR qui sera utilisé dans le job suivant où l’on va construire l’image de notre application Java. On utilise pour cela la commande de workflow set-output qui s’utilise comme suit :

::set-output name={name}::{value}

Les commandes de workflow sont nombreuses et sont décrites ici.

On déclare en outputs du job un paramètre qu’on appelle gateway-ecr-repository-url-output qui prend sa valeur depuis le dernier step du job.

On arrive maintenant au job gateway-build suivant qui va gérer le cycle de vie de notre application Java :

  gateway-build:
    defaults:
      run:
        working-directory: gateway
    runs-on: ubuntu-latest
    needs: gateway-ecr
    container:
      image: taufort/infrastructure-ubuntu-docker:latest
    steps:
      - name: Checkout
        id: checkout
        uses: actions/checkout@v2
      - name: Set environment variables
        id: set-env-variables
        run: |
          echo "GATEWAY_ECR_REPOSITORY_URL=${{needs.gateway-ecr.outputs.gateway-ecr-repository-url-output}}" >> $GITHUB_ENV
          echo "ECR_REGISTRY_URI=$(echo ${{needs.gateway-ecr.outputs.gateway-ecr-repository-url-output}} | cut -d/ -f1)" >> $GITHUB_ENV
      - name: Print env variables
        id: print-env-variables
        run: |
          echo GATEWAY_ECR_REPOSITORY_URL=${{ env.GATEWAY_ECR_REPOSITORY_URL }}
          echo ECR_REGISTRY_URI=${{ env.ECR_REGISTRY_URI }}
      - name: Run maven clean verify
        id: maven-clean-verify
        run: ./mvnw clean verify
      - name: Build docker image
        id: maven-build-image
        run: ./mvnw -DskipTests spring-boot:build-image
      - name: Login to ECR
        id: ecr-login
        run: aws ecr get-login-password --region eu-west-3 | docker login --username AWS --password-stdin ${ECR_REGISTRY_URI}
      - name: Push the gateway docker image
        id: docker-push
        run: docker push ${GATEWAY_ECR_REPOSITORY_URL}:latest


On utilise la directive needs: gateway-ecr pour indiquer que ce nouveau job dépend du job précédent, cela permet d’ordonner l’exécution des jobs.

A la différence des jobs précédents, celui-ci utilise la directive container qui permet d’indiquer à GitHub Actions qu’on souhaite lancer les steps dans un container Docker dédié. J’ai utilisé ici l’une de mes images image: taufort/infrastructure-ubuntu-docker:latest contenant plusieurs binaires utiles à l’exécution de ce job dont le CLI AWS, le CLI Docker ainsi que AdoptOpenJDK 15.

Si on ne spécifie pas de container dans un job, les steps s'exécutent par défaut sur la machine hôte directement.

Le step set-env-variables est intéressant car on utilise une nouvelle commande de workflow pour définir deux nouvelles variables d’environnement à partir d’un output du job précédent :

echo "GATEWAY_ECR_REPOSITORY_URL=${{needs.gateway-ecr.outputs.gateway-ecr-repository-url-output}}" >> $GITHUB_ENV

On peut ensuite accéder à  la variable d’environnement de plusieurs façons :

  • via le contexte env fourni par GitHub Actions : ${{ env.GATEWAY_ECR_REPOSITORY_URL }}
  • ou bien directement via une variable shell : ${GATEWAY_ECR_REPOSITORY_URL}

L’image Docker de notre application Java est construite grâce à Buildpacks via le plugin Maven de Spring Boot spring-boot-maven-plugin où on référence justement cette variable d’environnement :

<build>
	<plugins>
		<plugin>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-maven-plugin</artifactId>
			<configuration>
				<image>
					<name>${env.GATEWAY_ECR_REPOSITORY_URL}</name>
				</image>
			</configuration>
		</plugin>
	</plugins>
</build>

Le dernier job du workflow gateway est assez simple et va se charger de déployer l’image Docker précédemment créée sur le cluster ECS Fargate via un service ECS :

gateway-deploy:
    defaults:
      run:
        working-directory: gateway/.cloud/terraform/20_ecs
    runs-on: ubuntu-latest
    needs: gateway-build
    steps:
      - name: Checkout
        id: checkout
        uses: actions/checkout@v2
      - name: Setup Terraform
        id: setup
        uses: hashicorp/setup-terraform@v1
        with:
          terraform_version: 0.14.7
          terraform_wrapper: false
      - name: Run Terraform workflow
        uses: ./.github/actions/terraform-workflow
        with:
          working-directory: 'gateway/.cloud/terraform/20_ecs'

Voilà le résultat de nos deux workflows et de leur 4 jobs dans une Pull Request (PR) :

Notre application a été déployée sur AWS avec succès :

Que retenir ?

GitHub Actions existe depuis fin 2019 et offre un beau lot de fonctionnalités pour un acteur si jeune dans le monde de la CI/CD. Il a l’avantage de s’intégrer très facilement avec GitHub si vous avez pour habitude d’utiliser ce gestionnaire de sources. Les actions sont un moyen simple et élégant de rendre du code réutilisable dans les workflows, quel que soit leur type (Docker, JavaScript ou Composite). Ce système souffre malgré tout de quelques limitations comme nous avons pu le voir avec le type d’action Composite. GitHub Actions est parfaitement adapté à la gestion des monorepos, contrairement à d’autres outils concurrents avec lesquels la mise en place est un peu plus compliquée voir impossible (coucou GitLab CI et Jenkins). On regrettera l’absence de possibilité d’ordonner les workflows à notre bon vouloir ou encore l’absence d’export d’artifacts qu’on pourrait se passer d’un job à l’autre. Malgré tout, GitHub Actions est un outil qui m’a semblé solide et prometteur et qui pourrait rapidement devenir l’un des référents dans le domaine de la CI/CD.

Author image
Cloud & DevOps Practice Leader, Cloud Architect & DevOps Engineer
Lyon