Claude commited on
Commit
628d92a
·
unverified ·
1 Parent(s): fc30527

feat(sprint-A9): release pipeline PyPI + ghcr.io + GitHub Release

Browse files

Sprint 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 ADDED
@@ -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') }}
.gitignore CHANGED
@@ -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
docs/operations/release-process.md ADDED
@@ -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.
picarones.spec CHANGED
@@ -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
- # Imports cachés (non détectés automatiquement par PyInstaller)
45
- hiddenimports=[
46
- # CLI
47
- "picarones.cli",
48
- "picarones.core.corpus",
49
- "picarones.core.metrics",
50
- "picarones.core.results",
51
- "picarones.core.runner",
52
- "picarones.core.normalization",
53
- "picarones.core.statistics",
54
- "picarones.core.confusion",
55
- "picarones.core.char_scores",
56
- "picarones.core.taxonomy",
57
- "picarones.core.structure",
58
- "picarones.core.image_quality",
59
- "picarones.core.difficulty",
60
- "picarones.core.history",
61
- "picarones.core.robustness",
62
- "picarones.engines.base",
63
- "picarones.engines.tesseract",
64
- "picarones.engines.pero_ocr",
65
- "picarones.engines.mistral_ocr",
66
- "picarones.engines.google_vision",
67
- "picarones.engines.azure_doc_intel",
68
- "picarones.llm.base",
69
- "picarones.llm.openai_adapter",
70
- "picarones.llm.anthropic_adapter",
71
- "picarones.llm.mistral_adapter",
72
- "picarones.llm.ollama_adapter",
73
- "picarones.importers.iiif",
74
- "picarones.importers.gallica",
75
- "picarones.importers.escriptorium",
76
- "picarones.importers.huggingface",
77
- "picarones.importers.htr_united",
78
- "picarones.pipelines.base",
79
- "picarones.pipelines.over_normalization",
80
- "picarones.report.generator",
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=[
picarones/__init__.py CHANGED
@@ -21,12 +21,21 @@ Voir ``docs/architecture.md`` pour la cartographie complète des
21
 
22
  from __future__ import annotations
23
 
24
- # Version (lecture dynamique depuis le package metadata après ``pip install -e .``)
 
 
 
 
 
 
25
  try:
26
- from importlib.metadata import version as _get_version
27
- __version__ = _get_version("picarones")
28
- except Exception: # noqa: BLE001
29
- __version__ = "1.0.0"
 
 
 
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
 
pyproject.toml CHANGED
@@ -1,10 +1,17 @@
1
  [build-system]
2
- requires = ["setuptools>=68.0", "wheel"]
 
 
 
 
 
3
  build-backend = "setuptools.build_meta"
4
 
5
  [project]
6
  name = "picarones"
7
- version = "1.0.0"
 
 
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
- # Métriques philologiques pour documents historiques (Cercle 3, phase B
94
- # du chantier de refonte post-Sprint 97). Aujourd'hui les modules
95
- # philologiques (`picarones.extras.historical.*`) sont livrés dans le
96
- # package principal sans dépendance externe — l'extra ``[historical]``
97
- # n'ajoute donc aucun paquet à installer. Il est déclaré ici pour
98
- # **documenter l'intention** : un usage purement moderne (sans cas
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,historical,importers]",
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*"]
tests/release/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Tests Sprint A9 — pipeline de release et artefacts produits."""
tests/release/test_release_artifacts.py ADDED
@@ -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
+ )