Picarones / tests /release /test_release_artifacts.py
Claude
feat(sprint-A9): release pipeline PyPI + ghcr.io + GitHub Release
628d92a unverified
"""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}"
)