# .github/workflows/ci.yml — Picarones CI/CD # # Pipeline GitHub Actions (mis à jour Sprint A1 — Hardening CI) : # - Tests sur Python 3.11 / 3.12 / 3.13 (3.13 informationnel, 6 mois) # - Linux, macOS, Windows # - Couverture exigée >= 85 % (--cov-fail-under, plancher 2 pts sous baseline 87 %) # - Timeout pytest 5 min par test individuel (pytest-timeout, mode thread) # - Type-check mypy (strict sur picarones/domain/, lax ailleurs — durci en A11) # - Scanners sécurité : bandit (statique) + pip-audit (CVE deps) + trivy (image) # - Build de la distribution Python # - Vérification de l'exécutable demo name: CI on: push: branches: [main, develop, "feature/**", "claude/**"] pull_request: branches: [main, develop] workflow_dispatch: # Déclenchement manuel permissions: contents: read # ────────────────────────────────────────────────────────────────── # Job 1 : Tests unitaires et d'intégration # ────────────────────────────────────────────────────────────────── jobs: tests: name: Tests Python ${{ matrix.python-version }} / ${{ matrix.os }} runs-on: ${{ matrix.os }} # ``CODECOV_TOKEN`` au niveau JOB plutôt que step : nécessaire # pour que ``env.CODECOV_TOKEN`` soit visible dans le ``if:`` de # l'étape Codecov (le ``env`` d'un step n'est PAS résolu avant # l'évaluation du ``if`` de ce même step). env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] # 3.13 ajouté Sprint A1 (item m-8). Reste informationnel # (continue-on-error sur Linux uniquement) pendant 6 mois pour # tracker la compat sans bloquer. python-version: ["3.11", "3.12", "3.13"] include: - python-version: "3.13" os: ubuntu-latest experimental: true steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: pip # ── Tesseract ────────────────────────────────────────────── - name: Install Tesseract (Ubuntu) if: runner.os == 'Linux' run: | sudo apt-get update -qq sudo apt-get install -y tesseract-ocr tesseract-ocr-fra tesseract-ocr-lat - name: Install Tesseract (macOS) if: runner.os == 'macOS' run: | brew install tesseract tesseract-lang env: HOMEBREW_NO_AUTO_UPDATE: "1" - name: Install Tesseract (Windows) if: runner.os == 'Windows' run: | choco install tesseract -y echo "C:\Program Files\Tesseract-OCR" >> $env:GITHUB_PATH shell: pwsh # ── Dépendances Python ────────────────────────────────────── # Mise à jour pip/setuptools/wheel en début de job (Sprint A1) pour # éviter les CVE qui dorment dans les images runner GitHub. - name: Install dependencies run: | python -m pip install --upgrade pip setuptools wheel pip install -e ".[dev,web]" # ── Tests ─────────────────────────────────────────────────── # Sprint A1 : --cov-fail-under=85 (baseline mesuré 87 %, marge 2 pts). # pytest-timeout est configuré dans pyproject.toml [tool.pytest.ini_options]. # # Garde-fous anti-hang : # # 1. ``timeout-minutes: 12`` au niveau step : cap dur GitHub si # tout le reste échoue. # 2. ``timeout`` GNU autour de pytest : SIGTERM à 9 minutes, # SIGKILL 30s après si Python n'a pas obéi. Couvre # spécifiquement le cas d'un hang de SHUTDOWN de # l'interpréteur Python 3.12+ (threads non-daemon, connexions # sqlite non fermées, ResourceWarnings — observé sur ubuntu # 3.12 où pytest finit en 3:21 et l'interpréteur reste 12 min # avant de rendre la main). # 3. ``-X faulthandler`` : si le hang revient, on aura les stack # traces de tous les threads dans le log avant le SIGKILL. # 4. ``PYTHONFAULTHANDLER=1`` redondance ceinture-bretelles. # # Le code de retour 124 (SIGTERM par GNU timeout) ou 137 (SIGKILL) # est traité comme un échec normal — on perd l'info pytest mais # on préserve la latence de la CI. - name: Run tests # Sur Python 3.13, on continue malgré une erreur pour ne pas bloquer # le merge pendant la fenêtre informationnelle de 6 mois (m-8). continue-on-error: ${{ matrix.python-version == '3.13' }} timeout-minutes: 12 shell: bash run: | # ``timeout`` n'est pas standard sur macOS (BSD vs GNU) — on # détecte et on adapte. Sur Windows, le shell bash de # Git-Bash n'a pas timeout : on retombe sur python direct. if command -v timeout >/dev/null 2>&1; then timeout --signal=SIGTERM --kill-after=30 540 \ python -X faulthandler -m pytest tests/ -q --tb=short --no-header \ --cov=picarones --cov-report=xml --cov-report=term-missing \ --cov-fail-under=85 elif command -v gtimeout >/dev/null 2>&1; then # macOS Homebrew coreutils. gtimeout --signal=SIGTERM --kill-after=30 540 \ python -X faulthandler -m pytest tests/ -q --tb=short --no-header \ --cov=picarones --cov-report=xml --cov-report=term-missing \ --cov-fail-under=85 else python -X faulthandler -m pytest tests/ -q --tb=short --no-header \ --cov=picarones --cov-report=xml --cov-report=term-missing \ --cov-fail-under=85 fi env: PYTHONIOENCODING: utf-8 PYTHONUTF8: "1" PYTHONFAULTHANDLER: "1" # ── Couverture ────────────────────────────────────────────── # Conditions : # - ``always()`` : on remonte la couverture MÊME quand pytest a # échoué (utile pour suivre la dérive sur un build cassé). # - ``runner.os == 'Linux' && python-version == '3.11'`` : un seul # upload par run pour ne pas saturer le rate limit Codecov. # - ``env.CODECOV_TOKEN != ''`` : skip si le secret n'est pas # défini (fork PR, environnement de dev local). # # Garde-fous : # - ``timeout-minutes: 5`` : codecov-action v4 a déjà bloqué la CI # 50+ min en attendant un upload qui n'aboutissait pas. # - ``fail_ci_if_error: false`` : un échec d'upload n'invalide # pas un run de tests valide. - name: Upload coverage to Codecov if: always() && runner.os == 'Linux' && matrix.python-version == '3.11' && env.CODECOV_TOKEN != '' timeout-minutes: 5 uses: codecov/codecov-action@v4 with: token: ${{ env.CODECOV_TOKEN }} files: coverage.xml flags: unittests name: picarones-coverage fail_ci_if_error: false # ────────────────────────────────────────────────────────────────── # Job 2 : Vérification du rapport demo # ────────────────────────────────────────────────────────────────── demo: name: Demo end-to-end runs-on: ubuntu-latest needs: tests steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.11" cache: pip - name: Install Tesseract run: | sudo apt-get update -qq sudo apt-get install -y tesseract-ocr tesseract-ocr-fra - name: Install Picarones run: pip install -e ".[web]" - name: Run demo run: | picarones demo --docs 12 --output rapport_demo_ci.html \ --with-history --with-robustness ls -lh rapport_demo_ci.html # Vérifier que le fichier est valide et contient les sections attendues python -c " content = open('rapport_demo_ci.html').read() assert 'Picarones' in content, 'Picarones non trouvé dans le rapport' assert 'CER' in content, 'CER non trouvé dans le rapport' assert len(content) > 50000, f'Rapport trop petit : {len(content)} octets' print(f'Rapport OK : {len(content):,} octets') " - name: Upload demo report as artifact uses: actions/upload-artifact@v4 with: name: rapport-demo path: rapport_demo_ci.html retention-days: 7 # ────────────────────────────────────────────────────────────────── # Job 3 : Build de la distribution Python # ────────────────────────────────────────────────────────────────── build: name: Build distribution runs-on: ubuntu-latest needs: tests steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.11" cache: pip - name: Install build tools run: pip install --upgrade build twine - name: Build wheel and sdist run: python -m build - name: Check distribution run: twine check dist/* - name: Upload distribution as artifact uses: actions/upload-artifact@v4 with: name: dist-packages path: dist/ retention-days: 30 # ────────────────────────────────────────────────────────────────── # Job 4 : Vérification de la qualité du code # La config ruff est centralisée dans pyproject.toml ([tool.ruff]), # donc CI, Makefile et invocations locales produisent exactement les # mêmes résultats — pas de divergence possible entre flags. # ────────────────────────────────────────────────────────────────── lint: name: Code quality runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.11" cache: pip - name: Install ruff run: pip install ruff - name: Run ruff run: ruff check picarones/ tests/ # ────────────────────────────────────────────────────────────────── # Job 4-ter : Validation Docker compose # # Sans ce check, une incohérence entre ``docker-compose.yml`` et # ``docker-compose.prod.yml`` (variable non héritée, override de # port mal formé, conflit de services) ne se voit qu'au # déploiement. ``docker compose config`` résout les merges, # substitue les variables d'env et échoue si la composition est # invalide — c'est exactement le linter manquant. # # Le secret CSRF est forcé sur une valeur factice : ``prod.yml`` # exige ``${PICARONES_CSRF_SECRET:?...}`` qui ferait échouer # ``config`` sans valeur. La valeur n'est jamais utilisée — on ne # démarre aucun container, on valide uniquement le YAML. # ────────────────────────────────────────────────────────────────── compose-check: name: Docker compose validation runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Validate local compose run: docker compose -f docker-compose.yml config > /dev/null - name: Validate local + prod merge env: PICARONES_CSRF_SECRET: compose-check-not-a-real-secret run: | docker compose \ -f docker-compose.yml \ -f docker-compose.prod.yml \ config > /dev/null # ────────────────────────────────────────────────────────────────── # Job 4-bis : Sync compteurs README/CLAUDE.md — Phase 2.1 audit # code-quality (2026-05). Le script gen_readme_tables.py reflète # le code réel dans la prose des docs (tableaux Engines/CLI/API + # compteur de tests). En CI : exit 1 si la doc dérive. # ────────────────────────────────────────────────────────────────── sync-counters: name: Doc counters sync runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.11" cache: pip - name: Install Picarones (web extras pour ouvrir l'app FastAPI) run: pip install -e ".[dev,web]" - name: Vérifier que README.md / CLAUDE.md reflètent le code run: python scripts/gen_readme_tables.py --check # ────────────────────────────────────────────────────────────────── # Job 5 : Type-checking — Sprint A1 (item M-4) # # mypy est configuré dans pyproject.toml [tool.mypy] : # - strict sur picarones.domain.* (couche 1 du rewrite, ex-picarones.core) # - lax ailleurs (follow_imports=silent) # Deux checks pré-existants désactivés (disallow_any_generics et # warn_return_any), à ré-activer en Sprint A11 après fix des # ~100 erreurs baseline. # ────────────────────────────────────────────────────────────────── typecheck: name: Type checking (mypy) runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.11" cache: pip - name: Install dependencies run: | python -m pip install --upgrade pip setuptools wheel pip install -e ".[dev,web,stats]" - name: Run mypy on picarones/domain (strict) run: python -m mypy picarones/domain/ # ────────────────────────────────────────────────────────────────── # Job 6 : Sécurité — Sprint A1 (item B-7) # # bandit : scan statique du code Python (HIGH/MEDIUM bloquants). # pip-audit : CVEs des dépendances installées. # trivy : scan du Dockerfile + image résultante (HIGH/CRITICAL bloquants). # # Configuration bandit dans pyproject.toml [tool.bandit] avec exclusions # documentées (B310, B608, B615, B701 — chacune avec sprint cible). # ────────────────────────────────────────────────────────────────── security: name: Security scanners runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.11" cache: pip - name: Install scanners run: | python -m pip install --upgrade pip setuptools wheel pip install -e ".[dev,web]" # bandit : -ll = LOW + above. La config pyproject.toml exclut les # informationnels acceptés ; tout HIGH/MEDIUM nouveau bloque. - name: Run bandit run: python -m bandit -r picarones/ -ll -c pyproject.toml # pip-audit : --skip-editable car picarones n'est pas sur PyPI. # En CI, l'env est frais (pip à jour grâce au step précédent) donc # les CVEs trouvées sont des vraies vulnérabilités déclarées. # NB : ``--strict`` + ``--skip-editable`` combinés transforment le # skip en erreur (incompatibilité documentée du tool). On utilise # uniquement ``--skip-editable`` ; pip-audit retourne déjà exit 1 # sur CVE détectée — le job échouera donc en cas de vraie vulnérabilité. - name: Run pip-audit run: python -m pip_audit --skip-editable # trivy : scan du Dockerfile + image résultante. Build local pour # auditer ce qui sera réellement déployé sur HuggingFace Space. - name: Build Docker image for scan run: docker build -t picarones:ci-scan . - name: Run Trivy vulnerability scanner (image) uses: aquasecurity/trivy-action@v0.36.0 with: image-ref: 'picarones:ci-scan' format: 'table' exit-code: '1' ignore-unfixed: true severity: 'HIGH,CRITICAL' vuln-type: 'os,library' - name: Run Trivy on Dockerfile (config scan) uses: aquasecurity/trivy-action@v0.36.0 with: scan-type: 'config' scan-ref: 'Dockerfile' exit-code: '1' severity: 'HIGH,CRITICAL' # ────────────────────────────────────────────────────────────────── # Job 7 : CI/CD — Détection de régression CER (optionnel) # Commenté par défaut — activer si vous avez un corpus de référence # ────────────────────────────────────────────────────────────────── # regression-check: # name: Regression check # runs-on: ubuntu-latest # needs: tests # if: github.event_name == 'pull_request' # # steps: # - name: Checkout # uses: actions/checkout@v4 # # - name: Install # run: pip install -e . # # - name: Run benchmark on reference corpus # run: | # picarones run \ # --corpus ./tests/fixtures/reference_corpus/ \ # --engines tesseract \ # --output results_pr.json \ # --fail-if-cer-above 0.15 # fraction (0.15 = 15 %)