# Release — Python package. Runs on version tags (v1.2.3) pushed to main. # Creates a Gitea release with wheel + sdist and publishes to PyPI. # Requires a PYPI_TOKEN secret (project-scoped upload token). name: Release on: push: tags: ["v*"] jobs: release: runs-on: fedora permissions: releases: write steps: - uses: actions/checkout@v7 - name: Set up Python and uv uses: astral-sh/setup-uv@v6 with: python-version: "3.14" # match requires-python in pyproject.toml enable-cache: true cache-dependency-glob: "uv.lock" - name: Resolve and verify version id: version env: VERSION: ${{ gitea.ref_name }} run: | set -euo pipefail if ! echo "$VERSION" | grep -qE '^v[0-9]+(\.[0-9]+){0,2}([-+].*)?$'; then echo "ERROR: expected a semver tag like v1.2.3, got: '$VERSION'" exit 1 fi VERSION_NO_V="${VERSION#v}" PYPROJECT_VERSION="$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")" if [ "$VERSION_NO_V" != "$PYPROJECT_VERSION" ]; then echo "ERROR: tag ${VERSION} does not match pyproject.toml version (${PYPROJECT_VERSION})" exit 1 fi echo "version_no_v=${VERSION_NO_V}" >> "$GITEA_OUTPUT" - name: Quality gate run: | set -euo pipefail uv sync --frozen --group dev uv run ruff check src/ tests/ uv run ruff format --check src/ tests/ uv run pytest - name: Build wheel and sdist env: VERSION_NO_V: ${{ steps.version.outputs.version_no_v }} run: | set -euo pipefail uv pip install --upgrade build uv run python -m build ls -lh "dist/volumen-${VERSION_NO_V}"* - name: Extract CHANGELOG section env: VERSION_NO_V: ${{ steps.version.outputs.version_no_v }} run: | set -euo pipefail sed -n "/^## \[${VERSION_NO_V}\] /,/^## \[/p" CHANGELOG.md \ | sed '$d' \ | tail -n +2 \ > release-body.md if [ ! -s release-body.md ]; then echo "ERROR: no CHANGELOG section found for ${VERSION_NO_V}" echo "Expected a heading like: ## [${VERSION_NO_V}] — YYYY-MM-DD" exit 1 fi echo "Release body preview:" head -n 20 release-body.md - name: Create release id: create env: GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_SERVER_URL: ${{ gitea.server_url }} GITEA_REPOSITORY: ${{ gitea.repository }} GITEA_REF_NAME: ${{ gitea.ref_name }} run: | set -euo pipefail BODY="$(python -c 'import json,sys; print(json.dumps(sys.stdin.read()))' < release-body.md)" response="$(curl -sS -w '\n%{http_code}' \ -H "Authorization: token ${GITEA_TOKEN}" \ -H "Content-Type: application/json" \ -X POST \ "${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases" \ -d "{\"tag_name\":\"${GITEA_REF_NAME}\",\"name\":\"${GITEA_REF_NAME}\",\"body\":${BODY},\"draft\":false,\"prerelease\":false}")" http_code="$(echo "$response" | tail -1)" payload="$(echo "$response" | sed '$d')" echo "HTTP ${http_code}" if [ "$http_code" != "201" ]; then echo "Failed to create release: ${payload}" exit 1 fi RELEASE_ID="$(echo "$payload" | python -c 'import json,sys; print(json.loads(sys.stdin.read())["id"])')" echo "Created release ID=${RELEASE_ID}" echo "release_id=${RELEASE_ID}" >> "$GITEA_OUTPUT" - name: Upload wheel and sdist assets env: GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_SERVER_URL: ${{ gitea.server_url }} GITEA_REPOSITORY: ${{ gitea.repository }} VERSION_NO_V: ${{ steps.version.outputs.version_no_v }} RELEASE_ID: ${{ steps.create.outputs.release_id }} run: | set -euo pipefail for ASSET in "volumen-${VERSION_NO_V}-py3-none-any.whl" \ "volumen-${VERSION_NO_V}.tar.gz"; do if [ ! -f "dist/${ASSET}" ]; then echo "Missing build artefact: dist/${ASSET}" exit 1 fi http_code="$(curl -sS -o /dev/null -w '%{http_code}' \ -H "Authorization: token ${GITEA_TOKEN}" \ -H "Content-Type: application/octet-stream" \ -X POST \ --data-binary "@dist/${ASSET}" \ "${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets?name=${ASSET}")" echo "Uploaded ${ASSET}: HTTP ${http_code}" if [ "$http_code" != "201" ]; then echo "Failed to upload ${ASSET}" exit 1 fi done - name: Publish to PyPI env: UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} run: uv publish