Aller au contenu

Templates de gates

Implémentation exécutable du skill Gates qualité & sécurité. Le skill porte le pourquoi et où passe la ligne plancher/jalon ; la liste consolidée des outils porte le quoi et son verdict de veto ; cette page porte le comment.

Des exemples, pas un livrable

Ces fichiers sont des templates dont on s'inspire, à réinstancier selon les stacks réelles du projet — pas à copier tels quels. Ils ne tournent pas sur ce dépôt : le suffixe .example garantit qu'aucun runner ne les découvre comme de la CI active.

Les actions sont épinglées par SHA et les images par version à la date de rédaction. Les revérifier avant reprise — un SHA épinglé se périme, et sa mise à jour relève du cercle de réexamen de l'ADR.

GitHub Actions

Un workflow par stack. Les workflows gate-secrets et gate-container sont appelés par les autres via workflow_call — les copier tous les deux, quelle que soit la stack.

astro.yml
name: Astro — CI Foyer
# Structure identique à nextjs.yml (même écosystème npm) — seule la
# commande de build / le dossier de sortie changent.

on:
  push:
    branches: [main]
  pull_request:
  schedule:
    - cron: "0 3 * * 1"

jobs:
  gate-secrets:
    uses: ./.github/workflows/gate-secrets.yml

  build:
    needs: gate-secrets
    runs-on: ubuntu-latest
    outputs:
      image: ${{ steps.meta.outputs.image }}
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020  # v4.4.0
        with:
          node-version: 22
      - run: npm ci
      - run: npm run build
      - name: Build image
        id: meta
        run: |
          IMAGE="ghcr.io/${{ github.repository }}:${{ github.sha }}"
          docker build -t "$IMAGE" .
          echo "image=$IMAGE" >> "$GITHUB_OUTPUT"

  gate-sbom:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020  # v4.4.0
        with:
          node-version: 22
      - run: npx --yes @cyclonedx/cyclonedx-npm --output-format JSON --output-file npm-sbom.cdx.json
      - run: test -s npm-sbom.cdx.json
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02  # v4.6.2
        with:
          name: npm-sbom
          path: npm-sbom.cdx.json
          retention-days: 90

  gate-container:
    needs: build
    uses: ./.github/workflows/gate-container.yml
    with:
      image-ref: ${{ needs.build.outputs.image }}

  guidance-sast:
    needs: build
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - name: Opengrep scan
        run: |
          docker run --rm -v "$PWD:/src" opengrep/opengrep:v1.25.0 \
            scan --config auto --sarif --output /src/opengrep.sarif /src
      - uses: github/codeql-action/upload-sarif@b7351df727350dca84cb9d725d57dcf5bc82ba26  # v3.37.1
        if: always()
        with:
          sarif_file: opengrep.sarif

  guidance-eslint:
    needs: build
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020  # v4.4.0
        with:
          node-version: 22
      - run: npm ci
      - run: npx eslint . --format json --output-file eslint-report.json
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02  # v4.6.2
        if: always()
        with:
          name: eslint-report
          path: eslint-report.json

  guidance-ai-checklist:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
        with:
          fetch-depth: 0
      - run: |
          if git diff --name-only origin/main...HEAD | grep -qE '(ai|llm)/'; then
            echo "::notice::Chemin IA modifié — checklist de mise en service (TOOLS.md#ia) :"
            echo "::notice::- version de modèle et endpoint consignés dans la config versionnée"
            echo "::notice::- juridiction d'hébergement du modèle documentée"
            echo "::notice::- accès au journal d'interactions restreint, finalité déclarée"
            echo "::notice::- évaluation art. 6§3 AI Act documentée si fonction annexe III"
          fi
csharp.yml
name: C# / .NET — CI Foyer

on:
  push:
    branches: [main]
  pull_request:
  schedule:
    - cron: "0 3 * * 1"

jobs:
  gate-secrets:
    uses: ./.github/workflows/gate-secrets.yml

  build:
    needs: gate-secrets
    runs-on: ubuntu-latest
    outputs:
      image: ${{ steps.meta.outputs.image }}
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9  # v4.3.1
        with:
          dotnet-version: "8.0.x"
      - run: dotnet restore
      - run: dotnet build -c Release --no-restore
      - name: Build image
        id: meta
        run: |
          IMAGE="ghcr.io/${{ github.repository }}:${{ github.sha }}"
          docker build -t "$IMAGE" .
          echo "image=$IMAGE" >> "$GITHUB_OUTPUT"

  # GATE 2 — SBOM applicatif (bloquant)
  gate-sbom:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9  # v4.3.1
        with:
          dotnet-version: "8.0.x"
      - run: dotnet tool install --global CycloneDX
      - run: echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH"
      # Une solution unique est attendue. En cas d'ambiguïté, échouer plutôt que
      # prendre la première venue : un SBOM qui ne couvre qu'une partie du dépôt
      # sans le dire est pire qu'un gate rouge.
      - run: |
          SLN_COUNT=$(find . -maxdepth 2 -name "*.sln" | wc -l)
          if [ "$SLN_COUNT" -ne 1 ]; then
            echo "::error::$SLN_COUNT fichiers .sln trouvés — préciser explicitement lequel scanner."
            exit 1
          fi
          dotnet-CycloneDX "$(find . -maxdepth 2 -name '*.sln')" -o . --filename dotnet-sbom.cdx.json
      - run: test -s dotnet-sbom.cdx.json
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02  # v4.6.2
        with:
          name: dotnet-sbom
          path: dotnet-sbom.cdx.json
          retention-days: 90

  gate-container:
    needs: build
    uses: ./.github/workflows/gate-container.yml
    with:
      image-ref: ${{ needs.build.outputs.image }}

  guidance-sast:
    needs: build
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - name: Opengrep scan
        run: |
          docker run --rm -v "$PWD:/src" opengrep/opengrep:v1.25.0 \
            scan --config auto --sarif --output /src/opengrep.sarif /src
      - uses: github/codeql-action/upload-sarif@b7351df727350dca84cb9d725d57dcf5bc82ba26  # v3.37.1
        if: always()
        with:
          sarif_file: opengrep.sarif

  guidance-nuget-vulnerable:
    needs: build
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9  # v4.3.1
        with:
          dotnet-version: "8.0.x"
      - run: dotnet restore
      - run: dotnet list package --vulnerable --include-transitive | tee nuget-vulnerable-report.txt
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02  # v4.6.2
        if: always()
        with:
          name: nuget-vulnerable-report
          path: nuget-vulnerable-report.txt

  guidance-dotnet-format:
    needs: build
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9  # v4.3.1
        with:
          dotnet-version: "8.0.x"
      - run: dotnet format --verify-no-changes --report dotnet-format-report.json
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02  # v4.6.2
        if: always()
        with:
          name: dotnet-format-report
          path: dotnet-format-report.json

  guidance-ai-checklist:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
        with:
          fetch-depth: 0
      - run: |
          if git diff --name-only origin/main...HEAD | grep -qE '(ai|llm)/.*\.cs$'; then
            echo "::notice::Chemin IA modifié — checklist de mise en service (TOOLS.md#ia) :"
            echo "::notice::- version de modèle et endpoint consignés dans la config versionnée"
            echo "::notice::- juridiction d'hébergement du modèle documentée"
            echo "::notice::- accès au journal d'interactions restreint, finalité déclarée"
            echo "::notice::- évaluation art. 6§3 AI Act documentée si fonction annexe III"
          fi
gate-container.yml
name: Gate — Conteneur

on:
  workflow_call:
    inputs:
      image-ref:
        required: true
        type: string
        description: "Référence de l'image à scanner (ex. ghcr.io/org/app:sha)"

jobs:
  trivy:
    name: Trivy (bloquant)
    runs-on: ubuntu-latest
    steps:
      - name: SBOM image (CycloneDX) — informatif, ne bloque pas
        uses: aquasecurity/trivy-action@915b19bbe73b92a6cf82a1bc12b087c9a19a5fe2  # v0.28.0
        with:
          image-ref: ${{ inputs.image-ref }}
          format: cyclonedx
          output: trivy-sbom.cdx.json

      - name: Scan vulnérabilités — bloquant sur CRITICAL/HIGH
        uses: aquasecurity/trivy-action@915b19bbe73b92a6cf82a1bc12b087c9a19a5fe2  # v0.28.0
        with:
          image-ref: ${{ inputs.image-ref }}
          format: sarif
          output: trivy-results.sarif
          severity: CRITICAL,HIGH
          exit-code: "1"
          trivyignores: .trivyignore

      - name: Upload SARIF
        if: always()
        uses: github/codeql-action/upload-sarif@b7351df727350dca84cb9d725d57dcf5bc82ba26  # v3.37.1
        with:
          sarif_file: trivy-results.sarif

      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02  # v4.6.2
        if: always()
        with:
          name: trivy-sbom
          path: trivy-sbom.cdx.json
          retention-days: 90

  # Second avis, non bloquant. Nécessite les secrets DOCKER_HUB_USER /
  # DOCKER_HUB_TOKEN. Déclenché manuellement (workflow_dispatch) tant que
  # la question de l'altérité n'est pas tranchée pour ce dépôt — cf.
  # TOOLS.md. Ne pas activer par défaut sur workflow_call.
  docker-scout:
    name: Docker Scout (informatif, manuel)
    if: github.event_name == 'workflow_dispatch'
    runs-on: ubuntu-latest
    steps:
      - name: Docker Scout CVEs
        uses: docker/scout-action@2688993af7bafd6ba8c6a74ec652442be91dd82b  # v1.23.1
        with:
          command: cves
          image: ${{ inputs.image-ref }}
          sarif-file: scout.sarif
        continue-on-error: true
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02  # v4.6.2
        if: always()
        with:
          name: docker-scout-report
          path: scout.sarif
gate-secrets.yml
name: Gate — Secrets

on:
  workflow_call:

jobs:
  gitleaks:
    name: Gitleaks (bloquant)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
        with:
          fetch-depth: 0
      - name: Run Gitleaks
        uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7  # v2.3.9
        env:
          GITLEAKS_ENABLE_UPLOAD_ARTIFACT: "true"
          GITLEAKS_ENABLE_SUMMARY: "true"
        # L'action échoue (exit non nul) si un secret est détecté —
        # c'est le comportement voulu, ne pas ajouter continue-on-error.

  trufflehog-verified:
    name: TruffleHog — sweep vérifié (planifié uniquement)
    if: github.event_name == 'schedule'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
        with:
          fetch-depth: 0
      - name: TruffleHog verified scan
        uses: trufflesecurity/trufflehog@27b0417c16317ca9a472a9a8092acce143b49c55  # v3.95.9
        with:
          extra_args: --results=verified --fail
java.yml
name: Java — CI Foyer

on:
  push:
    branches: [main]
  pull_request:
  schedule:
    - cron: "0 3 * * 1"

jobs:
  gate-secrets:
    uses: ./.github/workflows/gate-secrets.yml

  build:
    needs: gate-secrets
    runs-on: ubuntu-latest
    outputs:
      image: ${{ steps.meta.outputs.image }}
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9  # v4.8.0
        with:
          distribution: temurin
          java-version: "21"
          cache: maven
      - run: mvn -B -DskipTests package
      - name: Build image
        id: meta
        run: |
          IMAGE="ghcr.io/${{ github.repository }}:${{ github.sha }}"
          docker build -t "$IMAGE" .
          echo "image=$IMAGE" >> "$GITHUB_OUTPUT"

  # GATE 2 — SBOM applicatif (bloquant)
  gate-sbom:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9  # v4.8.0
        with:
          distribution: temurin
          java-version: "21"
          cache: maven
      - run: mvn -B org.cyclonedx:cyclonedx-maven-plugin:makeAggregateBom
      # makeAggregateBom produit DÉJÀ l'agrégat à la racine du reactor. Un find
      # récursif ramassait en plus les BOM par module et, faute de -quit, chaque
      # cp écrasait le précédent : c'était le BOM du dernier module parcouru qui
      # était publié à la place de l'agrégat.
      - run: cp target/bom.json maven-sbom.cdx.json
      - run: test -s maven-sbom.cdx.json
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02  # v4.6.2
        with:
          name: maven-sbom
          path: maven-sbom.cdx.json
          retention-days: 90

  gate-container:
    needs: build
    uses: ./.github/workflows/gate-container.yml
    with:
      image-ref: ${{ needs.build.outputs.image }}

  guidance-sast:
    needs: build
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - name: Opengrep scan
        run: |
          docker run --rm -v "$PWD:/src" opengrep/opengrep:v1.25.0 \
            scan --config auto --sarif --output /src/opengrep.sarif /src
      - uses: github/codeql-action/upload-sarif@b7351df727350dca84cb9d725d57dcf5bc82ba26  # v3.37.1
        if: always()
        with:
          sarif_file: opengrep.sarif

  guidance-spotbugs:
    needs: build
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9  # v4.8.0
        with:
          distribution: temurin
          java-version: "21"
          cache: maven
      - run: mvn -B com.github.spotbugs:spotbugs-maven-plugin:check
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02  # v4.6.2
        if: always()
        with:
          name: spotbugs-report
          path: "**/spotbugsXml.xml"

  guidance-ai-checklist:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
        with:
          fetch-depth: 0
      - run: |
          if git diff --name-only origin/main...HEAD | grep -qE '(ai|llm)/.*\.java$'; then
            echo "::notice::Chemin IA modifié — checklist de mise en service (TOOLS.md#ia) :"
            echo "::notice::- version de modèle et endpoint consignés dans la config versionnée"
            echo "::notice::- juridiction d'hébergement du modèle documentée"
            echo "::notice::- accès au journal d'interactions restreint, finalité déclarée"
            echo "::notice::- évaluation art. 6§3 AI Act documentée si fonction annexe III"
          fi
nextjs.yml
name: Next.js — CI Foyer

on:
  push:
    branches: [main]
  pull_request:
  schedule:
    - cron: "0 3 * * 1" # sweep secrets vérifiés, chaque lundi

jobs:
  gate-secrets:
    uses: ./.github/workflows/gate-secrets.yml

  build:
    needs: gate-secrets
    runs-on: ubuntu-latest
    outputs:
      image: ${{ steps.meta.outputs.image }}
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020  # v4.4.0
        with:
          node-version: 22
      - run: npm ci
      - run: npm run build
      - name: Build image
        id: meta
        run: |
          IMAGE="ghcr.io/${{ github.repository }}:${{ github.sha }}"
          docker build -t "$IMAGE" .
          echo "image=$IMAGE" >> "$GITHUB_OUTPUT"

  # GATE 2 — SBOM applicatif (bloquant : job échoue si le fichier est vide)
  gate-sbom:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020  # v4.4.0
        with:
          node-version: 22
      - run: npx --yes @cyclonedx/cyclonedx-npm --output-format JSON --output-file npm-sbom.cdx.json
      - run: test -s npm-sbom.cdx.json
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02  # v4.6.2
        with:
          name: npm-sbom
          path: npm-sbom.cdx.json
          retention-days: 90

  gate-container:
    needs: build
    uses: ./.github/workflows/gate-container.yml
    with:
      image-ref: ${{ needs.build.outputs.image }}

  guidance-sast:
    needs: build
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - name: Opengrep scan
        run: |
          docker run --rm -v "$PWD:/src" opengrep/opengrep:v1.25.0 \
            scan --config auto --sarif --output /src/opengrep.sarif /src
      - uses: github/codeql-action/upload-sarif@b7351df727350dca84cb9d725d57dcf5bc82ba26  # v3.37.1
        if: always()
        with:
          sarif_file: opengrep.sarif

  guidance-eslint:
    needs: build
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020  # v4.4.0
        with:
          node-version: 22
      - run: npm ci
      - run: npx eslint . --format json --output-file eslint-report.json
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02  # v4.6.2
        if: always()
        with:
          name: eslint-report
          path: eslint-report.json

  # Rappel non bloquant, uniquement si des chemins IA sont touchés.
  guidance-ai-checklist:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
        with:
          fetch-depth: 0
      - name: Vérifier les chemins modifiés
        run: |
          if git diff --name-only origin/main...HEAD | grep -qE '(ai|llm)/'; then
            echo "::notice::Chemin IA modifié — checklist de mise en service (TOOLS.md#ia) :"
            echo "::notice::- version de modèle et endpoint consignés dans la config versionnée"
            echo "::notice::- juridiction d'hébergement du modèle documentée"
            echo "::notice::- accès au journal d'interactions restreint, finalité déclarée"
            echo "::notice::- évaluation art. 6§3 AI Act documentée si fonction annexe III"
          fi
php.yml
name: PHP (Symfony/Drupal) — CI Foyer

on:
  push:
    branches: [main]
  pull_request:
  schedule:
    - cron: "0 3 * * 1"

jobs:
  gate-secrets:
    uses: ./.github/workflows/gate-secrets.yml

  build:
    needs: gate-secrets
    runs-on: ubuntu-latest
    outputs:
      image: ${{ steps.meta.outputs.image }}
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240  # 2.37.2
        with:
          php-version: "8.3"
      - run: composer install --no-dev --optimize-autoloader --no-interaction
      - name: Build image
        id: meta
        run: |
          IMAGE="ghcr.io/${{ github.repository }}:${{ github.sha }}"
          docker build -t "$IMAGE" .
          echo "image=$IMAGE" >> "$GITHUB_OUTPUT"

  # GATE 2 — SBOM applicatif (bloquant)
  gate-sbom:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240  # 2.37.2
        with:
          php-version: "8.3"
      - run: composer global require cyclonedx/cyclonedx-php-composer --no-interaction
      - run: |
          echo "$(composer global config bin-dir --absolute)" >> "$GITHUB_PATH"
      - run: composer CycloneDX:make-sbom --output-format=JSON --output-file=php-sbom.cdx.json --omit=dev
      - run: test -s php-sbom.cdx.json
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02  # v4.6.2
        with:
          name: php-sbom
          path: php-sbom.cdx.json
          retention-days: 90

  gate-container:
    needs: build
    uses: ./.github/workflows/gate-container.yml
    with:
      image-ref: ${{ needs.build.outputs.image }}

  guidance-sast:
    needs: build
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - name: Opengrep scan
        run: |
          docker run --rm -v "$PWD:/src" opengrep/opengrep:v1.25.0 \
            scan --config auto --sarif --output /src/opengrep.sarif /src
      - uses: github/codeql-action/upload-sarif@b7351df727350dca84cb9d725d57dcf5bc82ba26  # v3.37.1
        if: always()
        with:
          sarif_file: opengrep.sarif

  guidance-composer-audit:
    needs: build
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240  # 2.37.2
        with:
          php-version: "8.3"
      - run: composer audit --locked --format=json > composer-audit-report.json
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02  # v4.6.2
        if: always()
        with:
          name: composer-audit-report
          path: composer-audit-report.json

  # phpcs — PSR-12 par défaut. Pour Drupal, remplacer --standard=PSR12
  # par --standard=Drupal,DrupalPractice (nécessite drupal/coder en
  # dépendance dev du projet).
  guidance-phpcs:
    needs: build
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240  # 2.37.2
        with:
          php-version: "8.3"
          tools: cs2pr, phpcs
      - run: phpcs --standard=PSR12 --report=json --report-file=phpcs-report.json src/
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02  # v4.6.2
        if: always()
        with:
          name: phpcs-report
          path: phpcs-report.json

  # Actif uniquement si composer.json référence Drupal.
  guidance-drupal-check:
    needs: build
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - id: is-drupal
        run: |
          if grep -q "drupal/core" composer.json 2>/dev/null; then
            echo "drupal=true" >> "$GITHUB_OUTPUT"
          else
            echo "drupal=false" >> "$GITHUB_OUTPUT"
          fi
      - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240  # 2.37.2
        if: steps.is-drupal.outputs.drupal == 'true'
        with:
          php-version: "8.3"
      - if: steps.is-drupal.outputs.drupal == 'true'
        run: |
          composer global require mglaman/drupal-check --no-interaction
          echo "$(composer global config bin-dir --absolute)" >> "$GITHUB_PATH"
          drupal-check --deprecations web/modules/custom web/themes/custom

  guidance-ai-checklist:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
        with:
          fetch-depth: 0
      - run: |
          if git diff --name-only origin/main...HEAD | grep -qE '(ai|llm)/.*\.php$'; then
            echo "::notice::Chemin IA modifié — checklist de mise en service (TOOLS.md#ia) :"
            echo "::notice::- version de modèle et endpoint consignés dans la config versionnée"
            echo "::notice::- juridiction d'hébergement du modèle documentée"
            echo "::notice::- accès au journal d'interactions restreint, finalité déclarée"
            echo "::notice::- évaluation art. 6§3 AI Act documentée si fonction annexe III"
          fi
postgresql-migrations.yml
name: PostgreSQL migrations — Gate Foyer
# Convention : migrations/<horodatage>_<nom>.up.sql / .down.sql
# Ajuster MIGRATIONS_DIR et le motif de nommage selon l'outil de
# migration utilisé (node-pg-migrate, sqlx, diesel, Flyway, Sqitch...).

on:
  pull_request:
    paths:
      - "migrations/**"
  push:
    branches: [main]
    paths:
      - "migrations/**"

env:
  MIGRATIONS_DIR: migrations

jobs:
  # GATE — irréversibilité des migrations (bloquant)
  # Une migration sans script de retour est un fait binaire, pas un
  # jugement — et son absence devient irréversible une fois jouée en
  # production. Même critère que les gates du document de cadrage.
  gate-migrations-reversibility:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - name: Vérifier la présence d'un script de retour pour chaque migration
        run: |
          missing=0
          for up in $(find "$MIGRATIONS_DIR" -name "*.up.sql"); do
            down="${up%.up.sql}.down.sql"
            if [ ! -f "$down" ]; then
              echo "::error::Manquant : $down (pour $up)"
              missing=$((missing + 1))
            fi
          done
          if [ "$missing" -gt 0 ]; then
            echo "$missing migration(s) sans script de retour."
            exit 1
          fi
          echo "Toutes les migrations ont un script de retour."

  guidance-sqlfluff:
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065  # v5.6.0
        with:
          python-version: "3.12"
      - run: pip install --no-cache-dir sqlfluff
      - run: sqlfluff lint "$MIGRATIONS_DIR" --dialect postgres

  # Dry-run réel sur une instance éphémère. Non bloquant (cf. TOOLS.md) :
  # une migration cassée se corrige avant merge, ce n'est pas une perte
  # d'information irréversible comme les quatre gates du cadrage.
  guidance-migration-dry-run:
    runs-on: ubuntu-latest
    continue-on-error: true
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_PASSWORD: ci
          POSTGRES_DB: dryrun
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 5s
          --health-timeout 5s
          --health-retries 10
    env:
      PGHOST: localhost
      PGUSER: postgres
      PGPASSWORD: ci
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - name: Appliquer puis annuler toutes les migrations
        run: |
          for up in $(find "$MIGRATIONS_DIR" -name "*.up.sql" | sort); do
            echo "Application : $up"
            psql -d dryrun -f "$up"
          done
          for down in $(find "$MIGRATIONS_DIR" -name "*.down.sql" | sort -r); do
            echo "Retour : $down"
            psql -d dryrun -f "$down"
          done
powershell.yml
name: PowerShell — CI Foyer
# Un dépôt PowerShell n'est pas systématiquement conteneurisé — voir
# gitlab/.gitlab-ci.powershell.yml pour la justification complète.
# Le gate conteneur ne s'exécute que si un Dockerfile est présent.

on:
  push:
    branches: [main]
  pull_request:
  schedule:
    - cron: "0 3 * * 1"

jobs:
  gate-secrets:
    uses: ./.github/workflows/gate-secrets.yml

  build:
    needs: gate-secrets
    runs-on: ubuntu-latest
    outputs:
      has-dockerfile: ${{ steps.check.outputs.exists }}
      image: ${{ steps.meta.outputs.image }}
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - name: Vérifier la syntaxe des scripts
        shell: pwsh
        run: |
          $errors = $null
          Get-ChildItem -Recurse -Filter *.ps1 | ForEach-Object {
            [System.Management.Automation.Language.Parser]::ParseFile($_.FullName, [ref]$null, [ref]$errors) | Out-Null
            if ($errors) { throw "Erreur de syntaxe : $($_.FullName)" }
          }
      - id: check
        run: |
          if [ -f Dockerfile ]; then echo "exists=true" >> "$GITHUB_OUTPUT"; else echo "exists=false" >> "$GITHUB_OUTPUT"; fi
      - name: Build image (si Dockerfile présent)
        id: meta
        if: steps.check.outputs.exists == 'true'
        run: |
          IMAGE="ghcr.io/${{ github.repository }}:${{ github.sha }}"
          docker build -t "$IMAGE" .
          echo "image=$IMAGE" >> "$GITHUB_OUTPUT"

  gate-container:
    needs: build
    if: needs.build.outputs.has-dockerfile == 'true'
    uses: ./.github/workflows/gate-container.yml
    with:
      image-ref: ${{ needs.build.outputs.image }}

  guidance-psscriptanalyzer:
    needs: build
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - shell: pwsh
        run: |
          Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser -Repository PSGallery
          $results = Invoke-ScriptAnalyzer -Path . -Recurse -Severity Warning,Error
          $results | ConvertTo-Json -Depth 4 | Out-File psscriptanalyzer-report.json
          $results | Format-Table -AutoSize
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02  # v4.6.2
        if: always()
        with:
          name: psscriptanalyzer-report
          path: psscriptanalyzer-report.json

  guidance-pester:
    needs: build
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - shell: pwsh
        run: |
          if (-not (Get-ChildItem -Recurse -Filter *.Tests.ps1 -ErrorAction SilentlyContinue)) {
            Write-Host "Aucun test Pester trouvé."
            exit 0
          }
          Install-Module -Name Pester -Force -Scope CurrentUser -MinimumVersion 5.0
          Invoke-Pester -CI

  guidance-ai-checklist:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
        with:
          fetch-depth: 0
      - run: |
          if git diff --name-only origin/main...HEAD | grep -qE '(ai|llm)/.*\.ps1$'; then
            echo "::notice::Chemin IA modifié — checklist de mise en service (TOOLS.md#ia) :"
            echo "::notice::- version de modèle et endpoint consignés dans la config versionnée"
            echo "::notice::- juridiction d'hébergement du modèle documentée"
            echo "::notice::- accès au journal d'interactions restreint, finalité déclarée"
            echo "::notice::- évaluation art. 6§3 AI Act documentée si fonction annexe III"
          fi
python.yml
name: Python — CI Foyer

on:
  push:
    branches: [main]
  pull_request:
  schedule:
    - cron: "0 3 * * 1"

jobs:
  gate-secrets:
    uses: ./.github/workflows/gate-secrets.yml

  build:
    needs: gate-secrets
    runs-on: ubuntu-latest
    outputs:
      image: ${{ steps.meta.outputs.image }}
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065  # v5.6.0
        with:
          python-version: "3.12"
      - run: pip install --no-cache-dir -r requirements.txt
      - name: Build image
        id: meta
        run: |
          IMAGE="ghcr.io/${{ github.repository }}:${{ github.sha }}"
          docker build -t "$IMAGE" .
          echo "image=$IMAGE" >> "$GITHUB_OUTPUT"

  # GATE 2 — SBOM applicatif (bloquant)
  gate-sbom:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065  # v5.6.0
        with:
          python-version: "3.12"
      - run: pip install --no-cache-dir cyclonedx-bom
      - run: cyclonedx-py requirements -o python-sbom.cdx.json requirements.txt
      - run: test -s python-sbom.cdx.json
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02  # v4.6.2
        with:
          name: python-sbom
          path: python-sbom.cdx.json
          retention-days: 90

  gate-container:
    needs: build
    uses: ./.github/workflows/gate-container.yml
    with:
      image-ref: ${{ needs.build.outputs.image }}

  guidance-sast:
    needs: build
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - name: Opengrep scan
        run: |
          docker run --rm -v "$PWD:/src" opengrep/opengrep:v1.25.0 \
            scan --config auto --sarif --output /src/opengrep.sarif /src
      - uses: github/codeql-action/upload-sarif@b7351df727350dca84cb9d725d57dcf5bc82ba26  # v3.37.1
        if: always()
        with:
          sarif_file: opengrep.sarif

  guidance-bandit:
    needs: build
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065  # v5.6.0
        with:
          python-version: "3.12"
      - run: pip install --no-cache-dir "bandit[sarif]"
      - run: bandit -r . -f sarif -o bandit.sarif
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02  # v4.6.2
        if: always()
        with:
          name: bandit-report
          path: bandit.sarif

  guidance-ruff:
    needs: build
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065  # v5.6.0
        with:
          python-version: "3.12"
      - run: pip install --no-cache-dir ruff
      - run: ruff check . --output-format=json > ruff-report.json
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02  # v4.6.2
        if: always()
        with:
          name: ruff-report
          path: ruff-report.json

  guidance-ai-checklist:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
        with:
          fetch-depth: 0
      - run: |
          if git diff --name-only origin/main...HEAD | grep -qE '(ai|llm)/.*\.py$'; then
            echo "::notice::Chemin IA modifié — checklist de mise en service (TOOLS.md#ia) :"
            echo "::notice::- version de modèle et endpoint consignés dans la config versionnée"
            echo "::notice::- juridiction d'hébergement du modèle documentée"
            echo "::notice::- accès au journal d'interactions restreint, finalité déclarée"
            echo "::notice::- évaluation art. 6§3 AI Act documentée si fonction annexe III"
          fi
rust.yml
name: Rust — CI Foyer

on:
  push:
    branches: [main]
  pull_request:
  schedule:
    - cron: "0 3 * * 1"

jobs:
  gate-secrets:
    uses: ./.github/workflows/gate-secrets.yml

  build:
    needs: gate-secrets
    runs-on: ubuntu-latest
    outputs:
      image: ${{ steps.meta.outputs.image }}
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      # Exception d'épinglage assumée : chez cette action, la ref n'est pas une
      # version mais le SÉLECTEUR de toolchain (stable/beta/nightly). L'épingler
      # par SHA lui ferait perdre son sens. Cf. TOOLS.md § Épinglage.
      - uses: dtolnay/rust-toolchain@stable
      - run: cargo build --release --locked
      - name: Build image
        id: meta
        run: |
          IMAGE="ghcr.io/${{ github.repository }}:${{ github.sha }}"
          docker build -t "$IMAGE" .
          echo "image=$IMAGE" >> "$GITHUB_OUTPUT"

  # GATE 2 — SBOM applicatif (bloquant)
  gate-sbom:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      # Exception d'épinglage assumée : chez cette action, la ref n'est pas une
      # version mais le SÉLECTEUR de toolchain (stable/beta/nightly). L'épingler
      # par SHA lui ferait perdre son sens. Cf. TOOLS.md § Épinglage.
      - uses: dtolnay/rust-toolchain@stable
      - run: cargo install cargo-cyclonedx --locked
      - run: cargo cyclonedx --format json --all --output-cdx
      # Un workspace produit un SBOM PAR CRATE. Les collecter tous, en
      # conservant l'arborescence : n'en garder qu'un (find … -quit) publierait
      # la nomenclature d'un seul crate en la présentant comme celle du dépôt,
      # ce qui vide le gate de sa fonction sans le signaler.
      - run: |
          mkdir -p sbom
          find . -name "*.cdx.json" -not -path "./sbom/*" -exec cp --parents {} sbom/ \;
          test -n "$(ls -A sbom)"
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02  # v4.6.2
        with:
          name: rust-sbom
          path: sbom/
          retention-days: 90

  gate-container:
    needs: build
    uses: ./.github/workflows/gate-container.yml
    with:
      image-ref: ${{ needs.build.outputs.image }}

  guidance-sast:
    needs: build
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - name: Opengrep scan
        run: |
          docker run --rm -v "$PWD:/src" opengrep/opengrep:v1.25.0 \
            scan --config auto --sarif --output /src/opengrep.sarif /src
      - uses: github/codeql-action/upload-sarif@b7351df727350dca84cb9d725d57dcf5bc82ba26  # v3.37.1
        if: always()
        with:
          sarif_file: opengrep.sarif

  guidance-clippy:
    needs: build
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      # Exception d'épinglage assumée : chez cette action, la ref n'est pas une
      # version mais le SÉLECTEUR de toolchain (stable/beta/nightly). L'épingler
      # par SHA lui ferait perdre son sens. Cf. TOOLS.md § Épinglage.
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: clippy
      - run: cargo clippy --message-format=json --all-targets > clippy-report.json
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02  # v4.6.2
        if: always()
        with:
          name: clippy-report
          path: clippy-report.json

  guidance-cargo-audit:
    needs: build
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      # Exception d'épinglage assumée : chez cette action, la ref n'est pas une
      # version mais le SÉLECTEUR de toolchain (stable/beta/nightly). L'épingler
      # par SHA lui ferait perdre son sens. Cf. TOOLS.md § Épinglage.
      - uses: dtolnay/rust-toolchain@stable
      - run: cargo install cargo-audit --locked
      - run: cargo audit --json > cargo-audit-report.json
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02  # v4.6.2
        if: always()
        with:
          name: cargo-audit-report
          path: cargo-audit-report.json

  guidance-ai-checklist:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
        with:
          fetch-depth: 0
      - run: |
          if git diff --name-only origin/main...HEAD | grep -qE '(ai|llm)/.*\.rs$'; then
            echo "::notice::Chemin IA modifié — checklist de mise en service (TOOLS.md#ia) :"
            echo "::notice::- version de modèle et endpoint consignés dans la config versionnée"
            echo "::notice::- juridiction d'hébergement du modèle documentée"
            echo "::notice::- accès au journal d'interactions restreint, finalité déclarée"
            echo "::notice::- évaluation art. 6§3 AI Act documentée si fonction annexe III"
          fi
svelte.yml
name: Svelte — CI Foyer
# Structure identique à nextjs.yml (même écosystème npm) — seule la
# commande de build / le dossier de sortie changent.

on:
  push:
    branches: [main]
  pull_request:
  schedule:
    - cron: "0 3 * * 1"

jobs:
  gate-secrets:
    uses: ./.github/workflows/gate-secrets.yml

  build:
    needs: gate-secrets
    runs-on: ubuntu-latest
    outputs:
      image: ${{ steps.meta.outputs.image }}
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020  # v4.4.0
        with:
          node-version: 22
      - run: npm ci
      - run: npm run build
      - name: Build image
        id: meta
        run: |
          IMAGE="ghcr.io/${{ github.repository }}:${{ github.sha }}"
          docker build -t "$IMAGE" .
          echo "image=$IMAGE" >> "$GITHUB_OUTPUT"

  gate-sbom:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020  # v4.4.0
        with:
          node-version: 22
      - run: npx --yes @cyclonedx/cyclonedx-npm --output-format JSON --output-file npm-sbom.cdx.json
      - run: test -s npm-sbom.cdx.json
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02  # v4.6.2
        with:
          name: npm-sbom
          path: npm-sbom.cdx.json
          retention-days: 90

  gate-container:
    needs: build
    uses: ./.github/workflows/gate-container.yml
    with:
      image-ref: ${{ needs.build.outputs.image }}

  guidance-sast:
    needs: build
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - name: Opengrep scan
        run: |
          docker run --rm -v "$PWD:/src" opengrep/opengrep:v1.25.0 \
            scan --config auto --sarif --output /src/opengrep.sarif /src
      - uses: github/codeql-action/upload-sarif@b7351df727350dca84cb9d725d57dcf5bc82ba26  # v3.37.1
        if: always()
        with:
          sarif_file: opengrep.sarif

  guidance-eslint:
    needs: build
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020  # v4.4.0
        with:
          node-version: 22
      - run: npm ci
      - run: npx eslint . --format json --output-file eslint-report.json
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02  # v4.6.2
        if: always()
        with:
          name: eslint-report
          path: eslint-report.json

  guidance-ai-checklist:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262  # v4.4.0
        with:
          fetch-depth: 0
      - run: |
          if git diff --name-only origin/main...HEAD | grep -qE '(ai|llm)/'; then
            echo "::notice::Chemin IA modifié — checklist de mise en service (TOOLS.md#ia) :"
            echo "::notice::- version de modèle et endpoint consignés dans la config versionnée"
            echo "::notice::- juridiction d'hébergement du modèle documentée"
            echo "::notice::- accès au journal d'interactions restreint, finalité déclarée"
            echo "::notice::- évaluation art. 6§3 AI Act documentée si fonction annexe III"
          fi

GitLab CI

.gitlab-ci.gates.yml porte les gates communs et les variables de version ; il s'inclut avec le fichier de la stack. .gitlab-ci.root.yml montre le .gitlab-ci.yml racine résultant.

.gitlab-ci.astro.yml
# Stack Astro — à inclure avec .gitlab-ci.gates.yml
# include:
#   - local: 'gitlab/.gitlab-ci.gates.yml'
#   - local: 'gitlab/.gitlab-ci.astro.yml'
#
# Structure identique au template Next.js (même écosystème npm) —
# seule la commande de build change. Voir .gitlab-ci.nextjs.yml pour les
# commentaires détaillés sur chaque job.

variables:
  NODE_IMAGE: "node:22-slim"

build:astro:
  stage: build
  image: ${NODE_IMAGE}
  script:
    - npm ci
    - npm run build   # astro build
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
  artifacts:
    paths:
      - dist/
    expire_in: 1 day

gate:sbom-npm:
  stage: gate-sbom
  image: ${NODE_IMAGE}
  script:
    - npx --yes @cyclonedx/cyclonedx-npm --output-format JSON --output-file npm-sbom.cdx.json
    - test -s npm-sbom.cdx.json
  artifacts:
    paths:
      - npm-sbom.cdx.json
    expire_in: 90 days
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

guidance:sast-opengrep:
  stage: guidance
  image: opengrep/opengrep:v${OPENGREP_VERSION}
  script:
    - opengrep scan --config auto --sarif --output opengrep.sarif .
  artifacts:
    when: always
    reports:
      sast: opengrep.sarif
  allow_failure: true

guidance:eslint:
  stage: guidance
  image: ${NODE_IMAGE}
  script:
    - npm ci
    - npx eslint . --format json --output-file eslint-report.json || true
  artifacts:
    when: always
    paths:
      - eslint-report.json
  allow_failure: true

guidance:ai-provenance-checklist:
  stage: guidance
  image: alpine:3.20
  script:
    - echo "Rappel — vérifier avant merge (voir TOOLS.md#ia) :"
    - echo "- version de modèle et endpoint consignés dans la config versionnée"
    - echo "- juridiction d'hébergement du modèle documentée"
    - echo "- accès au journal d'interactions restreint, finalité déclarée"
    - echo "- évaluation art. 6§3 AI Act documentée si fonction annexe III"
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - "**/ai/**/*"
        - "**/llm/**/*"
  allow_failure: true
.gitlab-ci.csharp.yml
# Stack C# / .NET — à inclure avec .gitlab-ci.gates.yml
# include:
#   - local: 'gitlab/.gitlab-ci.gates.yml'
#   - local: 'gitlab/.gitlab-ci.csharp.yml'

variables:
  DOTNET_IMAGE: "mcr.microsoft.com/dotnet/sdk:8.0"

build:csharp:
  stage: build
  image: ${DOTNET_IMAGE}
  script:
    - dotnet restore
    - dotnet build -c Release --no-restore
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
  artifacts:
    paths:
      - "**/bin/Release/"
    expire_in: 1 day

# GATE 2 — SBOM applicatif (bloquant)
gate:sbom-dotnet:
  stage: gate-sbom
  image: ${DOTNET_IMAGE}
  script:
    - dotnet tool install --global CycloneDX
    - export PATH="$PATH:/root/.dotnet/tools"
    # Une solution unique est attendue. En cas d'ambiguïté, échouer plutôt que
    # prendre la première venue : un SBOM qui ne couvre qu'une partie du dépôt
    # sans le dire est pire qu'un gate rouge.
    - |
      SLN_COUNT=$(find . -maxdepth 2 -name "*.sln" | wc -l)
      if [ "$SLN_COUNT" -ne 1 ]; then
        echo "$SLN_COUNT fichiers .sln trouvés — préciser explicitement lequel scanner." >&2
        exit 1
      fi
    - dotnet-CycloneDX "$(find . -maxdepth 2 -name '*.sln')" -o . --filename dotnet-sbom.cdx.json
    - test -s dotnet-sbom.cdx.json
  artifacts:
    paths:
      - dotnet-sbom.cdx.json
    expire_in: 90 days
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

# GUIDANCE — SAST, non bloquant
guidance:sast-opengrep:
  stage: guidance
  image: opengrep/opengrep:v${OPENGREP_VERSION}
  script:
    - opengrep scan --config auto --sarif --output opengrep.sarif .
  artifacts:
    when: always
    reports:
      sast: opengrep.sarif
  allow_failure: true

# Vulnérabilités NuGet — commande native du SDK (base GitHub Advisory
# Database). Ne renvoie pas de code d'erreur par elle-même (limite connue
# de l'outil) ; capturée ici en rapport informatif plutôt que bloquante,
# cohérent avec le traitement des autres audits de dépendances de ce
# dossier (cargo-audit, composer audit) : triage humain requis.
guidance:nuget-vulnerable:
  stage: guidance
  image: ${DOTNET_IMAGE}
  script:
    - dotnet restore
    - dotnet list package --vulnerable --include-transitive > nuget-vulnerable-report.txt || true
    - cat nuget-vulnerable-report.txt
  artifacts:
    when: always
    paths:
      - nuget-vulnerable-report.txt
  allow_failure: true

guidance:dotnet-format:
  stage: guidance
  image: ${DOTNET_IMAGE}
  script:
    - dotnet format --verify-no-changes --report dotnet-format-report.json || true
  artifacts:
    when: always
    paths:
      - dotnet-format-report.json
  allow_failure: true

guidance:ai-provenance-checklist:
  stage: guidance
  image: alpine:3.20
  script:
    - echo "Rappel — vérifier avant merge (voir TOOLS.md#ia) :"
    - echo "- version de modèle et endpoint consignés dans la config versionnée"
    - echo "- juridiction d'hébergement du modèle documentée"
    - echo "- accès au journal d'interactions restreint, finalité déclarée"
    - echo "- évaluation art. 6§3 AI Act documentée si fonction annexe III"
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - "**/ai/**/*.cs"
        - "**/llm/**/*.cs"
  allow_failure: true
.gitlab-ci.gates.yml
# Gates communs — esprit Foyer
# À inclure dans le .gitlab-ci.yml du projet :
#   include:
#     - local: 'gitlab/.gitlab-ci.gates.yml'
#     - local: 'gitlab/.gitlab-ci.<stack>.yml'
#
# Ces jobs sont BLOQUANTS (allow_failure: false, valeur par défaut).
# Ne jamais les repasser en allow_failure: true sans repasser par la
# section 6 du document de cadrage — ce sont les seuls gates dont
# l'irréversibilité justifie le blocage.

stages:
  - gate-secrets
  - build
  - gate-sbom
  - gate-container
  - guidance
  - publish

variables:
  TRIVY_VERSION: "0.56.2"
  GITLEAKS_VERSION: "8.21.2"
  OPENGREP_VERSION: "1.25.0"
  TRUFFLEHOG_VERSION: "3.95.9"

# ---------------------------------------------------------------------------
# GATE 1 — Secrets (bloquant)
# ---------------------------------------------------------------------------
gate:secrets:
  stage: gate-secrets
  image: zricethezav/gitleaks:v${GITLEAKS_VERSION}
  script:
    - gitleaks detect --source . --report-format sarif --report-path gitleaks.sarif --exit-code 1
  artifacts:
    when: always
    reports:
      sast: gitleaks.sarif
    paths:
      - gitleaks.sarif
    expire_in: 30 days
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'

# Sweep périodique avec vérification de vivacité — job planifié (CI/CD
# Schedules), pas sur chaque commit : plus lent (appels réseau au
# fournisseur du secret pour confirmer qu'il est actif).
gate:secrets-verified:
  stage: gate-secrets
  image: trufflesecurity/trufflehog:v${TRUFFLEHOG_VERSION}
  script:
    - trufflehog git file://. --since-commit HEAD~200 --results=verified --fail
  rules:
    - if: '$CI_PIPELINE_SOURCE == "schedule"'
  allow_failure: false

# ---------------------------------------------------------------------------
# GATE 3 — Vulnérabilités image conteneur (bloquant)
# Suppose que le job `build` du template de stack produit une image locale
# taggée $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA avant ce stage.
# ---------------------------------------------------------------------------
gate:container:
  stage: gate-container
  image: aquasec/trivy:${TRIVY_VERSION}
  script:
    # SBOM de l'image, format CycloneDX — alimente l'agrégateur SBOM
    - trivy image --format cyclonedx --output trivy-sbom.cdx.json
        $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
    # Gate bloquant : échec si CRITICAL ou HIGH non couvert par une exception
    - trivy image --severity CRITICAL,HIGH --exit-code 1
        --ignorefile .trivyignore
        $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
  artifacts:
    when: always
    paths:
      - trivy-sbom.cdx.json
    expire_in: 90 days
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

# Second avis, non bloquant — base de vulnérabilités différente de Trivy.
# Nécessite DOCKER_SCOUT_HUB_USER / DOCKER_SCOUT_HUB_PASSWORD en variable
# CI protégée. Désactivé par défaut (rules: when: manual) tant que la
# question de l'altérité (image envoyée à un tiers) n'a pas été tranchée
# pour les dépôts contenant du code non public — cf. TOOLS.md.
guidance:docker-scout:
  stage: guidance
  image: docker:24
  services:
    - docker:24-dind
  script:
    - docker scout cves $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA --format sarif --output scout.sarif || true
  artifacts:
    when: always
    paths:
      - scout.sarif
  allow_failure: true
  rules:
    - when: manual

# Provenance IA — rappel de checklist, jamais bloquant. Aucun outil ne peut
# objectiver une dérogation art. 6§3 : c'est un jugement sur la finalité, pas
# un fait détectable dans un diff. Ce job affiche la checklist quand une MR
# touche un chemin IA ; c'est un humain qui la tient (cf. TOOLS.md#ia).
guidance:ai-checklist:
  stage: guidance
  image: alpine:3.20
  variables:
    GIT_DEPTH: 0   # le clone superficiel par défaut ne permet pas le diff à la base
  before_script:
    - apk add --no-cache git
  script:
    - |
      if git diff --name-only "origin/$CI_DEFAULT_BRANCH...HEAD" | grep -qE '(ai|llm)/'; then
        echo "Chemin IA modifié — checklist de mise en service :"
        echo "- version de modèle et endpoint consignés dans la config versionnée"
        echo "- juridiction d'hébergement du modèle documentée"
        echo "- accès au journal d'interactions restreint, finalité déclarée"
        echo "- évaluation art. 6§3 AI Act documentée si fonction annexe III"
      fi
  allow_failure: true
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

# ---------------------------------------------------------------------------
# Publication vers l'agrégateur central — désactivé tant que DefectDojo /
# Dependency-Track ne sont pas déployés (cf. TOOLS.md). Décommenter et
# renseigner DEFECTDOJO_URL / DEFECTDOJO_TOKEN en variable CI protégée.
# ---------------------------------------------------------------------------
# publish:aggregator:
#   stage: publish
#   image: curlimages/curl:8.10.1
#   script:
#     - >
#       curl -sf -X POST "$DEFECTDOJO_URL/api/v2/import-scan/"
#       -H "Authorization: Token $DEFECTDOJO_TOKEN"
#       -F "scan_type=Trivy Scan"
#       -F "file=@trivy-sbom.cdx.json"
#       -F "engagement=$DEFECTDOJO_ENGAGEMENT_ID"
#   rules:
#     - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
.gitlab-ci.java.yml
# Stack Java / Maven — à inclure avec .gitlab-ci.gates.yml
# include:
#   - local: 'gitlab/.gitlab-ci.gates.yml'
#   - local: 'gitlab/.gitlab-ci.java.yml'

variables:
  MAVEN_IMAGE: "maven:3.9-eclipse-temurin-21"

build:java:
  stage: build
  image: ${MAVEN_IMAGE}
  script:
    - mvn -B -DskipTests package
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
  artifacts:
    paths:
      - target/*.jar
    expire_in: 1 day

# GATE 2 — SBOM applicatif (bloquant)
gate:sbom-maven:
  stage: gate-sbom
  image: ${MAVEN_IMAGE}
  script:
    - mvn -B org.cyclonedx:cyclonedx-maven-plugin:makeAggregateBom
    # makeAggregateBom produit déjà l'agrégat à la racine du reactor ; le find
    # récursif publiait le BOM d'un module au hasard à sa place.
    - cp target/bom.json maven-sbom.cdx.json
    - test -s maven-sbom.cdx.json
  artifacts:
    paths:
      - maven-sbom.cdx.json
    expire_in: 90 days
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

# GUIDANCE — SAST, non bloquant
guidance:sast-opengrep:
  stage: guidance
  image: opengrep/opengrep:v${OPENGREP_VERSION}
  script:
    - opengrep scan --config auto --sarif --output opengrep.sarif .
  artifacts:
    when: always
    reports:
      sast: opengrep.sarif
  allow_failure: true

guidance:spotbugs:
  stage: guidance
  image: ${MAVEN_IMAGE}
  script:
    - mvn -B com.github.spotbugs:spotbugs-maven-plugin:check || true
  artifacts:
    when: always
    paths:
      - "**/spotbugsXml.xml"
  allow_failure: true

guidance:ai-provenance-checklist:
  stage: guidance
  image: alpine:3.20
  script:
    - echo "Rappel — vérifier avant merge (voir TOOLS.md#ia) :"
    - echo "- version de modèle et endpoint consignés dans la config versionnée"
    - echo "- juridiction d'hébergement du modèle documentée"
    - echo "- accès au journal d'interactions restreint, finalité déclarée"
    - echo "- évaluation art. 6§3 AI Act documentée si fonction annexe III"
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - "**/ai/**/*.java"
        - "**/llm/**/*.java"
  allow_failure: true
.gitlab-ci.nextjs.yml
# Stack Next.js / Node — à inclure avec .gitlab-ci.gates.yml
# include:
#   - local: 'gitlab/.gitlab-ci.gates.yml'
#   - local: 'gitlab/.gitlab-ci.nextjs.yml'

variables:
  NODE_IMAGE: "node:22-slim"

build:nextjs:
  stage: build
  image: ${NODE_IMAGE}
  script:
    - npm ci
    - npm run build
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
  # nécessite docker:dind si build d'image dans ce job ; sinon déplacer
  # la construction d'image vers un job dédié avec services: [docker:dind]
  artifacts:
    paths:
      - .next/
    expire_in: 1 day

# GATE 2 — SBOM applicatif (bloquant : le job échoue si le fichier n'est
# pas produit, garantissant qu'aucun build ne sort sans nomenclature)
gate:sbom-npm:
  stage: gate-sbom
  image: ${NODE_IMAGE}
  script:
    - npx --yes @cyclonedx/cyclonedx-npm --output-format JSON --output-file npm-sbom.cdx.json
    - test -s npm-sbom.cdx.json
  artifacts:
    paths:
      - npm-sbom.cdx.json
    expire_in: 90 days
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

# GUIDANCE — SAST, non bloquant
guidance:sast-opengrep:
  stage: guidance
  image: opengrep/opengrep:v${OPENGREP_VERSION}
  script:
    - opengrep scan --config auto --sarif --output opengrep.sarif .
  artifacts:
    when: always
    reports:
      sast: opengrep.sarif
  allow_failure: true

guidance:eslint:
  stage: guidance
  image: ${NODE_IMAGE}
  script:
    - npm ci
    - npx eslint . --format json --output-file eslint-report.json || true
  artifacts:
    when: always
    paths:
      - eslint-report.json
  allow_failure: true

# GUIDANCE — checklist provenance IA, déclenché uniquement si des chemins
# liés à l'IA sont modifiés. Ne bloque pas le pipeline : dépose un rapport
# à faire viser en revue de merge request (cf. TOOLS.md#ia).
guidance:ai-provenance-checklist:
  stage: guidance
  image: alpine:3.20
  script:
    - echo "Rappel — vérifier avant merge (voir TOOLS.md#ia) :"
    - echo "- version de modèle et endpoint consignés dans la config versionnée"
    - echo "- juridiction d'hébergement du modèle documentée"
    - echo "- accès au journal d'interactions restreint, finalité déclarée"
    - echo "- évaluation art. 6§3 AI Act documentée si fonction annexe III"
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - "**/ai/**/*"
        - "**/llm/**/*"
  allow_failure: true
.gitlab-ci.php.yml
# Stack PHP / Composer (Symfony, Drupal) — à inclure avec .gitlab-ci.gates.yml
# include:
#   - local: 'gitlab/.gitlab-ci.gates.yml'
#   - local: 'gitlab/.gitlab-ci.php.yml'
#
# Commun aux deux frameworks : Composer est le gestionnaire de dépendances
# dans les deux cas, donc le SBOM et l'audit de sécurité sont identiques.
# Ce qui diffère est signalé en commentaire (standard de code, vérificateur
# de dépréciation Drupal).

variables:
  PHP_IMAGE: "php:8.3-cli"

build:php:
  stage: build
  image: composer:2
  script:
    - composer install --no-dev --optimize-autoloader --no-interaction
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
  artifacts:
    paths:
      - vendor/
    expire_in: 1 day

# GATE 2 — SBOM applicatif (bloquant)
gate:sbom-composer:
  stage: gate-sbom
  image: composer:2
  script:
    - composer global require cyclonedx/cyclonedx-php-composer --no-interaction
    - composer CycloneDX:make-sbom --output-format=JSON --output-file=php-sbom.cdx.json --omit=dev
    - test -s php-sbom.cdx.json
  artifacts:
    paths:
      - php-sbom.cdx.json
    expire_in: 90 days
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

# GUIDANCE — SAST, non bloquant
guidance:sast-opengrep:
  stage: guidance
  image: opengrep/opengrep:v${OPENGREP_VERSION}
  script:
    - opengrep scan --config auto --sarif --output opengrep.sarif .
  artifacts:
    when: always
    reports:
      sast: opengrep.sarif
  allow_failure: true

# Audit de dépendances — natif Composer depuis 2.4, base
# FriendsOfPHP/security-advisories. Même statut que cargo-audit /
# composer audit ailleurs dans ce dossier : une dépendance listée n'est
# pas nécessairement atteinte par le code, donc guidance et non gate.
guidance:composer-audit:
  stage: guidance
  image: composer:2
  script:
    - composer audit --locked --format=json > composer-audit-report.json || true
  artifacts:
    when: always
    paths:
      - composer-audit-report.json
  allow_failure: true

# Standard de code — PSR-12 par défaut. Pour un projet Drupal, remplacer
# --standard=PSR12 par --standard=Drupal,DrupalPractice (paquet
# drupal/coder) : décommenter la ligne correspondante ci-dessous.
guidance:phpcs:
  stage: guidance
  image: ${PHP_IMAGE}
  script:
    - composer global require "squizlabs/php_codesniffer=*" --no-interaction
    - export PATH="$PATH:$(composer global config bin-dir --absolute --quiet 2>/dev/null)"
    - phpcs --standard=PSR12 --report=json --report-file=phpcs-report.json src/ || true
    # Variante Drupal (nécessite composer require --dev drupal/coder dans le projet) :
    # phpcs --standard=Drupal,DrupalPractice --report=json --report-file=phpcs-report.json web/modules/custom web/themes/custom
  artifacts:
    when: always
    paths:
      - phpcs-report.json
  allow_failure: true

# Guidance spécifique Drupal — dépréciations d'API core/contrib, hors
# scope pour un projet Symfony pur. Actif uniquement si le paquet
# drupal/core-recommended est présent dans composer.json.
guidance:drupal-check:
  stage: guidance
  image: ${PHP_IMAGE}
  script:
    # Actif uniquement si composer.json référence Drupal — sort proprement
    # (succès) sur un dépôt Symfony pur plutôt que d'échouer inutilement.
    - grep -q "drupal/core" composer.json || { echo "Pas un projet Drupal — job ignoré."; exit 0; }
    - composer global require mglaman/drupal-check --no-interaction
    - export PATH="$PATH:$(composer global config bin-dir --absolute --quiet 2>/dev/null)"
    - drupal-check --deprecations web/modules/custom web/themes/custom || true
  allow_failure: true
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

guidance:ai-provenance-checklist:
  stage: guidance
  image: alpine:3.20
  script:
    - echo "Rappel — vérifier avant merge (voir TOOLS.md#ia) :"
    - echo "- version de modèle et endpoint consignés dans la config versionnée"
    - echo "- juridiction d'hébergement du modèle documentée"
    - echo "- accès au journal d'interactions restreint, finalité déclarée"
    - echo "- évaluation art. 6§3 AI Act documentée si fonction annexe III"
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - "**/ai/**/*.php"
        - "**/llm/**/*.php"
  allow_failure: true
.gitlab-ci.postgresql.yml
# PostgreSQL / migrations — à inclure avec .gitlab-ci.gates.yml
# include:
#   - local: 'gitlab/.gitlab-ci.gates.yml'
#   - local: 'gitlab/.gitlab-ci.postgresql.yml'
#
# Ce template ne compile rien : il porte sur le dossier de migrations
# (convention : migrations/<horodatage>_<nom>.up.sql /
# migrations/<horodatage>_<nom>.down.sql — ajuster GLOB_UP / GLOB_DOWN
# selon l'outil de migration utilisé : node-pg-migrate, sqlx, diesel,
# Flyway, Sqitch...).
#
# GATE — irréversibilité des migrations (bloquant)
# Justification Foyer directe : une migration appliquée sans script de
# retour n'est pas un jugement de qualité, c'est un fait binaire — le
# fichier .down.sql existe ou n'existe pas — et l'absence de retour est
# irréversible une fois la migration jouée en production. C'est le même
# critère "objectivable × irréversible" que les quatre gates du document
# de cadrage, appliqué au domaine base de données plutôt qu'au code.

variables:
  MIGRATIONS_DIR: "migrations"

gate:migrations-reversibility:
  stage: gate-sbom   # même position dans le pipeline que les autres gates de fond
  image: alpine:3.20
  script:
    - |
      missing=0
      for up in $(find "$MIGRATIONS_DIR" -name "*.up.sql" 2>/dev/null); do
        down="${up%.up.sql}.down.sql"
        if [ ! -f "$down" ]; then
          echo "MANQUANT : $down (pour $up)"
          missing=$((missing + 1))
        fi
      done
      if [ "$missing" -gt 0 ]; then
        echo "$missing migration(s) sans script de retour — irréversible, bloqué."
        exit 1
      fi
      echo "Toutes les migrations ont un script de retour."
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - "${MIGRATIONS_DIR}/**/*"
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
      changes:
        - "${MIGRATIONS_DIR}/**/*"

# GUIDANCE — lint SQL, non bloquant. Attrape les erreurs de style et
# certains anti-patterns (SELECT *, DROP sans garde-fou) avant revue.
guidance:sqlfluff:
  stage: guidance
  image: python:3.12-slim
  script:
    - pip install --no-cache-dir sqlfluff
    - sqlfluff lint "$MIGRATIONS_DIR" --dialect postgres --format github-annotation > sqlfluff-report.json || true
  artifacts:
    when: always
    paths:
      - sqlfluff-report.json
  allow_failure: true
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - "${MIGRATIONS_DIR}/**/*"

# GUIDANCE — test d'application réelle up puis down sur une instance
# éphémère. Ne remplace pas le gate ci-dessus (qui vérifie l'existence
# du fichier) : ceci vérifie qu'il s'exécute sans erreur SQL. Adapter
# la commande d'application au client de migration réellement utilisé.
guidance:migration-dry-run:
  stage: guidance
  image: postgres:16-alpine
  services:
    - postgres:16-alpine
  variables:
    POSTGRES_PASSWORD: ci
    POSTGRES_DB: dryrun
    PGHOST: postgres
    PGUSER: postgres
    PGPASSWORD: ci
  script:
    - |
      for up in $(find "$MIGRATIONS_DIR" -name "*.up.sql" | sort); do
        echo "Application : $up"
        psql -d dryrun -f "$up" || { echo "Échec sur $up"; exit 1; }
      done
      for down in $(find "$MIGRATIONS_DIR" -name "*.down.sql" | sort -r); do
        echo "Retour : $down"
        psql -d dryrun -f "$down" || { echo "Échec sur $down"; exit 1; }
      done
  allow_failure: true
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - "${MIGRATIONS_DIR}/**/*"
.gitlab-ci.powershell.yml
# Stack PowerShell — à inclure avec .gitlab-ci.gates.yml
# include:
#   - local: 'gitlab/.gitlab-ci.gates.yml'
#   - local: 'gitlab/.gitlab-ci.powershell.yml'
#
# Particularité de ce stack : un dépôt PowerShell (scripts d'automatisation,
# module d'administration) n'est pas systématiquement conteneurisé. Les
# gates SBOM/conteneur du fichier gates.yml supposent une image Docker —
# ils ne s'exécutent donc ici que si un Dockerfile est présent. Aucun
# générateur CycloneDX dédié à l'écosystème PowerShell Gallery n'est
# aujourd'hui établi (voir TOOLS.md) : à défaut, on documente les modules
# requis plutôt que de produire un SBOM de façade.

variables:
  PWSH_IMAGE: "mcr.microsoft.com/powershell:7.4-alpine-3.20"

build:powershell:
  stage: build
  image: ${PWSH_IMAGE}
  script:
    - pwsh -NoProfile -Command "Get-ChildItem -Recurse -Filter *.ps1 | ForEach-Object { \$null = [System.Management.Automation.Language.Parser]::ParseFile(\$_.FullName, [ref]\$null, [ref]\$errors); if (\$errors) { Write-Error \"Erreur de syntaxe : \$($_.FullName)\" } }"
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'

# Build/scan conteneur — uniquement si le dépôt en produit un (ex. tâche
# planifiée packagée, agent d'automatisation). Sans Dockerfile, ce job ne
# s'exécute pas : ni SBOM ni gate conteneur ne s'appliquent à un script
# exécuté directement sur un hôte, hors du périmètre couvert ici.
build:powershell-container:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
      exists:
        - Dockerfile
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      exists:
        - Dockerfile

# GUIDANCE — PSScriptAnalyzer : lint et sécurité (détecte notamment
# ConvertTo-SecureString en clair, Invoke-Expression sur entrée non
# fiable, mots de passe en paramètre). Éditeur Microsoft, MIT, gratuit.
guidance:psscriptanalyzer:
  stage: guidance
  image: ${PWSH_IMAGE}
  script:
    - >
      pwsh -NoProfile -Command "
      Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser -Repository PSGallery;
      \$results = Invoke-ScriptAnalyzer -Path . -Recurse -Severity Warning,Error;
      \$results | ConvertTo-Json -Depth 4 | Out-File psscriptanalyzer-report.json;
      \$results | Format-Table -AutoSize
      "
  artifacts:
    when: always
    paths:
      - psscriptanalyzer-report.json
  allow_failure: true

# GUIDANCE — Pester, si des tests sont présents (*.Tests.ps1).
guidance:pester:
  stage: guidance
  image: ${PWSH_IMAGE}
  script:
    - >
      pwsh -NoProfile -Command "
      if (-not (Get-ChildItem -Recurse -Filter *.Tests.ps1 -ErrorAction SilentlyContinue)) {
        Write-Host 'Aucun test Pester trouvé — job ignoré.'; exit 0
      }
      Install-Module -Name Pester -Force -Scope CurrentUser -MinimumVersion 5.0;
      Invoke-Pester -CI
      "
  allow_failure: true

guidance:ai-provenance-checklist:
  stage: guidance
  image: alpine:3.20
  script:
    - echo "Rappel — vérifier avant merge (voir TOOLS.md#ia) :"
    - echo "- version de modèle et endpoint consignés dans la config versionnée"
    - echo "- juridiction d'hébergement du modèle documentée"
    - echo "- accès au journal d'interactions restreint, finalité déclarée"
    - echo "- évaluation art. 6§3 AI Act documentée si fonction annexe III"
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - "**/ai/**/*.ps1"
        - "**/llm/**/*.ps1"
  allow_failure: true
.gitlab-ci.python.yml
# Stack Python — à inclure avec .gitlab-ci.gates.yml
# include:
#   - local: 'gitlab/.gitlab-ci.gates.yml'
#   - local: 'gitlab/.gitlab-ci.python.yml'

variables:
  PYTHON_IMAGE: "python:3.12-slim"

build:python:
  stage: build
  image: ${PYTHON_IMAGE}
  script:
    - pip install --no-cache-dir -r requirements.txt
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .

# GATE 2 — SBOM applicatif (bloquant)
gate:sbom-python:
  stage: gate-sbom
  image: ${PYTHON_IMAGE}
  script:
    - pip install --no-cache-dir cyclonedx-bom
    - cyclonedx-py requirements -o python-sbom.cdx.json requirements.txt
    - test -s python-sbom.cdx.json
  artifacts:
    paths:
      - python-sbom.cdx.json
    expire_in: 90 days
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

# GUIDANCE — SAST, non bloquant
guidance:sast-opengrep:
  stage: guidance
  image: opengrep/opengrep:v${OPENGREP_VERSION}
  script:
    - opengrep scan --config auto --sarif --output opengrep.sarif .
  artifacts:
    when: always
    reports:
      sast: opengrep.sarif
  allow_failure: true

guidance:bandit:
  stage: guidance
  image: ${PYTHON_IMAGE}
  script:
    - pip install --no-cache-dir bandit[sarif]
    - bandit -r . -f sarif -o bandit.sarif || true
  artifacts:
    when: always
    paths:
      - bandit.sarif
  allow_failure: true

guidance:ruff:
  stage: guidance
  image: ${PYTHON_IMAGE}
  script:
    - pip install --no-cache-dir ruff
    - ruff check . --output-format=json > ruff-report.json || true
  artifacts:
    when: always
    paths:
      - ruff-report.json
  allow_failure: true

guidance:ai-provenance-checklist:
  stage: guidance
  image: alpine:3.20
  script:
    - echo "Rappel — vérifier avant merge (voir TOOLS.md#ia) :"
    - echo "- version de modèle et endpoint consignés dans la config versionnée"
    - echo "- juridiction d'hébergement du modèle documentée"
    - echo "- accès au journal d'interactions restreint, finalité déclarée"
    - echo "- évaluation art. 6§3 AI Act documentée si fonction annexe III"
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - "**/ai/**/*.py"
        - "**/llm/**/*.py"
  allow_failure: true
.gitlab-ci.root.yml
# Exemple de .gitlab-ci.yml pour un projet Next.js.
# Copier ce fichier à la racine du projet sous le nom .gitlab-ci.yml,
# ajuster le chemin des includes selon l'emplacement de ce dossier
# (sous-module, projet partagé "ci-templates", ou copie locale).

include:
  - local: 'gitlab/.gitlab-ci.gates.yml'
  - local: 'gitlab/.gitlab-ci.nextjs.yml'
  # Remplacer par .gitlab-ci.java.yml ou .gitlab-ci.python.yml selon le stack.
  # Un projet mixte (ex. frontend Next.js + backend Java) peut inclure
  # les deux templates de stack : les stages sont partagés, les jobs
  # portent des noms distincts (build:nextjs / build:java).
.gitlab-ci.rust.yml
# Stack Rust / Cargo — à inclure avec .gitlab-ci.gates.yml
# include:
#   - local: 'gitlab/.gitlab-ci.gates.yml'
#   - local: 'gitlab/.gitlab-ci.rust.yml'

variables:
  RUST_IMAGE: "rust:1.82-slim-bookworm"

build:rust:
  stage: build
  image: ${RUST_IMAGE}
  script:
    - cargo build --release --locked
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
  artifacts:
    paths:
      - target/release/
    expire_in: 1 day

# GATE 2 — SBOM applicatif (bloquant)
gate:sbom-rust:
  stage: gate-sbom
  image: ${RUST_IMAGE}
  script:
    - cargo install cargo-cyclonedx --locked
    - cargo cyclonedx --format json --all --output-cdx
    # Un workspace produit un SBOM PAR CRATE — les collecter tous (cf. le
    # commentaire du template GitHub équivalent).
    - mkdir -p sbom
    - find . -name "*.cdx.json" -not -path "./sbom/*" -exec cp --parents {} sbom/ \;
    - test -n "$(ls -A sbom)"
  artifacts:
    paths:
      - sbom/
    expire_in: 90 days
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

# GUIDANCE — SAST, non bloquant
guidance:sast-opengrep:
  stage: guidance
  image: opengrep/opengrep:v${OPENGREP_VERSION}
  script:
    - opengrep scan --config auto --sarif --output opengrep.sarif .
  artifacts:
    when: always
    reports:
      sast: opengrep.sarif
  allow_failure: true

guidance:clippy:
  stage: guidance
  image: ${RUST_IMAGE}
  script:
    - rustup component add clippy
    - cargo clippy --message-format=json --all-targets > clippy-report.json || true
  artifacts:
    when: always
    paths:
      - clippy-report.json
  allow_failure: true

# Base RustSec — complète Trivy, dont la couverture de l'écosystème Cargo
# est plus tardive que celle de RustSec. Non bloquant : une dépendance
# vulnérable listée n'est pas nécessairement atteinte par le code
# (triage requis, cf. TOOLS.md).
guidance:cargo-audit:
  stage: guidance
  image: ${RUST_IMAGE}
  script:
    - cargo install cargo-audit --locked
    - cargo audit --json > cargo-audit-report.json || true
  artifacts:
    when: always
    paths:
      - cargo-audit-report.json
  allow_failure: true

guidance:ai-provenance-checklist:
  stage: guidance
  image: alpine:3.20
  script:
    - echo "Rappel — vérifier avant merge (voir TOOLS.md#ia) :"
    - echo "- version de modèle et endpoint consignés dans la config versionnée"
    - echo "- juridiction d'hébergement du modèle documentée"
    - echo "- accès au journal d'interactions restreint, finalité déclarée"
    - echo "- évaluation art. 6§3 AI Act documentée si fonction annexe III"
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - "**/ai/**/*.rs"
        - "**/llm/**/*.rs"
  allow_failure: true
.gitlab-ci.svelte.yml
# Stack Svelte / SvelteKit — à inclure avec .gitlab-ci.gates.yml
# include:
#   - local: 'gitlab/.gitlab-ci.gates.yml'
#   - local: 'gitlab/.gitlab-ci.svelte.yml'
#
# Structure identique au template Next.js (même écosystème npm) —
# seule la commande de build change.

variables:
  NODE_IMAGE: "node:22-slim"

build:svelte:
  stage: build
  image: ${NODE_IMAGE}
  script:
    - npm ci
    - npm run build   # vite build / svelte-kit build
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
  artifacts:
    paths:
      - build/
    expire_in: 1 day

gate:sbom-npm:
  stage: gate-sbom
  image: ${NODE_IMAGE}
  script:
    - npx --yes @cyclonedx/cyclonedx-npm --output-format JSON --output-file npm-sbom.cdx.json
    - test -s npm-sbom.cdx.json
  artifacts:
    paths:
      - npm-sbom.cdx.json
    expire_in: 90 days
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

guidance:sast-opengrep:
  stage: guidance
  image: opengrep/opengrep:v${OPENGREP_VERSION}
  script:
    - opengrep scan --config auto --sarif --output opengrep.sarif .
  artifacts:
    when: always
    reports:
      sast: opengrep.sarif
  allow_failure: true

guidance:eslint:
  stage: guidance
  image: ${NODE_IMAGE}
  script:
    - npm ci
    - npx eslint . --format json --output-file eslint-report.json || true
  artifacts:
    when: always
    paths:
      - eslint-report.json
  allow_failure: true

guidance:ai-provenance-checklist:
  stage: guidance
  image: alpine:3.20
  script:
    - echo "Rappel — vérifier avant merge (voir TOOLS.md#ia) :"
    - echo "- version de modèle et endpoint consignés dans la config versionnée"
    - echo "- juridiction d'hébergement du modèle documentée"
    - echo "- accès au journal d'interactions restreint, finalité déclarée"
    - echo "- évaluation art. 6§3 AI Act documentée si fonction annexe III"
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - "**/ai/**/*"
        - "**/llm/**/*"
  allow_failure: true

Poste de travail

Seul le gate secrets est bloquant au commit (rapide, hors ligne). Le reste est délégué aux hooks natifs de chaque écosystème — voir install.sh dans le dépôt.

.pre-commit-config.yaml
# Gates de poste de travail — esprit Foyer.
# Installation : voir install.sh dans ce même dossier.
#
# Principe : seul le gate secrets est bloquant au commit (rapide, hors
# ligne). Tout le reste (lint, format) est laissé aux hooks natifs de
# chaque écosystème pour ne pas imposer d'outil — cf. README.md.

repos:
  # ---------------------------------------------------------------------
  # GATE — Secrets (bloquant, < 1s, aucun appel réseau)
  # ---------------------------------------------------------------------
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.21.2
    hooks:
      - id: gitleaks

  # ---------------------------------------------------------------------
  # Garde-fous génériques, peu coûteux, consensuels — pas spécifiques
  # à un écosystème, ne remplacent aucun gate CI.
  # ---------------------------------------------------------------------
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      - id: check-added-large-files
        args: ["--maxkb=2000"]
      - id: detect-private-key
      - id: check-merge-conflict
      - id: end-of-file-fixer
      - id: trailing-whitespace

  # ---------------------------------------------------------------------
  # Guidance Python — non bloquant en soi (ruff --fix corrige plutôt que
  # de rejeter), à activer uniquement dans les dépôts Python.
  # ---------------------------------------------------------------------
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.8.4
    hooks:
      - id: ruff
        args: ["--fix"]

  # ---------------------------------------------------------------------
  # Guidance Next.js / Astro / Svelte (npm) — délégué à la config ESLint
  # du projet. Décommenter si le dépôt est un projet Node.
  # ---------------------------------------------------------------------
  # - repo: local
  #   hooks:
  #     - id: eslint
  #       name: eslint
  #       entry: npx eslint --fix
  #       language: system
  #       files: \.(js|jsx|ts|tsx|astro|svelte)$

  # ---------------------------------------------------------------------
  # Guidance Rust — clippy en local, rapide sur un dépôt déjà compilé au
  # moins une fois (cache incrémental). Décommenter dans un dépôt Rust.
  # ---------------------------------------------------------------------
  # - repo: local
  #   hooks:
  #     - id: clippy
  #       name: cargo clippy
  #       entry: cargo clippy --all-targets -- -D warnings
  #       language: system
  #       pass_filenames: false

  # ---------------------------------------------------------------------
  # Guidance PHP — phpcs local. Décommenter dans un dépôt Symfony ou
  # Drupal (ajuster --standard selon le cas, cf. TOOLS.md).
  # ---------------------------------------------------------------------
  # - repo: local
  #   hooks:
  #     - id: phpcs
  #       name: phpcs (PSR-12)
  #       entry: phpcs --standard=PSR12
  #       language: system
  #       files: \.php$

  # ---------------------------------------------------------------------
  # Guidance C# / .NET — dotnet format en mode vérification. Décommenter
  # dans un dépôt .NET.
  # ---------------------------------------------------------------------
  # - repo: local
  #   hooks:
  #     - id: dotnet-format
  #       name: dotnet format
  #       entry: dotnet format --verify-no-changes
  #       language: system
  #       pass_filenames: false

  # ---------------------------------------------------------------------
  # Guidance PowerShell — PSScriptAnalyzer en local. Décommenter dans un
  # dépôt de scripts/modules PowerShell.
  # ---------------------------------------------------------------------
  # - repo: local
  #   hooks:
  #     - id: psscriptanalyzer
  #       name: PSScriptAnalyzer
  #       entry: pwsh -NoProfile -Command "Invoke-ScriptAnalyzer -Path . -Recurse -Severity Warning,Error -EnableExit"
  #       language: system
  #       pass_filenames: false

  # ---------------------------------------------------------------------
  # Guidance PostgreSQL — lint des migrations. Décommenter dans un dépôt
  # qui contient un dossier migrations/.
  # ---------------------------------------------------------------------
  # - repo: local
  #   hooks:
  #     - id: sqlfluff
  #       name: sqlfluff lint (postgres)
  #       entry: sqlfluff lint --dialect postgres
  #       language: system
  #       files: ^migrations/.*\.sql$