◀ Retour au blog
DevOps

CI/CD avec GitHub Actions

Publié le 08 Feb 2024· 8 min de lecture
#GitHub Actions#CI/CD#Automatisation

GitHub Actions pour vos projets PHP

GitHub Actions offre une solution de CI/CD intégrée directement dans votre dépôt. Voici comment configurer un pipeline complet.

L'intérêt principal est l'absence d'infrastructure à maintenir : les workflows sont des fichiers YAML placés dans .github/workflows/, versionnés avec le code, et exécutés sur des machines virtuelles fournies par GitHub (les runners). Chaque push ou pull request déclenche les vérifications, et leur résultat s'affiche directement dans l'interface de revue. Pour un projet PHP/Symfony, on obtient en quelques dizaines de lignes l'analyse statique, les tests avec une vraie base de données et le déploiement automatique.

Les notions de base

  • Workflow : un fichier YAML qui décrit quand s'exécuter (on) et quoi faire (jobs).
  • Job : un ensemble d'étapes exécutées sur un même runner. Les jobs d'un workflow tournent en parallèle par défaut, sauf si needs impose un ordre.
  • Step : une commande shell (run) ou une action réutilisable (uses), publiée sur le Marketplace ou dans votre propre dépôt.
  • Service : un conteneur annexe (MySQL, Redis…) démarré à côté du job, le temps de son exécution.

Workflow de test

Ce premier workflow s'exécute à chaque push sur main et develop, ainsi que sur chaque pull request visant main. Il démarre un MySQL 8, installe PHP 8.3 avec les extensions nécessaires, puis lance PHPStan et PHPUnit.

name: CI
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  tests:
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: root
          MYSQL_DATABASE: test
        ports:
          - 3306:3306

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: mbstring, pdo_mysql, intl
          coverage: xdebug

      - name: Install dependencies
        run: composer install --prefer-dist --no-progress

      - name: Run PHPStan
        run: vendor/bin/phpstan analyse src

      - name: Run tests
        run: php bin/phpunit --coverage-clover coverage.xml
        env:
          DATABASE_URL: mysql://root:[email protected]:3306/test

Quelques détails qui comptent :

  • shivammathur/setup-php est l'action de référence pour PHP : elle installe la version demandée, les extensions, Composer et le pilote de couverture de code.
  • Le port 3306:3306 expose MySQL sur le runner, d'où l'adresse 127.0.0.1 dans DATABASE_URL. Si le job tournait lui-même dans un conteneur (container:), il faudrait utiliser le nom du service, mysql, comme hôte.
  • Le mot de passe root est acceptable ici : la base est jetable et n'existe que le temps du job. Les vrais secrets, eux, ne doivent jamais apparaître dans le YAML.

Déploiement automatique

Le job de déploiement s'ajoute sous jobs:. Il attend le succès des tests (needs) et ne s'exécute que sur la branche main.

  deploy:
    needs: tests
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - name: Deploy to production
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: deploy
          key: ${{ secrets.SSH_KEY }}
          script: |
            cd /var/www/app
            git pull origin main
            composer install --no-dev
            php bin/console cache:clear
            php bin/console doctrine:migrations:migrate -n

Ce script est volontairement simple. En production, je recommande d'ajouter set -e en première ligne pour interrompre le déploiement à la première erreur, et --optimize-autoloader à composer install. Pour éviter les quelques secondes pendant lesquelles le code et les dépendances ne sont pas synchronisés, la stratégie des répertoires de release (un dossier par version et un lien symbolique current) reste la plus sûre.

Un workflow prêt pour la production

La version suivante reprend les mêmes étapes en appliquant les bonnes pratiques : lint et tests dans des jobs séparés qui tournent en parallèle, matrice de versions PHP, cache Composer, attente de MySQL et permissions minimales.

name: CI

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          coverage: none
      - run: composer install --prefer-dist --no-progress
      - run: vendor/bin/php-cs-fixer fix --dry-run --diff
      - run: vendor/bin/phpstan analyse src --error-format=github

  tests:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        php: ['8.2', '8.3', '8.4']
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: root
          MYSQL_DATABASE: test
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=5
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          extensions: mbstring, pdo_mysql, intl
          coverage: pcov

      - name: Get Composer cache directory
        id: composer-cache
        run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"

      - name: Cache Composer dependencies
        uses: actions/cache@v4
        with:
          path: ${{ steps.composer-cache.outputs.dir }}
          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
          restore-keys: ${{ runner.os }}-composer-

      - run: composer install --prefer-dist --no-progress
      - run: php bin/phpunit --coverage-clover coverage.xml
        env:
          DATABASE_URL: mysql://root:[email protected]:3306/test

Ce qui change par rapport au premier workflow :

  • permissions: contents: read réduit les droits du GITHUB_TOKEN au strict nécessaire. Un job qui doit commenter une pull request ou publier une image demandera explicitement des droits supplémentaires.
  • concurrency annule l'exécution précédente quand un nouveau commit arrive sur la même branche : inutile de tester un code déjà remplacé.
  • La matrice lance un job par version de PHP. Avec fail-fast: false, un échec sur PHP 8.4 n'interrompt pas les autres versions, ce qui donne une vision complète de la compatibilité.
  • Le health check MySQL : sans les options, le job peut démarrer alors que MySQL initialise encore sa base, et les premiers tests échouent de façon aléatoire. GitHub attend désormais que le conteneur soit déclaré sain.
  • Le cache Composer est indexé sur le hash de composer.lock : tant que les dépendances ne changent pas, les paquets sont restaurés au lieu d'être téléchargés.
  • pcov collecte la couverture de code beaucoup plus vite que Xdebug, qui n'est utile que si vous avez besoin de ses autres fonctionnalités.
  • --error-format=github transforme les erreurs PHPStan en annotations affichées directement sur les lignes concernées de la pull request.

Protéger le déploiement avec un environnement

GitHub propose les environnements : des secrets propres à chaque cible et, selon votre offre GitHub, des règles de protection comme une validation manuelle par des relecteurs désignés. Le job de déploiement déclare simplement l'environnement qu'il utilise :

  deploy:
    needs: [lint, tests]
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://example.com
    concurrency:
      group: production
      cancel-in-progress: false
    steps:
      - name: Deploy to production
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: deploy
          key: ${{ secrets.SSH_KEY }}
          script: |
            set -e
            cd /var/www/app
            git pull origin main
            composer install --no-dev --optimize-autoloader
            php bin/console cache:clear
            php bin/console doctrine:migrations:migrate -n

Ici, le groupe de concurrence production garantit qu'un seul déploiement s'exécute à la fois, sans jamais en annuler un en cours : interrompre un déploiement au milieu des migrations serait bien pire que d'attendre.

Pièges fréquents

  • Secrets et forks : les workflows déclenchés par une pull request provenant d'un fork n'ont pas accès aux secrets. Les tests doivent donc fonctionner sans eux.
  • Actions tierces : une action référencée par un tag (@v1) peut changer sans que vous le sachiez. Pour les actions sensibles, épinglez-les sur un SHA de commit complet et laissez Dependabot proposer les mises à jour.
  • Noms des status checks : avec une matrice, les checks s'appellent par exemple tests (8.3). Renommer un job ou modifier la matrice oblige à mettre à jour les règles de protection de branche.
  • Déploiement depuis une pull request : la condition github.event_name == 'push' évite qu'un événement inattendu sur main déclenche une mise en production.

Bonnes pratiques

  • Utiliser le cache des dépendances Composer
  • Paralléliser les jobs de test et de lint
  • Protéger les branches avec des status checks requis
  • Stocker les secrets dans GitHub Secrets
  • Limiter les permissions du GITHUB_TOKEN
  • Utiliser un environnement protégé pour la production

Avec ces quelques règles, chaque pull request est vérifiée automatiquement, les erreurs apparaissent directement dans la revue de code, et la mise en production devient une opération banale plutôt qu'un événement redouté.