Picarones / .github /workflows /release.yml
Claude
feat(sprint-A9): release pipeline PyPI + ghcr.io + GitHub Release
628d92a unverified
# Sprint A9 (M-5 + M-6) β€” pipeline de release.
#
# DΓ©clenchΓ© sur push d'un tag ``v*.*.*`` (ex : ``v1.1.0``,
# ``v1.2.0-rc1``). Comportement :
#
# 1. Build sdist + wheel via ``python -m build`` (setuptools_scm
# dΓ©rive automatiquement la version du tag).
# 2. Validation ``twine check`` (taille, README rendu, mΓ©tadonnΓ©es).
# 3. Smoke test : install dans un container vierge + ``picarones demo``.
# 4. Publication TestPyPI (validation finale avant prod).
# 5. Smoke test depuis TestPyPI (``pip install --index-url testpypi``).
# 6. Publication PyPI via Trusted Publisher (OIDC, sans token).
# 7. Build image Docker multi-arch (linux/amd64 + linux/arm64).
# 8. Push ghcr.io/maribakulj/picarones:<version> + :latest.
# 9. CrΓ©ation GitHub Release avec corps gΓ©nΓ©rΓ© depuis CHANGELOG.
#
# PrΓ©requis (Γ  configurer une fois cΓ΄tΓ© GitHub) :
# - PyPI Trusted Publisher : ajouter ce workflow dans
# https://pypi.org/manage/account/publishing/
# - GHCR_TOKEN n'est pas requis : ``GITHUB_TOKEN`` natif suffit
# avec ``packages: write`` (cf. permissions du job docker).
name: Release
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
tag:
description: "Tag Γ  releaser (ex: v1.1.0). Manuel uniquement."
required: true
type: string
permissions:
contents: read
jobs:
# ──────────────────────────────────────────────────────────────────
# Job 1 β€” Build & validate distribution Python
# ──────────────────────────────────────────────────────────────────
build:
name: Build & validate
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout (full history for setuptools_scm)
uses: actions/checkout@v4
with:
fetch-depth: 0 # tags + history requis par setuptools_scm
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
- name: Install build tools
run: python -m pip install --upgrade build twine setuptools_scm
- name: Detect version from tag
id: version
run: |
VERSION=$(python -m setuptools_scm)
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "Version dΓ©tectΓ©e : ${VERSION}"
- name: Build sdist + wheel
run: python -m build
- name: Twine validate
run: twine check dist/*
- name: Smoke test wheel install
run: |
python -m venv /tmp/smoke
/tmp/smoke/bin/pip install dist/*.whl
/tmp/smoke/bin/picarones --version
- name: Upload artefacts
uses: actions/upload-artifact@v4
with:
name: dist-${{ steps.version.outputs.version }}
path: dist/
retention-days: 30
# ──────────────────────────────────────────────────────────────────
# Job 2 β€” Publication TestPyPI (validation finale)
# ──────────────────────────────────────────────────────────────────
publish-testpypi:
name: Publish to TestPyPI
needs: build
runs-on: ubuntu-latest
environment:
name: testpypi
url: https://test.pypi.org/p/picarones
permissions:
id-token: write # OIDC trust pour TestPyPI
steps:
- name: Download artefacts
uses: actions/download-artifact@v4
with:
name: dist-${{ needs.build.outputs.version }}
path: dist/
- name: Publish to TestPyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/
skip-existing: true
# ──────────────────────────────────────────────────────────────────
# Job 3 β€” Smoke test depuis TestPyPI (avant publication finale)
# ──────────────────────────────────────────────────────────────────
testpypi-smoke:
name: Smoke test TestPyPI install
needs: [build, publish-testpypi]
runs-on: ubuntu-latest
steps:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install Tesseract (pour le demo)
run: |
sudo apt-get update -qq
sudo apt-get install -y tesseract-ocr tesseract-ocr-fra
- name: Install from TestPyPI
run: |
# Retry pour le dΓ©lai d'indexation TestPyPI (~30s typique)
for i in 1 2 3 4 5; do
pip install \
--index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ \
picarones==${{ needs.build.outputs.version }} && break
echo "Tentative $i Γ©chouΓ©e, retry dans 30s..."
sleep 30
done
- name: Run demo
run: |
picarones --version
picarones demo --output /tmp/demo.html
test -s /tmp/demo.html
# ──────────────────────────────────────────────────────────────────
# Job 4 β€” Publication PyPI (production)
# ──────────────────────────────────────────────────────────────────
publish-pypi:
name: Publish to PyPI
needs: [build, testpypi-smoke]
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/picarones
permissions:
id-token: write # OIDC trust β€” pas de token long-lived
steps:
- name: Download artefacts
uses: actions/download-artifact@v4
with:
name: dist-${{ needs.build.outputs.version }}
path: dist/
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
# ──────────────────────────────────────────────────────────────────
# Job 5 β€” Image Docker multi-arch publiΓ©e sur ghcr.io
# ──────────────────────────────────────────────────────────────────
docker:
name: Build & push Docker image
needs: [build, publish-pypi]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # push ghcr.io
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU (for arm64 emulation)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to ghcr.io
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build & push (multi-arch)
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/picarones:${{ needs.build.outputs.version }}
ghcr.io/${{ github.repository_owner }}/picarones:latest
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true # SLSA attestation
sbom: true # Software Bill of Materials
# ──────────────────────────────────────────────────────────────────
# Job 6 β€” GitHub Release avec corps depuis CHANGELOG
# ──────────────────────────────────────────────────────────────────
github-release:
name: Create GitHub Release
needs: [build, publish-pypi, docker]
runs-on: ubuntu-latest
permissions:
contents: write # crΓ©er la release
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download artefacts
uses: actions/download-artifact@v4
with:
name: dist-${{ needs.build.outputs.version }}
path: dist/
- name: Extract release notes from CHANGELOG
id: notes
run: |
# Lit la section ``## [X.Y.Z]`` correspondant Γ  la version.
# Si pas trouvΓ©e, fallback sur un message gΓ©nΓ©rique.
VERSION="${{ needs.build.outputs.version }}"
NOTES_FILE=/tmp/release_notes.md
python <<PYEOF > "$NOTES_FILE"
import re, sys
changelog = open("CHANGELOG.md", encoding="utf-8").read()
version = "$VERSION"
# On cherche soit ``## [1.2.3]``, ``## [v1.2.3]``, ou ``## [1.2.3]``
pat = re.compile(rf"^## \[v?{re.escape(version)}\][^\n]*\n(.+?)(?=^## \[|^---|\Z)", re.MULTILINE | re.DOTALL)
m = pat.search(changelog)
if m:
print(m.group(1).strip())
else:
print(f"Release {version} β€” voir CHANGELOG.md pour les dΓ©tails.")
PYEOF
echo "Notes extraites depuis CHANGELOG :"
cat "$NOTES_FILE"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: Picarones ${{ needs.build.outputs.version }}
body_path: /tmp/release_notes.md
files: |
dist/*.whl
dist/*.tar.gz
draft: false
prerelease: ${{ contains(needs.build.outputs.version, 'rc') || contains(needs.build.outputs.version, 'a') || contains(needs.build.outputs.version, 'b') }}