Spaces:
Running
Running
| """Tests Sprint A9 — pipeline de release et artefacts. | |
| Items M-5, M-6, m-15, m-16 de l'audit institutional-readiness-2026-05. | |
| Ces tests valident le **contrat de release** sans déclencher de | |
| build réel (qui nécessiterait Docker buildx + accès PyPI). Ils | |
| vérifient que les fichiers de configuration sont cohérents et que | |
| les workflows existent. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from pathlib import Path | |
| import pytest | |
| import yaml | |
| REPO_ROOT = Path(__file__).resolve().parents[2] | |
| # --------------------------------------------------------------------------- | |
| # M-5 — setuptools_scm + version dynamique | |
| # --------------------------------------------------------------------------- | |
| def test_pyproject_uses_dynamic_version() -> None: | |
| """``pyproject.toml`` doit déclarer ``version`` en dynamique | |
| (résolu par setuptools_scm) plutôt qu'en dur.""" | |
| pyproject = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8") | |
| # ``version = "1.0.0"`` ne doit plus apparaître au scope ``[project]``. | |
| project_block = re.search( | |
| r"\[project\](.*?)(?=\n\[)", | |
| pyproject, | |
| re.DOTALL, | |
| ) | |
| assert project_block is not None | |
| block = project_block.group(1) | |
| assert 'dynamic = ["version"]' in block, ( | |
| "[project] doit avoir dynamic = [\"version\"] (Sprint A9 M-5)" | |
| ) | |
| # Pas de ligne ``version = "..."`` codée en dur dans [project]. | |
| assert not re.search(r'^version\s*=\s*"', block, re.MULTILINE), ( | |
| '[project] ne doit pas avoir version = "..." en dur — ' | |
| 'incompatible avec setuptools_scm.' | |
| ) | |
| def test_setuptools_scm_configured() -> None: | |
| """``[tool.setuptools_scm]`` doit exister avec ``write_to`` pointant | |
| vers ``picarones/_version.py``.""" | |
| pyproject = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8") | |
| assert "[tool.setuptools_scm]" in pyproject | |
| assert 'write_to = "picarones/_version.py"' in pyproject | |
| # Politique : pas de ``+local`` dans la version (problématique pour | |
| # PyPI qui rejette les locals). | |
| assert 'local_scheme = "no-local-version"' in pyproject | |
| def test_build_system_includes_setuptools_scm() -> None: | |
| """``[build-system].requires`` doit inclure setuptools_scm.""" | |
| pyproject = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8") | |
| build_block = re.search( | |
| r"\[build-system\](.*?)(?=\n\[)", | |
| pyproject, | |
| re.DOTALL, | |
| ) | |
| assert build_block is not None | |
| block = build_block.group(1) | |
| assert "setuptools_scm" in block | |
| def test_picarones_version_resolves() -> None: | |
| """``picarones.__version__`` doit être lisible et au format PEP 440.""" | |
| import picarones | |
| v = picarones.__version__ | |
| assert isinstance(v, str) | |
| assert len(v) > 0 | |
| # PEP 440 : commence par X.Y.Z | |
| assert re.match(r"^\d+\.\d+", v), f"Version mal formée : {v!r}" | |
| # --------------------------------------------------------------------------- | |
| # M-5 + M-6 — Workflow release.yml | |
| # --------------------------------------------------------------------------- | |
| def test_release_workflow_exists() -> None: | |
| """``.github/workflows/release.yml`` doit exister.""" | |
| f = REPO_ROOT / ".github" / "workflows" / "release.yml" | |
| assert f.exists(), "release.yml manquant — pipeline release non automatisé" | |
| def test_release_workflow_triggers_on_tag() -> None: | |
| """Le workflow doit se déclencher sur push d'un tag ``v*.*.*``.""" | |
| f = REPO_ROOT / ".github" / "workflows" / "release.yml" | |
| data = yaml.safe_load(f.read_text(encoding="utf-8")) | |
| # YAML parse ``on`` en bool True — utiliser ``True`` ou la clé string. | |
| triggers = data.get(True) or data.get("on") or {} | |
| assert "push" in triggers | |
| push = triggers["push"] | |
| assert "tags" in push | |
| tags = push["tags"] | |
| # Au moins un pattern qui matche v*.*.* | |
| assert any("v*" in str(t) for t in tags) | |
| def test_release_workflow_uses_oidc() -> None: | |
| """Le workflow doit utiliser OIDC trust pour PyPI (pas de token long-lived).""" | |
| f = REPO_ROOT / ".github" / "workflows" / "release.yml" | |
| text = f.read_text(encoding="utf-8") | |
| # Vérifie que ``id-token: write`` est présent pour les jobs publish | |
| assert "id-token: write" in text | |
| # Vérifie que pypi-publish est utilisé (gh-action-pypi-publish) | |
| assert "pypa/gh-action-pypi-publish" in text | |
| def test_release_workflow_publishes_to_ghcr() -> None: | |
| """Le workflow doit construire et pousser une image multi-arch | |
| sur ghcr.io.""" | |
| f = REPO_ROOT / ".github" / "workflows" / "release.yml" | |
| text = f.read_text(encoding="utf-8") | |
| assert "ghcr.io/" in text | |
| assert "linux/amd64,linux/arm64" in text | |
| assert "docker/build-push-action" in text | |
| def test_release_workflow_creates_github_release() -> None: | |
| """Le workflow doit créer une GitHub Release avec les artefacts.""" | |
| f = REPO_ROOT / ".github" / "workflows" / "release.yml" | |
| text = f.read_text(encoding="utf-8") | |
| assert "softprops/action-gh-release" in text or "create-release" in text | |
| # --------------------------------------------------------------------------- | |
| # m-15 — picarones.spec (PyInstaller) sans hiddenimports manuels | |
| # --------------------------------------------------------------------------- | |
| def test_pyinstaller_spec_uses_collect_submodules() -> None: | |
| """``picarones.spec`` doit utiliser ``collect_submodules`` au lieu | |
| d'une liste manuelle d'imports cachés.""" | |
| f = REPO_ROOT / "picarones.spec" | |
| if not f.exists(): | |
| pytest.skip("picarones.spec absent — release PyInstaller non utilisée") | |
| text = f.read_text(encoding="utf-8") | |
| assert "collect_submodules" in text, ( | |
| "picarones.spec doit utiliser PyInstaller.utils.hooks.collect_submodules " | |
| "pour auto-détecter les imports — la liste manuelle dérivait silencieusement." | |
| ) | |
| assert 'collect_submodules("picarones")' in text | |
| def test_pyinstaller_spec_no_obsolete_paths() -> None: | |
| """``picarones.spec`` ne doit plus référencer les anciens chemins | |
| qui n'existent plus depuis le refactor Cercle 1/2/3 (Sprint 33).""" | |
| f = REPO_ROOT / "picarones.spec" | |
| if not f.exists(): | |
| pytest.skip("picarones.spec absent") | |
| text = f.read_text(encoding="utf-8") | |
| obsolete = [ | |
| "picarones.core.runner", # → measurements.runner | |
| "picarones.core.statistics", # → measurements.statistics | |
| "picarones.core.confusion", # → measurements.confusion | |
| "picarones.importers.iiif", # → extras.importers.iiif | |
| ] | |
| for path in obsolete: | |
| assert path not in text, ( | |
| f"Chemin obsolète référencé : {path}. " | |
| "Refactor Sprint 33 a déplacé les modules — collect_submodules " | |
| "résout automatiquement." | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # m-16 — Extras placeholders retirés | |
| # --------------------------------------------------------------------------- | |
| def test_no_empty_extras_placeholders() -> None: | |
| """Les extras ``[historical]`` et ``[importers]`` qui valaient | |
| ``[]`` ont été retirés (Sprint A9 m-16).""" | |
| pyproject = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8") | |
| # On cherche ``historical = []`` ou ``importers = []`` au scope | |
| # ``[project.optional-dependencies]``. | |
| assert not re.search( | |
| r"^historical\s*=\s*\[\s*\]", | |
| pyproject, | |
| re.MULTILINE, | |
| ), "Placeholder vide ``historical = []`` doit être retiré." | |
| assert not re.search( | |
| r"^importers\s*=\s*\[\s*\]", | |
| pyproject, | |
| re.MULTILINE, | |
| ), "Placeholder vide ``importers = []`` doit être retiré." | |
| def test_all_extra_does_not_reference_removed_extras() -> None: | |
| """L'extra ``all`` ne doit plus référencer ``historical`` / | |
| ``importers``.""" | |
| pyproject = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8") | |
| # Cherche la définition de ``all = [...]`` | |
| m = re.search(r"^all\s*=\s*\[(.*?)\]", pyproject, re.MULTILINE | re.DOTALL) | |
| assert m is not None | |
| all_block = m.group(1) | |
| assert "historical" not in all_block | |
| assert "importers" not in all_block | |
| # --------------------------------------------------------------------------- | |
| # Doc release-process.md | |
| # --------------------------------------------------------------------------- | |
| def test_release_process_doc_exists() -> None: | |
| """``docs/operations/release-process.md`` doit exister et couvrir | |
| les sections clés de la procédure.""" | |
| f = REPO_ROOT / "docs" / "operations" / "release-process.md" | |
| assert f.exists() | |
| text = f.read_text(encoding="utf-8") | |
| for section in [ | |
| "Procédure release standard", | |
| "Versionnement", | |
| "rollback", | |
| "OIDC", | |
| ]: | |
| assert section.lower() in text.lower(), ( | |
| f"Section manquante dans release-process.md : {section!r}" | |
| ) | |