YOUR DIGITAL HUB
← Retour au blog

Pipeline CI/CD Symfony avec GitHub Actions : du commit à la production

· 10 min de lecture
Visuel de couverture — pipeline CI/CD Symfony avec GitHub Actions

Livraison continue, pas livraison baroque

Une pipeline CI/CD saine se lit comme une recette. Installer, vérifier, tester, sécuriser, construire, déployer. Pas besoin de Jenkins avec 200 plugins, pas besoin de ArgoCD si l'équipe est à 4 développeurs, pas besoin de Kubernetes pour déployer une API Symfony.

Les clients chez qui nous intervenons ont souvent hérité d'un Jenkins monolithique de 2016, ou d'un GitLab Runner sur une VM oubliée. Ça marche, mais le pipeline est lent, opaque, et personne ne sait comment le modifier. Notre approche : GitHub Actions ou GitLab CI, workflows courts, déclaratifs, testés, avec OIDC pour les accès cloud.

Cet article détaille la pipeline complète que nous déployons sur nos projets Symfony en production : les 7 étages, le fichier ci.yml commenté, les stratégies de déploiement et les anti-patterns qui tuent la qualité.

Anatomie d'une pipeline en 7 étages

Chaque push sur une branche ouvre une Pull Request, la PR déclenche la pipeline complète. Le merge vers main déclenche le déploiement. Voici la séquence.

  1. Install. composer install avec cache Composer et cache OPcache preload artefact.
  2. Lint. PHP-CS-Fixer, Twig-CS-Fixer, YAML lint, Markdown lint, ESLint pour le front s'il existe.
  3. Static analysis. PHPStan level 8 ou 9, Psalm, Rector dry-run.
  4. Test. PHPUnit unit + integration, Behat sur les parcours critiques, PHPUnit fonctionnel, couverture > 80 %.
  5. Security. composer audit, symfony security:check, Snyk, Trivy sur l'image Docker.
  6. Build & push. Docker multi-stage vers ECR privé ou GHCR, tag sha et tag env.
  7. Deploy. ECS rolling update, blue-green ou canary selon environnement.

Les étages sont parallélisables à partir d'Install. Sur un monorepo Symfony moyen (300 tests, 60 kloc), la pipeline complète tourne en 8 à 12 minutes, avec du cache bien posé.

Fichier GitHub Actions complet

Voici le workflow que nous installons et adaptons sur chaque projet client. Il va jusqu'au déploiement ECS via OIDC (sans secrets AWS en clair).

name: CI/CD

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

permissions:
  contents: read
  id-token: write     # requis pour OIDC vers AWS
  pull-requests: write

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
  COMPOSER_VERSION: 2.8.4
  AWS_REGION: eu-west-3
  ECR_REPOSITORY: invoicing-api

jobs:
  install:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: intl, pdo_pgsql, redis, zip, bcmath, apcu
          coverage: none
          tools: composer:${{ env.COMPOSER_VERSION }}
      - name: Cache Composer
        uses: actions/cache@v4
        with:
          path: ~/.composer/cache
          key: composer-${{ hashFiles('composer.lock') }}
          restore-keys: composer-
      - name: Install dependencies
        run: composer install --no-progress --no-interaction --prefer-dist
      - name: Upload vendor
        uses: actions/upload-artifact@v4
        with:
          name: vendor
          path: vendor
          retention-days: 1

  lint:
    runs-on: ubuntu-24.04
    needs: install
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          coverage: none
      - uses: actions/download-artifact@v4
        with:
          name: vendor
          path: vendor
      - name: PHP-CS-Fixer
        run: vendor/bin/php-cs-fixer fix --dry-run --diff --show-progress=none
      - name: Twig lint
        run: bin/console lint:twig templates
      - name: YAML lint
        run: bin/console lint:yaml config translations
      - name: Container lint
        run: bin/console lint:container --env=prod

  static:
    runs-on: ubuntu-24.04
    needs: install
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          coverage: none
      - uses: actions/download-artifact@v4
        with:
          name: vendor
          path: vendor
      - name: PHPStan
        run: vendor/bin/phpstan analyse --memory-limit=1G --error-format=github
      - name: Psalm
        run: vendor/bin/psalm --output-format=github --threads=4
      - name: Rector dry-run
        run: vendor/bin/rector process --dry-run

  test:
    runs-on: ubuntu-24.04
    needs: install
    strategy:
      fail-fast: false
      matrix:
        php: ['8.2', '8.3', '8.4']
        symfony: ['6.4.*', '7.2.*']
        exclude:
          - php: '8.2'
            symfony: '7.2.*'
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: test
        ports: ['5432:5432']
        options: >-
          --health-cmd pg_isready --health-interval 5s
          --health-timeout 3s --health-retries 5
      redis:
        image: redis:7-alpine
        ports: ['6379:6379']
        options: >-
          --health-cmd "redis-cli ping" --health-interval 5s
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          extensions: intl, pdo_pgsql, redis, zip, bcmath
          coverage: pcov
      - name: Lock Symfony version
        run: composer require --no-update symfony/symfony:${{ matrix.symfony }}
      - name: Install
        run: composer update --no-progress --prefer-dist
      - name: DB schema
        run: |
          bin/console doctrine:database:create --env=test --if-not-exists
          bin/console doctrine:migrations:migrate --env=test --no-interaction
        env:
          DATABASE_URL: postgresql://test:test@127.0.0.1:5432/test
      - name: PHPUnit
        run: vendor/bin/phpunit --coverage-clover=coverage.xml
        env:
          DATABASE_URL: postgresql://test:test@127.0.0.1:5432/test
          REDIS_URL: redis://127.0.0.1:6379
      - name: Coverage gate
        run: php tools/coverage-check.php coverage.xml 80
      - name: Behat
        run: vendor/bin/behat --no-colors

  security:
    runs-on: ubuntu-24.04
    needs: install
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          coverage: none
      - uses: actions/download-artifact@v4
        with:
          name: vendor
          path: vendor
      - name: Composer audit
        run: composer audit --locked
      - name: Symfony Security
        run: |
          curl -sS https://get.symfony.com/cli/installer | bash
          $HOME/.symfony5/bin/symfony security:check
      - name: Snyk
        uses: snyk/actions/php@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
        with:
          args: --severity-threshold=high

  build:
    runs-on: ubuntu-24.04
    needs: [lint, static, test, security]
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    outputs:
      image_tag: ${{ steps.meta.outputs.tag }}
    steps:
      - uses: actions/checkout@v4
      - name: Configure AWS OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gha-ecr-push
          aws-region: ${{ env.AWS_REGION }}
      - name: Login ECR
        id: ecr
        uses: aws-actions/amazon-ecr-login@v2
      - name: Set image tag
        id: meta
        run: echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT
      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ steps.meta.outputs.tag }}
            ${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:main
          cache-from: type=gha
          cache-to: type=gha,mode=max
      - name: Trivy scan
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ steps.meta.outputs.tag }}
          format: 'sarif'
          output: 'trivy.sarif'
          severity: 'CRITICAL,HIGH'
          exit-code: '1'

  deploy:
    runs-on: ubuntu-24.04
    needs: build
    environment:
      name: production
      url: https://app.example.com
    steps:
      - uses: actions/checkout@v4
      - name: Configure AWS OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gha-ecs-deploy
          aws-region: ${{ env.AWS_REGION }}
      - name: Render task definition
        id: task
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: deploy/task-def.prod.json
          container-name: app
          image: ${{ needs.build.outputs.image_tag }}
      - name: Deploy to ECS
        uses: aws-actions/amazon-ecs-deploy-task-definition@v2
        with:
          task-definition: ${{ steps.task.outputs.task-definition }}
          service: invoicing-api
          cluster: prod-cluster
          wait-for-service-stability: true
      - name: Smoke tests
        run: ./tools/smoke-tests.sh https://app.example.com

Ce qu'il faut retenir de ce workflow :

  • Concurrence avec cancel-in-progress sur PR : chaque nouveau push annule l'exécution précédente, on ne gâche pas les minutes CI.
  • Artefact vendor partagé entre jobs pour éviter N composer install.
  • Matrice PHP 8.2 / 8.3 / 8.4 et Symfony 6.4 LTS / 7.2 : on valide la compatibilité future dès maintenant.
  • Services Postgres et Redis containerisés dans le runner pour des tests d'intégration réalistes.
  • OIDC vers AWS plutôt que des access keys stockés dans les secrets : les rôles IAM sont assumés temporairement, aucun credential long terme n'est stocké dans GitHub.
  • Trivy scanne l'image Docker juste après le push ECR, avec exit-code: 1 sur CRITICAL/HIGH.
  • Environnement production : GitHub Environments impose les protections (approbateurs, branches autorisées, wait timer).

Matrice PHP / Symfony

Nous maintenons la matrice suivante sur tous les projets Symfony actifs.

PHP Symfony 6.4 LTS Symfony 7.2 Statut
8.2 Oui Non (7.x requiert 8.2 min) Validation LTS
8.3 Oui Oui Cible principale
8.4 Oui Oui Anticipation EOL

L'intérêt est double. D'un côté, on détecte rapidement les incompatibilités quand une nouvelle version de PHP ou Symfony sort. De l'autre, on montre aux responsables techniques que la trajectoire de migration est anticipée par la CI, pas improvisée au moment de la fin de support.

Stratégies de déploiement

Le choix de la stratégie dépend du SLO, du trafic et de la tolérance aux régressions.

  • Rolling update. Par défaut sur ECS. Les nouvelles tasks remplacent les anciennes progressivement, health check ALB. Simple, fiable, un peu de risque résiduel en cas de bug croisé entre versions.
  • Blue-green. Deux environnements cibles, bascule ALB. Zero downtime, rollback instantané. Coût : x2 sur le compute pendant la bascule. Recommandé pour les SLO > 99,95 %.
  • Canary. 5 % de trafic sur la nouvelle version, surveillance métriques 15 à 30 minutes, puis promotion progressive. Outils : AWS CodeDeploy canary, Argo Rollouts sur EKS, ou implémentation maison via poids ALB.
  • Feature flags. Unleash, Flagsmith ou LaunchDarkly. Le code nouveau est en prod depuis longtemps, mais désactivé. On active pour 1 %, 10 %, 100 % des utilisateurs. Permet de découpler déploiement et release.

Nous recommandons la combinaison rolling update + feature flags pour la majorité des apps B2B. Le zero downtime strict (blue-green ou canary) est justifié au-delà de 99,95 % de SLO ou sur des systèmes à revenu direct.

Gestion des secrets

Trois règles appliquées partout :

  1. Aucun secret dans le repo, même chiffré. .env en prod n'est pas dans git.
  2. OIDC pour l'accès AWS depuis GitHub Actions, pas d'AWS access keys dans les secrets GitHub.
  3. Secrets Manager pour les secrets runtime (DB password, API keys). Injection dans les tasks ECS via la task definition, jamais copiés ailleurs.

La configuration OIDC côté AWS est un rôle IAM avec un trust policy qui valide l'issuer GitHub et le repository. Exemple simplifié :

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:acme/invoicing-api:ref:refs/heads/main"
        }
      }
    }
  ]
}

Gates qualité

Le merge vers main exige :

  • Tous les checks passent : lint, static, test, security.
  • Couverture > 80 %, enforced par un script maison qui parse le clover.xml.
  • Review approuvée par un owner du module (CODEOWNERS).
  • Dependabot alerts résolues en critical et high.
  • Pas de commit direct sur main (branch protection activée).

Rollback

Un déploiement doit être roulable en arrière en moins de 5 minutes. Les deux leviers que nous installons systématiquement :

  • ECS previous task definition. Le workflow de rollback ressort la task def prod-N-1, pousse le service dessus, attend la stabilité. Un script d'une trentaine de lignes.
  • Migrations DB reversibles. Chaque migration Doctrine a sa méthode down() testée. Les migrations non reversibles (DROP COLUMN, data-loss) sont interdites hors fenêtre de maintenance.

Le rollback doit être testé tous les trimestres en staging sinon il ne fonctionnera pas le jour où on en aura besoin.

Observabilité post-déploiement

Après le ecs-deploy, le job deploy lance des smoke tests, puis nous monitorons les 30 minutes qui suivent.

  • Smoke tests : 10 à 20 requêtes HTTP sur les endpoints critiques. Bash + curl, ou script PHP. Échec → rollback automatique.
  • Canary analysis : comparaison des taux d'erreur et de la p95 avant/après sur une fenêtre glissante. Implémenté via CloudWatch Metrics + une Lambda, ou via Datadog release tracking.
  • Alerting : Slack et PagerDuty pour les alertes SLO, avec info de release (commit sha, auteur) dans le message.

DORA metrics

Les 4 métriques DORA sont les seules que nous demandons au comité de pilotage ops.

Métrique Cible élite Cible haute Notre baseline client type
Deploy frequency Plusieurs fois par jour Entre 1/jour et 1/semaine 2 à 5 par jour
Lead time for changes < 1 heure < 1 jour 4 à 8 heures
Change failure rate < 15 % < 15 % 5 à 10 %
Mean time to restore < 1 heure < 1 jour 20 à 60 minutes

Une pipeline saine permet de livrer plusieurs fois par jour sans peur. Une équipe qui met une semaine entre le merge et la prod a un problème de process, pas de technique.

Anti-patterns que nous refusons

  • Tests flaky tolérés. Un test qui passe 7 fois sur 10 pollue le signal. On isole, on corrige, on quarantine. On ne désactive pas.
  • Merge vers main sans PR. Même pour les hotfixes. La PR est l'unité de traçabilité.
  • Secrets en clair dans GitHub. Même dans les secrets non liés à la prod. Tout passe par un vault ou par OIDC.
  • Pas de rollback testé. Le rollback qui marche dans les slides ne marchera pas en crise.
  • Build non déterministe. composer install sans composer.lock commit, npm install sans package-lock.json, apt install sans version pin : reproductibilité cassée.
  • Pipeline qui dure > 20 minutes. Les devs contournent, poussent direct, sautent les PR. Budget CI = 15 minutes max sur la PR, 25 minutes max sur main.
  • Tests d'intégration qui parlent à la prod. Jamais. Jamais. Jamais.

Conclusion

Une pipeline CI/CD Symfony moderne tient dans un fichier GitHub Actions de 200 lignes, tourne en 10 minutes, couvre lint, analyse statique, tests multi-versions, sécurité, image Docker durcie et déploiement ECS via OIDC. Aucun secret long terme, aucune machine à maintenir, des DORA metrics qui se rapprochent du niveau élite.

Pour un audit de pipeline existant, une mise en place from scratch ou une bascule Jenkins → GitHub Actions, écrivez-nous à contact@your-digital-hub.com ou découvrez notre expertise PHP et nos prestations DevOps.