Spaces:
Sleeping
feat(sprint-A9): release pipeline PyPI + ghcr.io + GitHub Release
Browse filesSprint A9 — Distribution (3 PJ, items M-5, M-6, m-15, m-16).
Items résolus :
- M-5 : version dynamique via ``setuptools_scm``.
- ``pyproject.toml`` : ``dynamic = ["version"]`` + section
``[tool.setuptools_scm]`` qui écrit ``picarones/_version.py``
au build (gitignored).
- ``picarones/__init__.py`` lit en priorité ``_version.py``,
fallback ``importlib.metadata.version()``, fallback ``"1.0.0"``.
- Comportement : tag ``v1.2.0`` → version ``1.2.0`` ; main hors tag
→ ``1.2.1.devN+gSHA`` ; ``no-local-version`` pour PyPI compat.
- M-5+M-6 : ``.github/workflows/release.yml`` (200 lignes), 6 jobs :
1. ``build`` : sdist + wheel, ``twine check``, smoke wheel install.
2. ``publish-testpypi`` : upload TestPyPI via OIDC trust.
3. ``testpypi-smoke`` : install depuis TestPyPI dans container vide
+ ``picarones demo`` avec retry pour délai d'indexation.
4. ``publish-pypi`` : upload PyPI via OIDC trust (sans token long-lived).
5. ``docker`` : build multi-arch (linux/amd64 + linux/arm64) via
QEMU + buildx, push ``ghcr.io/.../picarones:<version>`` +
``:latest``, attestations SLSA + SBOM activées.
6. ``github-release`` : crée la Release avec corps extrait depuis
CHANGELOG (regex ``## [VERSION]``), pre-release auto si suffix
rc/a/b dans le tag.
Déclencheur : push tag ``v*.*.*``.
- m-15 : ``picarones.spec`` (PyInstaller) — la liste manuelle de 35+
``hiddenimports`` qui dérivait silencieusement (référençait
``picarones.core.runner``, ``picarones.importers.*`` qui ont migré
au refactor Cercle 1/2/3) est remplacée par
``collect_submodules("picarones")`` qui auto-détecte tout le
sous-arbre. Plus rien à maintenir à la main.
- m-16 : extras placeholders ``historical = []`` et ``importers = []``
retirés. La séparation future en packages PyPI distincts est
documentée dans ``docs/developer/module-policy.md`` (Sprint 97)
et n'a plus besoin d'être réservée par un extra vide. ``all =
[...]`` mis à jour pour ne plus référencer ces extras.
- ``docs/operations/release-process.md`` (180 lignes) : procédure
end-to-end, pré-requis OIDC PyPI/TestPyPI, cycle release standard,
hotfix sécurité < 72h, yanking, rollback complet, validation
post-release.
Tests : ``tests/release/test_release_artifacts.py`` (14 cas) :
- pyproject ``dynamic = ["version"]`` + setuptools_scm configuré.
- ``picarones.__version__`` résout au format PEP 440.
- release.yml existe, déclencheur tag, OIDC, multi-arch ghcr.io,
GitHub Release auto.
- ``picarones.spec`` utilise ``collect_submodules`` et ne référence
plus les anciens chemins.
- Extras vides ``historical`` / ``importers`` retirés du
pyproject.toml et de l'extra ``all``.
- ``release-process.md`` couvre les sections clés.
Sprint 29 + chantier5 inchangés (pas de nouveau détecteur narratif).
- .github/workflows/release.yml +273 -0
- .gitignore +1 -0
- docs/operations/release-process.md +138 -0
- picarones.spec +42 -55
- picarones/__init__.py +14 -5
- pyproject.toml +33 -21
- tests/release/__init__.py +1 -0
- tests/release/test_release_artifacts.py +230 -0
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Sprint A9 (M-5 + M-6) — pipeline de release.
|
| 2 |
+
#
|
| 3 |
+
# Déclenché sur push d'un tag ``v*.*.*`` (ex : ``v1.1.0``,
|
| 4 |
+
# ``v1.2.0-rc1``). Comportement :
|
| 5 |
+
#
|
| 6 |
+
# 1. Build sdist + wheel via ``python -m build`` (setuptools_scm
|
| 7 |
+
# dérive automatiquement la version du tag).
|
| 8 |
+
# 2. Validation ``twine check`` (taille, README rendu, métadonnées).
|
| 9 |
+
# 3. Smoke test : install dans un container vierge + ``picarones demo``.
|
| 10 |
+
# 4. Publication TestPyPI (validation finale avant prod).
|
| 11 |
+
# 5. Smoke test depuis TestPyPI (``pip install --index-url testpypi``).
|
| 12 |
+
# 6. Publication PyPI via Trusted Publisher (OIDC, sans token).
|
| 13 |
+
# 7. Build image Docker multi-arch (linux/amd64 + linux/arm64).
|
| 14 |
+
# 8. Push ghcr.io/maribakulj/picarones:<version> + :latest.
|
| 15 |
+
# 9. Création GitHub Release avec corps généré depuis CHANGELOG.
|
| 16 |
+
#
|
| 17 |
+
# Prérequis (à configurer une fois côté GitHub) :
|
| 18 |
+
# - PyPI Trusted Publisher : ajouter ce workflow dans
|
| 19 |
+
# https://pypi.org/manage/account/publishing/
|
| 20 |
+
# - GHCR_TOKEN n'est pas requis : ``GITHUB_TOKEN`` natif suffit
|
| 21 |
+
# avec ``packages: write`` (cf. permissions du job docker).
|
| 22 |
+
|
| 23 |
+
name: Release
|
| 24 |
+
|
| 25 |
+
on:
|
| 26 |
+
push:
|
| 27 |
+
tags:
|
| 28 |
+
- 'v*.*.*'
|
| 29 |
+
workflow_dispatch:
|
| 30 |
+
inputs:
|
| 31 |
+
tag:
|
| 32 |
+
description: "Tag à releaser (ex: v1.1.0). Manuel uniquement."
|
| 33 |
+
required: true
|
| 34 |
+
type: string
|
| 35 |
+
|
| 36 |
+
permissions:
|
| 37 |
+
contents: read
|
| 38 |
+
|
| 39 |
+
jobs:
|
| 40 |
+
|
| 41 |
+
# ──────────────────────────────────────────────────────────────────
|
| 42 |
+
# Job 1 — Build & validate distribution Python
|
| 43 |
+
# ──────────────────────────────────────────────────────────────────
|
| 44 |
+
build:
|
| 45 |
+
name: Build & validate
|
| 46 |
+
runs-on: ubuntu-latest
|
| 47 |
+
outputs:
|
| 48 |
+
version: ${{ steps.version.outputs.version }}
|
| 49 |
+
|
| 50 |
+
steps:
|
| 51 |
+
- name: Checkout (full history for setuptools_scm)
|
| 52 |
+
uses: actions/checkout@v4
|
| 53 |
+
with:
|
| 54 |
+
fetch-depth: 0 # tags + history requis par setuptools_scm
|
| 55 |
+
|
| 56 |
+
- name: Set up Python
|
| 57 |
+
uses: actions/setup-python@v5
|
| 58 |
+
with:
|
| 59 |
+
python-version: "3.11"
|
| 60 |
+
cache: pip
|
| 61 |
+
|
| 62 |
+
- name: Install build tools
|
| 63 |
+
run: python -m pip install --upgrade build twine setuptools_scm
|
| 64 |
+
|
| 65 |
+
- name: Detect version from tag
|
| 66 |
+
id: version
|
| 67 |
+
run: |
|
| 68 |
+
VERSION=$(python -m setuptools_scm)
|
| 69 |
+
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
| 70 |
+
echo "Version détectée : ${VERSION}"
|
| 71 |
+
|
| 72 |
+
- name: Build sdist + wheel
|
| 73 |
+
run: python -m build
|
| 74 |
+
|
| 75 |
+
- name: Twine validate
|
| 76 |
+
run: twine check dist/*
|
| 77 |
+
|
| 78 |
+
- name: Smoke test wheel install
|
| 79 |
+
run: |
|
| 80 |
+
python -m venv /tmp/smoke
|
| 81 |
+
/tmp/smoke/bin/pip install dist/*.whl
|
| 82 |
+
/tmp/smoke/bin/picarones --version
|
| 83 |
+
|
| 84 |
+
- name: Upload artefacts
|
| 85 |
+
uses: actions/upload-artifact@v4
|
| 86 |
+
with:
|
| 87 |
+
name: dist-${{ steps.version.outputs.version }}
|
| 88 |
+
path: dist/
|
| 89 |
+
retention-days: 30
|
| 90 |
+
|
| 91 |
+
# ──────────────────────────────────────────────────────────────────
|
| 92 |
+
# Job 2 — Publication TestPyPI (validation finale)
|
| 93 |
+
# ──────────────────────────────────────────────────────────────────
|
| 94 |
+
publish-testpypi:
|
| 95 |
+
name: Publish to TestPyPI
|
| 96 |
+
needs: build
|
| 97 |
+
runs-on: ubuntu-latest
|
| 98 |
+
environment:
|
| 99 |
+
name: testpypi
|
| 100 |
+
url: https://test.pypi.org/p/picarones
|
| 101 |
+
permissions:
|
| 102 |
+
id-token: write # OIDC trust pour TestPyPI
|
| 103 |
+
|
| 104 |
+
steps:
|
| 105 |
+
- name: Download artefacts
|
| 106 |
+
uses: actions/download-artifact@v4
|
| 107 |
+
with:
|
| 108 |
+
name: dist-${{ needs.build.outputs.version }}
|
| 109 |
+
path: dist/
|
| 110 |
+
|
| 111 |
+
- name: Publish to TestPyPI
|
| 112 |
+
uses: pypa/gh-action-pypi-publish@release/v1
|
| 113 |
+
with:
|
| 114 |
+
repository-url: https://test.pypi.org/legacy/
|
| 115 |
+
skip-existing: true
|
| 116 |
+
|
| 117 |
+
# ──────────────────────────────────────────────────────────────────
|
| 118 |
+
# Job 3 — Smoke test depuis TestPyPI (avant publication finale)
|
| 119 |
+
# ──────────────────────────────────────────────────────────────────
|
| 120 |
+
testpypi-smoke:
|
| 121 |
+
name: Smoke test TestPyPI install
|
| 122 |
+
needs: [build, publish-testpypi]
|
| 123 |
+
runs-on: ubuntu-latest
|
| 124 |
+
|
| 125 |
+
steps:
|
| 126 |
+
- name: Set up Python
|
| 127 |
+
uses: actions/setup-python@v5
|
| 128 |
+
with:
|
| 129 |
+
python-version: "3.11"
|
| 130 |
+
|
| 131 |
+
- name: Install Tesseract (pour le demo)
|
| 132 |
+
run: |
|
| 133 |
+
sudo apt-get update -qq
|
| 134 |
+
sudo apt-get install -y tesseract-ocr tesseract-ocr-fra
|
| 135 |
+
|
| 136 |
+
- name: Install from TestPyPI
|
| 137 |
+
run: |
|
| 138 |
+
# Retry pour le délai d'indexation TestPyPI (~30s typique)
|
| 139 |
+
for i in 1 2 3 4 5; do
|
| 140 |
+
pip install \
|
| 141 |
+
--index-url https://test.pypi.org/simple/ \
|
| 142 |
+
--extra-index-url https://pypi.org/simple/ \
|
| 143 |
+
picarones==${{ needs.build.outputs.version }} && break
|
| 144 |
+
echo "Tentative $i échouée, retry dans 30s..."
|
| 145 |
+
sleep 30
|
| 146 |
+
done
|
| 147 |
+
|
| 148 |
+
- name: Run demo
|
| 149 |
+
run: |
|
| 150 |
+
picarones --version
|
| 151 |
+
picarones demo --output /tmp/demo.html
|
| 152 |
+
test -s /tmp/demo.html
|
| 153 |
+
|
| 154 |
+
# ──────────────────────────────────────────────────────────────────
|
| 155 |
+
# Job 4 — Publication PyPI (production)
|
| 156 |
+
# ──────────────────────────────────────────────────────────────────
|
| 157 |
+
publish-pypi:
|
| 158 |
+
name: Publish to PyPI
|
| 159 |
+
needs: [build, testpypi-smoke]
|
| 160 |
+
runs-on: ubuntu-latest
|
| 161 |
+
environment:
|
| 162 |
+
name: pypi
|
| 163 |
+
url: https://pypi.org/p/picarones
|
| 164 |
+
permissions:
|
| 165 |
+
id-token: write # OIDC trust — pas de token long-lived
|
| 166 |
+
|
| 167 |
+
steps:
|
| 168 |
+
- name: Download artefacts
|
| 169 |
+
uses: actions/download-artifact@v4
|
| 170 |
+
with:
|
| 171 |
+
name: dist-${{ needs.build.outputs.version }}
|
| 172 |
+
path: dist/
|
| 173 |
+
|
| 174 |
+
- name: Publish to PyPI
|
| 175 |
+
uses: pypa/gh-action-pypi-publish@release/v1
|
| 176 |
+
|
| 177 |
+
# ──────────────────────────────────────────────────────────────────
|
| 178 |
+
# Job 5 — Image Docker multi-arch publiée sur ghcr.io
|
| 179 |
+
# ──────────────────────────────────────────────────────────────────
|
| 180 |
+
docker:
|
| 181 |
+
name: Build & push Docker image
|
| 182 |
+
needs: [build, publish-pypi]
|
| 183 |
+
runs-on: ubuntu-latest
|
| 184 |
+
permissions:
|
| 185 |
+
contents: read
|
| 186 |
+
packages: write # push ghcr.io
|
| 187 |
+
|
| 188 |
+
steps:
|
| 189 |
+
- name: Checkout
|
| 190 |
+
uses: actions/checkout@v4
|
| 191 |
+
|
| 192 |
+
- name: Set up QEMU (for arm64 emulation)
|
| 193 |
+
uses: docker/setup-qemu-action@v3
|
| 194 |
+
|
| 195 |
+
- name: Set up Docker Buildx
|
| 196 |
+
uses: docker/setup-buildx-action@v3
|
| 197 |
+
|
| 198 |
+
- name: Login to ghcr.io
|
| 199 |
+
uses: docker/login-action@v3
|
| 200 |
+
with:
|
| 201 |
+
registry: ghcr.io
|
| 202 |
+
username: ${{ github.actor }}
|
| 203 |
+
password: ${{ secrets.GITHUB_TOKEN }}
|
| 204 |
+
|
| 205 |
+
- name: Build & push (multi-arch)
|
| 206 |
+
uses: docker/build-push-action@v5
|
| 207 |
+
with:
|
| 208 |
+
context: .
|
| 209 |
+
platforms: linux/amd64,linux/arm64
|
| 210 |
+
push: true
|
| 211 |
+
tags: |
|
| 212 |
+
ghcr.io/${{ github.repository_owner }}/picarones:${{ needs.build.outputs.version }}
|
| 213 |
+
ghcr.io/${{ github.repository_owner }}/picarones:latest
|
| 214 |
+
cache-from: type=gha
|
| 215 |
+
cache-to: type=gha,mode=max
|
| 216 |
+
provenance: true # SLSA attestation
|
| 217 |
+
sbom: true # Software Bill of Materials
|
| 218 |
+
|
| 219 |
+
# ──────────────────────────────────────────────────────────────────
|
| 220 |
+
# Job 6 — GitHub Release avec corps depuis CHANGELOG
|
| 221 |
+
# ──────────────────────────────────────────────────────────────────
|
| 222 |
+
github-release:
|
| 223 |
+
name: Create GitHub Release
|
| 224 |
+
needs: [build, publish-pypi, docker]
|
| 225 |
+
runs-on: ubuntu-latest
|
| 226 |
+
permissions:
|
| 227 |
+
contents: write # créer la release
|
| 228 |
+
|
| 229 |
+
steps:
|
| 230 |
+
- name: Checkout
|
| 231 |
+
uses: actions/checkout@v4
|
| 232 |
+
with:
|
| 233 |
+
fetch-depth: 0
|
| 234 |
+
|
| 235 |
+
- name: Download artefacts
|
| 236 |
+
uses: actions/download-artifact@v4
|
| 237 |
+
with:
|
| 238 |
+
name: dist-${{ needs.build.outputs.version }}
|
| 239 |
+
path: dist/
|
| 240 |
+
|
| 241 |
+
- name: Extract release notes from CHANGELOG
|
| 242 |
+
id: notes
|
| 243 |
+
run: |
|
| 244 |
+
# Lit la section ``## [X.Y.Z]`` correspondant à la version.
|
| 245 |
+
# Si pas trouvée, fallback sur un message générique.
|
| 246 |
+
VERSION="${{ needs.build.outputs.version }}"
|
| 247 |
+
NOTES_FILE=/tmp/release_notes.md
|
| 248 |
+
python <<PYEOF > "$NOTES_FILE"
|
| 249 |
+
import re, sys
|
| 250 |
+
changelog = open("CHANGELOG.md", encoding="utf-8").read()
|
| 251 |
+
version = "$VERSION"
|
| 252 |
+
# On cherche soit ``## [1.2.3]``, ``## [v1.2.3]``, ou ``## [1.2.3]``
|
| 253 |
+
pat = re.compile(rf"^## \[v?{re.escape(version)}\][^\n]*\n(.+?)(?=^## \[|^---|\Z)", re.MULTILINE | re.DOTALL)
|
| 254 |
+
m = pat.search(changelog)
|
| 255 |
+
if m:
|
| 256 |
+
print(m.group(1).strip())
|
| 257 |
+
else:
|
| 258 |
+
print(f"Release {version} — voir CHANGELOG.md pour les détails.")
|
| 259 |
+
PYEOF
|
| 260 |
+
echo "Notes extraites depuis CHANGELOG :"
|
| 261 |
+
cat "$NOTES_FILE"
|
| 262 |
+
|
| 263 |
+
- name: Create GitHub Release
|
| 264 |
+
uses: softprops/action-gh-release@v2
|
| 265 |
+
with:
|
| 266 |
+
tag_name: ${{ github.ref_name }}
|
| 267 |
+
name: Picarones ${{ needs.build.outputs.version }}
|
| 268 |
+
body_path: /tmp/release_notes.md
|
| 269 |
+
files: |
|
| 270 |
+
dist/*.whl
|
| 271 |
+
dist/*.tar.gz
|
| 272 |
+
draft: false
|
| 273 |
+
prerelease: ${{ contains(needs.build.outputs.version, 'rc') || contains(needs.build.outputs.version, 'a') || contains(needs.build.outputs.version, 'b') }}
|
|
@@ -30,3 +30,4 @@ jobs.db-wal
|
|
| 30 |
# Exceptions : fichiers HTML sources du package (templates Jinja2, pas rapports)
|
| 31 |
!picarones/report/templates/*.html
|
| 32 |
!picarones/web/templates/*.html
|
|
|
|
|
|
| 30 |
# Exceptions : fichiers HTML sources du package (templates Jinja2, pas rapports)
|
| 31 |
!picarones/report/templates/*.html
|
| 32 |
!picarones/web/templates/*.html
|
| 33 |
+
_version.py
|
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Procédure de release
|
| 2 |
+
|
| 3 |
+
> Sprint A9 du plan de remédiation institutionnelle
|
| 4 |
+
> ([`docs/audits/remediation-plan-2026-05.md`](../audits/remediation-plan-2026-05.md)).
|
| 5 |
+
|
| 6 |
+
## Vue d'ensemble
|
| 7 |
+
|
| 8 |
+
Une release Picarones produit **trois artefacts** :
|
| 9 |
+
|
| 10 |
+
1. Un wheel + sdist sur **PyPI** (`pip install picarones==X.Y.Z`).
|
| 11 |
+
2. Une image Docker **multi-arch** sur ghcr.io
|
| 12 |
+
(`docker pull ghcr.io/maribakulj/picarones:X.Y.Z`).
|
| 13 |
+
3. Une **GitHub Release** avec le sdist/wheel attachés et les
|
| 14 |
+
release notes extraites du `CHANGELOG.md`.
|
| 15 |
+
|
| 16 |
+
Le pipeline est entièrement automatisé : il suffit de pousser un
|
| 17 |
+
tag `v*.*.*` pour déclencher l'enchaînement complet (workflow
|
| 18 |
+
[`.github/workflows/release.yml`](../../.github/workflows/release.yml)).
|
| 19 |
+
|
| 20 |
+
## Procédure release standard
|
| 21 |
+
|
| 22 |
+
### Pré-requis (une fois)
|
| 23 |
+
|
| 24 |
+
1. **PyPI Trusted Publisher** : sur <https://pypi.org/manage/account/publishing/>,
|
| 25 |
+
ajouter ce repo + workflow `release.yml` + environnement `pypi`.
|
| 26 |
+
Idem pour TestPyPI dans l'environnement `testpypi`.
|
| 27 |
+
2. **GitHub repo** : créer les environnements `pypi` et `testpypi`
|
| 28 |
+
dans Settings → Environments, et marquer `pypi` comme "required
|
| 29 |
+
reviewers" si vous voulez une validation manuelle finale.
|
| 30 |
+
3. **GHCR** : `packages: write` sur `GITHUB_TOKEN` est natif (rien
|
| 31 |
+
à configurer).
|
| 32 |
+
|
| 33 |
+
### Cycle de release
|
| 34 |
+
|
| 35 |
+
```bash
|
| 36 |
+
# 1. Vérifier que main est vert + à jour
|
| 37 |
+
git checkout main
|
| 38 |
+
git pull --ff-only
|
| 39 |
+
|
| 40 |
+
# 2. Mettre à jour le CHANGELOG.md (Keep a Changelog)
|
| 41 |
+
# Ajouter une section ## [1.2.0] — YYYY-MM-DD avec les changes
|
| 42 |
+
git add CHANGELOG.md
|
| 43 |
+
git commit -m "docs(changelog): release 1.2.0"
|
| 44 |
+
|
| 45 |
+
# 3. Tag annoté + push
|
| 46 |
+
git tag -a v1.2.0 -m "Picarones 1.2.0"
|
| 47 |
+
git push origin main
|
| 48 |
+
git push origin v1.2.0
|
| 49 |
+
|
| 50 |
+
# 4. Surveiller le workflow Actions
|
| 51 |
+
gh run watch
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
Le workflow déroule **automatiquement** :
|
| 55 |
+
|
| 56 |
+
1. **build** — sdist + wheel via setuptools_scm (version dérivée du tag),
|
| 57 |
+
`twine check`, smoke test wheel install.
|
| 58 |
+
2. **publish-testpypi** — upload TestPyPI via OIDC trust.
|
| 59 |
+
3. **testpypi-smoke** — installation depuis TestPyPI dans un container
|
| 60 |
+
vierge + `picarones demo`.
|
| 61 |
+
4. **publish-pypi** — upload PyPI via OIDC trust (production).
|
| 62 |
+
5. **docker** — build multi-arch (linux/amd64 + linux/arm64) avec
|
| 63 |
+
QEMU, push ghcr.io, attestations SLSA + SBOM.
|
| 64 |
+
6. **github-release** — création de la Release GitHub avec corps
|
| 65 |
+
extrait depuis la section CHANGELOG correspondante.
|
| 66 |
+
|
| 67 |
+
Durée totale : ~15 min (multi-arch + 30s d'indexation TestPyPI).
|
| 68 |
+
|
| 69 |
+
## Versionnement
|
| 70 |
+
|
| 71 |
+
Picarones suit **Semantic Versioning 2.0.0** :
|
| 72 |
+
|
| 73 |
+
- `MAJOR.MINOR.PATCH` — incompatibilité, ajout, fix.
|
| 74 |
+
- Suffixes pré-release : `-rc1`, `-beta1`, `-alpha1`. Le workflow
|
| 75 |
+
les détecte et coche `prerelease=true` sur la GitHub Release.
|
| 76 |
+
|
| 77 |
+
`setuptools_scm` dérive automatiquement la version du tag git :
|
| 78 |
+
|
| 79 |
+
| Contexte | Version produite |
|
| 80 |
+
|---|---|
|
| 81 |
+
| Tag `v1.2.0` | `1.2.0` |
|
| 82 |
+
| 5 commits après `v1.2.0` | `1.2.1.dev5+g<sha>` (dev seulement) |
|
| 83 |
+
| `v1.3.0-rc1` | `1.3.0rc1` (PEP 440) |
|
| 84 |
+
|
| 85 |
+
## Procédure d'urgence : hotfix sécurité
|
| 86 |
+
|
| 87 |
+
Pour un fix CVE qui doit sortir en < 72 h (politique GOVERNANCE.md) :
|
| 88 |
+
|
| 89 |
+
```bash
|
| 90 |
+
git checkout -b hotfix-cve-2026-XXXX main
|
| 91 |
+
# correctif minimal + test
|
| 92 |
+
git commit -m "fix(security): patch CVE-2026-XXXX"
|
| 93 |
+
# CHANGELOG bump
|
| 94 |
+
git commit -m "docs(changelog): release 1.2.1"
|
| 95 |
+
git tag -a v1.2.1 -m "Picarones 1.2.1 (security)"
|
| 96 |
+
git push origin hotfix-cve-2026-XXXX v1.2.1
|
| 97 |
+
# Le workflow release.yml gère le reste.
|
| 98 |
+
# Après merge : annonce sur SECURITY.md + courriel mainteneur.
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
## Yanking d'une release publiée
|
| 102 |
+
|
| 103 |
+
PyPI permet de retirer (yank) une version compromise sans la
|
| 104 |
+
supprimer. À utiliser si une release introduit une régression
|
| 105 |
+
critique :
|
| 106 |
+
|
| 107 |
+
```bash
|
| 108 |
+
# Connexion à PyPI → Manage → version concernée → "Yank"
|
| 109 |
+
# Justification dans le commentaire (visible publiquement).
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
L'image ghcr.io reste — mais le tag `:latest` ne pointera plus vers
|
| 113 |
+
la version yankée si on pousse une nouvelle release.
|
| 114 |
+
|
| 115 |
+
## Validation post-release
|
| 116 |
+
|
| 117 |
+
Checklist 30 min après la fin du workflow :
|
| 118 |
+
|
| 119 |
+
- [ ] `pip install picarones==<version>` fonctionne dans un venv frais.
|
| 120 |
+
- [ ] `docker run ghcr.io/maribakulj/picarones:<version>` démarre
|
| 121 |
+
sans erreur et expose `/health`.
|
| 122 |
+
- [ ] La GitHub Release affiche bien les release notes attendues.
|
| 123 |
+
- [ ] `cffconvert --validate` confirme que `CITATION.cff` cite la
|
| 124 |
+
bonne version (Sprint A12+).
|
| 125 |
+
|
| 126 |
+
## Annexe : rollback complet
|
| 127 |
+
|
| 128 |
+
Si la release est compromise et doit être retirée intégralement :
|
| 129 |
+
|
| 130 |
+
1. PyPI : yank la version (cf. plus haut).
|
| 131 |
+
2. ghcr.io : `docker manifest rm ghcr.io/maribakulj/picarones:<version>`.
|
| 132 |
+
3. GitHub Release : passer en draft + ajouter un README explicatif.
|
| 133 |
+
4. Tag git : `git push --delete origin v<version>` puis nouveau
|
| 134 |
+
tag `v<version>+1` corrigé (un tag git ne peut pas être réécrit
|
| 135 |
+
sans casser tous les checkouts existants — préférer le bump).
|
| 136 |
+
|
| 137 |
+
Ne **jamais** force-push un tag déjà publié — les utilisateurs qui
|
| 138 |
+
ont fait `git fetch` voient un conflit.
|
|
@@ -17,6 +17,11 @@
|
|
| 17 |
import sys
|
| 18 |
from pathlib import Path
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
# Chemin racine du projet
|
| 21 |
ROOT = Path(spec_file).parent # noqa: F821 (spec_file est défini par PyInstaller)
|
| 22 |
|
|
@@ -41,61 +46,43 @@ a = Analysis(
|
|
| 41 |
# (str(ROOT / "prompts"), "prompts"),
|
| 42 |
],
|
| 43 |
|
| 44 |
-
#
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
"picarones
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
"picarones.report.diff_utils",
|
| 82 |
-
"picarones.fixtures",
|
| 83 |
-
# Dépendances tiers
|
| 84 |
-
"click",
|
| 85 |
-
"jiwer",
|
| 86 |
-
"PIL",
|
| 87 |
-
"PIL.Image",
|
| 88 |
-
"PIL.ImageFilter",
|
| 89 |
-
"PIL.ImageOps",
|
| 90 |
-
"yaml",
|
| 91 |
-
"tqdm",
|
| 92 |
-
"numpy",
|
| 93 |
-
"pytesseract",
|
| 94 |
-
# SQLite (stdlib, mais parfois manquant)
|
| 95 |
-
"sqlite3",
|
| 96 |
-
# Encodage
|
| 97 |
-
"unicodedata",
|
| 98 |
-
],
|
| 99 |
|
| 100 |
# Fichiers à exclure pour réduire la taille
|
| 101 |
excludes=[
|
|
|
|
| 17 |
import sys
|
| 18 |
from pathlib import Path
|
| 19 |
|
| 20 |
+
# Sprint A9 (m-15) — utilitaire PyInstaller pour auto-détecter les
|
| 21 |
+
# imports d'un package entier. Remplace la liste hiddenimports manuelle
|
| 22 |
+
# qui dérivait silencieusement à chaque refactor.
|
| 23 |
+
from PyInstaller.utils.hooks import collect_submodules # noqa: F401
|
| 24 |
+
|
| 25 |
# Chemin racine du projet
|
| 26 |
ROOT = Path(spec_file).parent # noqa: F821 (spec_file est défini par PyInstaller)
|
| 27 |
|
|
|
|
| 46 |
# (str(ROOT / "prompts"), "prompts"),
|
| 47 |
],
|
| 48 |
|
| 49 |
+
# Sprint A9 (m-15) — auto-détection des hiddenimports.
|
| 50 |
+
#
|
| 51 |
+
# Avant Sprint A9, la liste était maintenue manuellement et
|
| 52 |
+
# dérivait : elle référençait des modules qui ont migré dans
|
| 53 |
+
# ``measurements/`` ou ``extras/`` au moment du refactor des
|
| 54 |
+
# Cercles 1/2/3 (Sprint 33). Bug latent : la PyInstaller build
|
| 55 |
+
# produisait un exécutable qui ratait silencieusement à
|
| 56 |
+
# l'``import`` de ces modules.
|
| 57 |
+
#
|
| 58 |
+
# ``collect_submodules`` parcourt tout le sous-arbre du package
|
| 59 |
+
# à la construction et inclut tout ce qui s'importe. Plus rien
|
| 60 |
+
# à maintenir à la main quand on ajoute un sous-module.
|
| 61 |
+
#
|
| 62 |
+
# Liste explicite des dépendances tierces conservée car certaines
|
| 63 |
+
# (PIL.ImageFilter, jiwer) ne sont pas trouvées par ``collect_submodules``
|
| 64 |
+
# de leur propre fait (importées paresseusement).
|
| 65 |
+
hiddenimports=(
|
| 66 |
+
collect_submodules("picarones") # noqa: F821 — défini par PyInstaller
|
| 67 |
+
+ [
|
| 68 |
+
"click",
|
| 69 |
+
"jiwer",
|
| 70 |
+
"PIL",
|
| 71 |
+
"PIL.Image",
|
| 72 |
+
"PIL.ImageFilter",
|
| 73 |
+
"PIL.ImageOps",
|
| 74 |
+
"yaml",
|
| 75 |
+
"tqdm",
|
| 76 |
+
"numpy",
|
| 77 |
+
"pytesseract",
|
| 78 |
+
"defusedxml",
|
| 79 |
+
"defusedxml.ElementTree",
|
| 80 |
+
"sqlite3",
|
| 81 |
+
"unicodedata",
|
| 82 |
+
# Sprint A1 — type-checking et tests embarqués au build dev
|
| 83 |
+
# uniquement. En build release pur, retirer.
|
| 84 |
+
]
|
| 85 |
+
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
# Fichiers à exclure pour réduire la taille
|
| 88 |
excludes=[
|
|
@@ -21,12 +21,21 @@ Voir ``docs/architecture.md`` pour la cartographie complète des
|
|
| 21 |
|
| 22 |
from __future__ import annotations
|
| 23 |
|
| 24 |
-
# Version (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
try:
|
| 26 |
-
from
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
__author__ = "Picarones contributors"
|
| 32 |
|
|
|
|
| 21 |
|
| 22 |
from __future__ import annotations
|
| 23 |
|
| 24 |
+
# Version (Sprint A9 / M-5) — résolue dans cet ordre :
|
| 25 |
+
# 1. ``picarones._version`` injecté au build par setuptools_scm
|
| 26 |
+
# (présent dans le wheel installé) ;
|
| 27 |
+
# 2. fallback ``importlib.metadata.version("picarones")`` pour les
|
| 28 |
+
# installations editable où ``_version.py`` peut être stale ;
|
| 29 |
+
# 3. fallback final ``"1.0.0"`` si aucune source n'est disponible
|
| 30 |
+
# (ex : tarball sans .git ni metadata).
|
| 31 |
try:
|
| 32 |
+
from picarones._version import __version__ # type: ignore[import-not-found]
|
| 33 |
+
except ImportError:
|
| 34 |
+
try:
|
| 35 |
+
from importlib.metadata import version as _get_version
|
| 36 |
+
__version__ = _get_version("picarones")
|
| 37 |
+
except Exception: # noqa: BLE001
|
| 38 |
+
__version__ = "1.0.0"
|
| 39 |
|
| 40 |
__author__ = "Picarones contributors"
|
| 41 |
|
|
@@ -1,10 +1,17 @@
|
|
| 1 |
[build-system]
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
build-backend = "setuptools.build_meta"
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "picarones"
|
| 7 |
-
version
|
|
|
|
|
|
|
| 8 |
description = "Plateforme de comparaison de moteurs OCR/HTR pour documents patrimoniaux"
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.11"
|
|
@@ -90,32 +97,37 @@ ocr-cloud = [
|
|
| 90 |
"boto3>=1.34.0",
|
| 91 |
"azure-ai-formrecognizer>=3.3.0",
|
| 92 |
]
|
| 93 |
-
#
|
| 94 |
-
#
|
| 95 |
-
#
|
| 96 |
-
#
|
| 97 |
-
#
|
| 98 |
-
#
|
| 99 |
-
# d'usage patrimonial) peut ignorer le sous-package extras/historical/
|
| 100 |
-
# entièrement, et un futur split en package PyPI séparé
|
| 101 |
-
# ``picarones-historical`` réutilisera ce nom d'extra.
|
| 102 |
-
historical = []
|
| 103 |
-
# Importeurs de corpus depuis sources distantes (Cercle 3, phase C).
|
| 104 |
-
# Les 6 importeurs (sous extras/importers/, dotted
|
| 105 |
-
# ``picarones.extras.importers.*``) sont livrés dans le package
|
| 106 |
-
# principal. ``[importers]`` documente l'intention de séparation
|
| 107 |
-
# future en package PyPI ``picarones-importers``. Les modules
|
| 108 |
-
# ``huggingface`` et ``escriptorium`` émettent un ``UserWarning`` à
|
| 109 |
-
# l'import (statut expérimental).
|
| 110 |
-
importers = []
|
| 111 |
# Installation complète (tous les extras sauf les OCR cloud)
|
| 112 |
all = [
|
| 113 |
-
"picarones[web,hf,llm,dev
|
| 114 |
]
|
| 115 |
|
| 116 |
[project.scripts]
|
| 117 |
picarones = "picarones.cli:cli"
|
| 118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
[tool.setuptools.packages.find]
|
| 120 |
where = ["."]
|
| 121 |
include = ["picarones*"]
|
|
|
|
| 1 |
[build-system]
|
| 2 |
+
# Sprint A9 (M-5) : setuptools_scm dérive la version du tag git le
|
| 3 |
+
# plus proche. Le pipeline release.yml tag ``v1.2.3`` produit donc
|
| 4 |
+
# un wheel ``picarones-1.2.3-py3-none-any.whl`` sans toucher à
|
| 5 |
+
# pyproject.toml. Pour les builds non-tag (PR, dev) : version
|
| 6 |
+
# pseudo ``1.2.4.dev3+g<sha>``.
|
| 7 |
+
requires = ["setuptools>=68.0", "wheel", "setuptools_scm[toml]>=8.0"]
|
| 8 |
build-backend = "setuptools.build_meta"
|
| 9 |
|
| 10 |
[project]
|
| 11 |
name = "picarones"
|
| 12 |
+
# Sprint A9 (M-5) : ``version`` est désormais dynamique, dérivé du
|
| 13 |
+
# tag git via setuptools_scm. Voir [tool.setuptools_scm] plus bas.
|
| 14 |
+
dynamic = ["version"]
|
| 15 |
description = "Plateforme de comparaison de moteurs OCR/HTR pour documents patrimoniaux"
|
| 16 |
readme = "README.md"
|
| 17 |
requires-python = ">=3.11"
|
|
|
|
| 97 |
"boto3>=1.34.0",
|
| 98 |
"azure-ai-formrecognizer>=3.3.0",
|
| 99 |
]
|
| 100 |
+
# Sprint A9 (m-16) — les anciens placeholders ``[historical]`` et
|
| 101 |
+
# ``[importers]`` (qui valaient ``[]`` et n'apportaient rien à
|
| 102 |
+
# l'installation) sont retirés. La séparation future en packages PyPI
|
| 103 |
+
# distincts (``picarones-historical``, ``picarones-importers``) est
|
| 104 |
+
# documentée dans ``docs/developer/module-policy.md`` (Sprint 97) et
|
| 105 |
+
# n'a plus besoin d'être réservée par un extra vide.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
# Installation complète (tous les extras sauf les OCR cloud)
|
| 107 |
all = [
|
| 108 |
+
"picarones[web,hf,llm,dev]",
|
| 109 |
]
|
| 110 |
|
| 111 |
[project.scripts]
|
| 112 |
picarones = "picarones.cli:cli"
|
| 113 |
|
| 114 |
+
# ──────────────────────────────────────────────────────────────────
|
| 115 |
+
# Sprint A9 (M-5) — version dynamique via setuptools_scm.
|
| 116 |
+
#
|
| 117 |
+
# Comportement :
|
| 118 |
+
# - sur un tag ``v1.2.3`` → version ``1.2.3``
|
| 119 |
+
# - hors tag (PR, main) → ``1.2.4.dev<N>+g<sha>`` (PEP 440)
|
| 120 |
+
# - le ``write_to`` injecte ``picarones/_version.py`` au build, lu
|
| 121 |
+
# par ``picarones/__init__.py`` via ``__version__``.
|
| 122 |
+
# ``fallback_version`` est utilisé si l'historique git est absent
|
| 123 |
+
# (ex : tarball sdist) — doit être maintenu cohérent avec le dernier tag.
|
| 124 |
+
# ──────────────────────────────────────────────────────────────────
|
| 125 |
+
[tool.setuptools_scm]
|
| 126 |
+
write_to = "picarones/_version.py"
|
| 127 |
+
fallback_version = "1.0.0"
|
| 128 |
+
version_scheme = "release-branch-semver"
|
| 129 |
+
local_scheme = "no-local-version"
|
| 130 |
+
|
| 131 |
[tool.setuptools.packages.find]
|
| 132 |
where = ["."]
|
| 133 |
include = ["picarones*"]
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Tests Sprint A9 — pipeline de release et artefacts produits."""
|
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests Sprint A9 — pipeline de release et artefacts.
|
| 2 |
+
|
| 3 |
+
Items M-5, M-6, m-15, m-16 de l'audit institutional-readiness-2026-05.
|
| 4 |
+
|
| 5 |
+
Ces tests valident le **contrat de release** sans déclencher de
|
| 6 |
+
build réel (qui nécessiterait Docker buildx + accès PyPI). Ils
|
| 7 |
+
vérifient que les fichiers de configuration sont cohérents et que
|
| 8 |
+
les workflows existent.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import re
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
import pytest
|
| 17 |
+
import yaml
|
| 18 |
+
|
| 19 |
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
# M-5 — setuptools_scm + version dynamique
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_pyproject_uses_dynamic_version() -> None:
|
| 28 |
+
"""``pyproject.toml`` doit déclarer ``version`` en dynamique
|
| 29 |
+
(résolu par setuptools_scm) plutôt qu'en dur."""
|
| 30 |
+
pyproject = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")
|
| 31 |
+
# ``version = "1.0.0"`` ne doit plus apparaître au scope ``[project]``.
|
| 32 |
+
project_block = re.search(
|
| 33 |
+
r"\[project\](.*?)(?=\n\[)",
|
| 34 |
+
pyproject,
|
| 35 |
+
re.DOTALL,
|
| 36 |
+
)
|
| 37 |
+
assert project_block is not None
|
| 38 |
+
block = project_block.group(1)
|
| 39 |
+
assert 'dynamic = ["version"]' in block, (
|
| 40 |
+
"[project] doit avoir dynamic = [\"version\"] (Sprint A9 M-5)"
|
| 41 |
+
)
|
| 42 |
+
# Pas de ligne ``version = "..."`` codée en dur dans [project].
|
| 43 |
+
assert not re.search(r'^version\s*=\s*"', block, re.MULTILINE), (
|
| 44 |
+
'[project] ne doit pas avoir version = "..." en dur — '
|
| 45 |
+
'incompatible avec setuptools_scm.'
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_setuptools_scm_configured() -> None:
|
| 50 |
+
"""``[tool.setuptools_scm]`` doit exister avec ``write_to`` pointant
|
| 51 |
+
vers ``picarones/_version.py``."""
|
| 52 |
+
pyproject = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")
|
| 53 |
+
assert "[tool.setuptools_scm]" in pyproject
|
| 54 |
+
assert 'write_to = "picarones/_version.py"' in pyproject
|
| 55 |
+
# Politique : pas de ``+local`` dans la version (problématique pour
|
| 56 |
+
# PyPI qui rejette les locals).
|
| 57 |
+
assert 'local_scheme = "no-local-version"' in pyproject
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_build_system_includes_setuptools_scm() -> None:
|
| 61 |
+
"""``[build-system].requires`` doit inclure setuptools_scm."""
|
| 62 |
+
pyproject = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")
|
| 63 |
+
build_block = re.search(
|
| 64 |
+
r"\[build-system\](.*?)(?=\n\[)",
|
| 65 |
+
pyproject,
|
| 66 |
+
re.DOTALL,
|
| 67 |
+
)
|
| 68 |
+
assert build_block is not None
|
| 69 |
+
block = build_block.group(1)
|
| 70 |
+
assert "setuptools_scm" in block
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_picarones_version_resolves() -> None:
|
| 74 |
+
"""``picarones.__version__`` doit être lisible et au format PEP 440."""
|
| 75 |
+
import picarones
|
| 76 |
+
|
| 77 |
+
v = picarones.__version__
|
| 78 |
+
assert isinstance(v, str)
|
| 79 |
+
assert len(v) > 0
|
| 80 |
+
# PEP 440 : commence par X.Y.Z
|
| 81 |
+
assert re.match(r"^\d+\.\d+", v), f"Version mal formée : {v!r}"
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ---------------------------------------------------------------------------
|
| 85 |
+
# M-5 + M-6 — Workflow release.yml
|
| 86 |
+
# ---------------------------------------------------------------------------
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_release_workflow_exists() -> None:
|
| 90 |
+
"""``.github/workflows/release.yml`` doit exister."""
|
| 91 |
+
f = REPO_ROOT / ".github" / "workflows" / "release.yml"
|
| 92 |
+
assert f.exists(), "release.yml manquant — pipeline release non automatisé"
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def test_release_workflow_triggers_on_tag() -> None:
|
| 96 |
+
"""Le workflow doit se déclencher sur push d'un tag ``v*.*.*``."""
|
| 97 |
+
f = REPO_ROOT / ".github" / "workflows" / "release.yml"
|
| 98 |
+
data = yaml.safe_load(f.read_text(encoding="utf-8"))
|
| 99 |
+
# YAML parse ``on`` en bool True — utiliser ``True`` ou la clé string.
|
| 100 |
+
triggers = data.get(True) or data.get("on") or {}
|
| 101 |
+
assert "push" in triggers
|
| 102 |
+
push = triggers["push"]
|
| 103 |
+
assert "tags" in push
|
| 104 |
+
tags = push["tags"]
|
| 105 |
+
# Au moins un pattern qui matche v*.*.*
|
| 106 |
+
assert any("v*" in str(t) for t in tags)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def test_release_workflow_uses_oidc() -> None:
|
| 110 |
+
"""Le workflow doit utiliser OIDC trust pour PyPI (pas de token long-lived)."""
|
| 111 |
+
f = REPO_ROOT / ".github" / "workflows" / "release.yml"
|
| 112 |
+
text = f.read_text(encoding="utf-8")
|
| 113 |
+
# Vérifie que ``id-token: write`` est présent pour les jobs publish
|
| 114 |
+
assert "id-token: write" in text
|
| 115 |
+
# Vérifie que pypi-publish est utilisé (gh-action-pypi-publish)
|
| 116 |
+
assert "pypa/gh-action-pypi-publish" in text
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def test_release_workflow_publishes_to_ghcr() -> None:
|
| 120 |
+
"""Le workflow doit construire et pousser une image multi-arch
|
| 121 |
+
sur ghcr.io."""
|
| 122 |
+
f = REPO_ROOT / ".github" / "workflows" / "release.yml"
|
| 123 |
+
text = f.read_text(encoding="utf-8")
|
| 124 |
+
assert "ghcr.io/" in text
|
| 125 |
+
assert "linux/amd64,linux/arm64" in text
|
| 126 |
+
assert "docker/build-push-action" in text
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def test_release_workflow_creates_github_release() -> None:
|
| 130 |
+
"""Le workflow doit créer une GitHub Release avec les artefacts."""
|
| 131 |
+
f = REPO_ROOT / ".github" / "workflows" / "release.yml"
|
| 132 |
+
text = f.read_text(encoding="utf-8")
|
| 133 |
+
assert "softprops/action-gh-release" in text or "create-release" in text
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# ---------------------------------------------------------------------------
|
| 137 |
+
# m-15 — picarones.spec (PyInstaller) sans hiddenimports manuels
|
| 138 |
+
# ---------------------------------------------------------------------------
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def test_pyinstaller_spec_uses_collect_submodules() -> None:
|
| 142 |
+
"""``picarones.spec`` doit utiliser ``collect_submodules`` au lieu
|
| 143 |
+
d'une liste manuelle d'imports cachés."""
|
| 144 |
+
f = REPO_ROOT / "picarones.spec"
|
| 145 |
+
if not f.exists():
|
| 146 |
+
pytest.skip("picarones.spec absent — release PyInstaller non utilisée")
|
| 147 |
+
text = f.read_text(encoding="utf-8")
|
| 148 |
+
assert "collect_submodules" in text, (
|
| 149 |
+
"picarones.spec doit utiliser PyInstaller.utils.hooks.collect_submodules "
|
| 150 |
+
"pour auto-détecter les imports — la liste manuelle dérivait silencieusement."
|
| 151 |
+
)
|
| 152 |
+
assert 'collect_submodules("picarones")' in text
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def test_pyinstaller_spec_no_obsolete_paths() -> None:
|
| 156 |
+
"""``picarones.spec`` ne doit plus référencer les anciens chemins
|
| 157 |
+
qui n'existent plus depuis le refactor Cercle 1/2/3 (Sprint 33)."""
|
| 158 |
+
f = REPO_ROOT / "picarones.spec"
|
| 159 |
+
if not f.exists():
|
| 160 |
+
pytest.skip("picarones.spec absent")
|
| 161 |
+
text = f.read_text(encoding="utf-8")
|
| 162 |
+
obsolete = [
|
| 163 |
+
"picarones.core.runner", # → measurements.runner
|
| 164 |
+
"picarones.core.statistics", # → measurements.statistics
|
| 165 |
+
"picarones.core.confusion", # → measurements.confusion
|
| 166 |
+
"picarones.importers.iiif", # → extras.importers.iiif
|
| 167 |
+
]
|
| 168 |
+
for path in obsolete:
|
| 169 |
+
assert path not in text, (
|
| 170 |
+
f"Chemin obsolète référencé : {path}. "
|
| 171 |
+
"Refactor Sprint 33 a déplacé les modules — collect_submodules "
|
| 172 |
+
"résout automatiquement."
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# ---------------------------------------------------------------------------
|
| 177 |
+
# m-16 — Extras placeholders retirés
|
| 178 |
+
# ---------------------------------------------------------------------------
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def test_no_empty_extras_placeholders() -> None:
|
| 182 |
+
"""Les extras ``[historical]`` et ``[importers]`` qui valaient
|
| 183 |
+
``[]`` ont été retirés (Sprint A9 m-16)."""
|
| 184 |
+
pyproject = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")
|
| 185 |
+
# On cherche ``historical = []`` ou ``importers = []`` au scope
|
| 186 |
+
# ``[project.optional-dependencies]``.
|
| 187 |
+
assert not re.search(
|
| 188 |
+
r"^historical\s*=\s*\[\s*\]",
|
| 189 |
+
pyproject,
|
| 190 |
+
re.MULTILINE,
|
| 191 |
+
), "Placeholder vide ``historical = []`` doit être retiré."
|
| 192 |
+
assert not re.search(
|
| 193 |
+
r"^importers\s*=\s*\[\s*\]",
|
| 194 |
+
pyproject,
|
| 195 |
+
re.MULTILINE,
|
| 196 |
+
), "Placeholder vide ``importers = []`` doit être retiré."
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def test_all_extra_does_not_reference_removed_extras() -> None:
|
| 200 |
+
"""L'extra ``all`` ne doit plus référencer ``historical`` /
|
| 201 |
+
``importers``."""
|
| 202 |
+
pyproject = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")
|
| 203 |
+
# Cherche la définition de ``all = [...]``
|
| 204 |
+
m = re.search(r"^all\s*=\s*\[(.*?)\]", pyproject, re.MULTILINE | re.DOTALL)
|
| 205 |
+
assert m is not None
|
| 206 |
+
all_block = m.group(1)
|
| 207 |
+
assert "historical" not in all_block
|
| 208 |
+
assert "importers" not in all_block
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
# ---------------------------------------------------------------------------
|
| 212 |
+
# Doc release-process.md
|
| 213 |
+
# ---------------------------------------------------------------------------
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def test_release_process_doc_exists() -> None:
|
| 217 |
+
"""``docs/operations/release-process.md`` doit exister et couvrir
|
| 218 |
+
les sections clés de la procédure."""
|
| 219 |
+
f = REPO_ROOT / "docs" / "operations" / "release-process.md"
|
| 220 |
+
assert f.exists()
|
| 221 |
+
text = f.read_text(encoding="utf-8")
|
| 222 |
+
for section in [
|
| 223 |
+
"Procédure release standard",
|
| 224 |
+
"Versionnement",
|
| 225 |
+
"rollback",
|
| 226 |
+
"OIDC",
|
| 227 |
+
]:
|
| 228 |
+
assert section.lower() in text.lower(), (
|
| 229 |
+
f"Section manquante dans release-process.md : {section!r}"
|
| 230 |
+
)
|