Pipeline CI/CD Symfony avec GitHub Actions : du commit à la production
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.
- Install.
composer installavec cache Composer et cache OPcache preload artefact. - Lint. PHP-CS-Fixer, Twig-CS-Fixer, YAML lint, Markdown lint, ESLint pour le front s'il existe.
- Static analysis. PHPStan level 8 ou 9, Psalm, Rector dry-run.
- Test. PHPUnit unit + integration, Behat sur les parcours critiques, PHPUnit fonctionnel, couverture > 80 %.
- Security.
composer audit,symfony security:check, Snyk, Trivy sur l'image Docker. - Build & push. Docker multi-stage vers ECR privé ou GHCR, tag
shaet tagenv. - 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-progresssur PR : chaque nouveau push annule l'exécution précédente, on ne gâche pas les minutes CI. - Artefact
vendorpartagé entre jobs pour éviter Ncomposer 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: 1sur 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 :
- Aucun secret dans le repo, même chiffré.
.enven prod n'est pas dans git. - OIDC pour l'accès AWS depuis GitHub Actions, pas d'AWS access keys dans les secrets GitHub.
- 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
mainsans 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 installsanscomposer.lockcommit,npm installsanspackage-lock.json,apt installsans 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.