Skip to content
This repository was archived by the owner on Mar 10, 2026. It is now read-only.

Chore: bump version 0.4.2 → 0.5.0 #2

Chore: bump version 0.4.2 → 0.5.0

Chore: bump version 0.4.2 → 0.5.0 #2

Workflow file for this run

# This file is part of the jebel-quant/rhiza repository
# (https://github.com/jebel-quant/rhiza).
#
# Release Workflow for Python Packages with Optional Devcontainer Publishing
#
# This workflow implements a secure, maintainable release pipeline with distinct phases:
#
# 📋 Pipeline Phases:
# 1. 🔍 Validate Tag - Check tag format and ensure release doesn't already exist
# 2. 🏗️ Build - Build Python package with Hatch (if [build-system] is defined in pyproject.toml)
# 3. 📦 Generate SBOM - Create Software Bill of Materials (CycloneDX format)
# 4. 📝 Draft Release - Create draft GitHub release with build artifacts and SBOM
# 5. 🚀 Publish to PyPI - Publish package using OIDC or custom feed
# 6. 🐳 Publish Devcontainer - Build and publish devcontainer image (conditional)
# 7. ✅ Finalize Release - Publish the GitHub release with links
#
# 📦 SBOM Generation:
# - Generated using CycloneDX format (industry standard for software supply chain security)
# - Creates both JSON and XML formats for maximum compatibility
# - SBOM attestations are created and stored (public repos only)
# - Attached to GitHub releases for transparency and compliance
# - Skipped if pyproject.toml doesn't exist
#
# 🐳 Devcontainer Publishing:
# - Only occurs when PUBLISH_DEVCONTAINER repository variable is set to "true"
# - Requires .devcontainer directory to exist
# - Uses the release tag (e.g., v1.2.3) as the image tag
# - Image name: {registry}/{owner}/{repository}/devcontainer
# - Registry defaults to ghcr.io (override with DEVCONTAINER_REGISTRY variable)
# - Config file: Always .devcontainer/devcontainer.json
# - Adds devcontainer image link to GitHub release notes
#
# 🚀 PyPI Publishing:
# - Skipped if no dist/ artifacts exist
# - Skipped if pyproject.toml contains "Private :: Do Not Upload"
# - Uses Trusted Publishing (OIDC) for PyPI (no stored credentials)
# - For custom feeds, use PYPI_REPOSITORY_URL and PYPI_TOKEN secrets
# - Adds PyPI/custom feed link to GitHub release notes
#
# 🔐 Security:
# - No PyPI credentials stored; relies on Trusted Publishing via GitHub OIDC
# - For custom feeds, PYPI_TOKEN secret is used with default username __token__
# - Container registry uses GITHUB_TOKEN for authentication
# - SLSA provenance attestations generated for build artifacts (public repos only)
# - SBOM attestations generated for supply chain transparency (public repos only)
#
# 📄 Requirements:
# - pyproject.toml with top-level version field (for Python packages)
# - Package registered on PyPI as Trusted Publisher (for PyPI publishing)
# - PUBLISH_DEVCONTAINER variable set to "true" (for devcontainer publishing)
# - .devcontainer/devcontainer.json file (for devcontainer publishing)
#
# ✅ To Trigger:
# Create and push a version tag:
# git tag v1.2.3
# git push origin v1.2.3
#
# 🎯 For repos without Python packages:
# The workflow gracefully skips Python-related steps if pyproject.toml doesn't exist.
#
# 🎯 For repos without devcontainers:
# The workflow gracefully skips devcontainer steps if PUBLISH_DEVCONTAINER is not
# set to "true" or .devcontainer directory doesn't exist.
name: (RHIZA) RELEASE
on:
push:
tags:
- 'v*'
permissions:
contents: write # Needed to create releases
id-token: write # Needed for OIDC authentication with PyPI
packages: write # Needed to publish devcontainer image
attestations: write # Needed for SLSA provenance attestations (public repos only)
jobs:
tag:
name: Validate Tag
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.set_tag.outputs.tag }}
steps:
- name: Checkout Code
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Set Tag Variable
id: set_tag
run: |
TAG="${GITHUB_REF#refs/tags/}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Validate Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.set_tag.outputs.tag }}
run: |
if gh release view "$TAG" >/dev/null 2>&1; then
DRAFT_STATUS=$(gh release view "$TAG" --json isDraft --jq '.isDraft')
if [ "$DRAFT_STATUS" != "true" ]; then
echo "::error::Release '$TAG' already exists and is not a draft. Please use a new tag."
exit 1
else
echo "::warning::Release '$TAG' exists as a draft. Will proceed to update it."
fi
fi
build:
name: Build
runs-on: ubuntu-latest
needs: tag
steps:
- name: Checkout Code
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@v7.3.1
with:
version: "0.10.8"
- name: Verify version matches tag
if: hashFiles('pyproject.toml') != ''
run: |
TAG_VERSION="${{ needs.tag.outputs.tag }}"
TAG_VERSION=${TAG_VERSION#v}
PROJECT_VERSION=$(uv version --short)
# Normalize tag version to PEP 440 format for comparison.
# Tags use semver format (e.g., 0.11.1-beta.1) while uv version --short
# returns PEP 440 normalized format (e.g., 0.11.1b1).
NORMALIZED_TAG=$(uv run --with packaging --no-project python3 -c "from packaging.version import Version; print(Version('$TAG_VERSION'))")
if [[ "$PROJECT_VERSION" != "$NORMALIZED_TAG" ]]; then
echo "::error::Version mismatch: pyproject.toml has '$PROJECT_VERSION' but tag is '$NORMALIZED_TAG' (from tag '$TAG_VERSION')"
exit 1
fi
echo "Version verified: $PROJECT_VERSION matches tag (normalized: $NORMALIZED_TAG)"
- name: Detect buildable Python package
id: buildable
run: |
if [[ -f pyproject.toml ]] && grep -q '^\[build-system\]' pyproject.toml; then
echo "buildable=true" >> "$GITHUB_OUTPUT"
else
echo "buildable=false" >> "$GITHUB_OUTPUT"
fi
- name: Build
if: steps.buildable.outputs.buildable == 'true'
run: |
printf "[INFO] Building package...\n"
uv build
- name: Install Python for SBOM generation
if: hashFiles('pyproject.toml') != ''
uses: actions/setup-python@v6.2.0
with:
python-version-file: .python-version
- name: Sync environment for SBOM generation
if: hashFiles('pyproject.toml') != ''
run: |
export UV_EXTRA_INDEX_URL="${{ secrets.UV_EXTRA_INDEX_URL }}"
uv sync --all-extras --all-groups --frozen
- name: Generate SBOM (CycloneDX)
if: hashFiles('pyproject.toml') != ''
run: |
printf "[INFO] Generating SBOM in CycloneDX format...\n"
# Note: uvx caches the tool environment, so the second call is fast
uvx --from 'cyclonedx-bom>=7.0.0' cyclonedx-py environment --pyproject pyproject.toml --of JSON -o sbom.cdx.json
uvx --from 'cyclonedx-bom>=7.0.0' cyclonedx-py environment --pyproject pyproject.toml --of XML -o sbom.cdx.xml
printf "[INFO] SBOM generation complete\n"
printf "Generated files:\n"
ls -lh sbom.cdx.*
- name: Attest SBOM
# Attest only the JSON format as it's the canonical machine-readable format.
# The XML format is provided for compatibility but doesn't need separate attestation.
if: hashFiles('pyproject.toml') != '' && github.event.repository.private == false
uses: actions/attest-sbom@v4
with:
subject-path: sbom.cdx.json
sbom-path: sbom.cdx.json
- name: Upload SBOM artifacts
if: hashFiles('pyproject.toml') != ''
uses: actions/upload-artifact@v7.0.0
with:
name: sbom
path: |
sbom.cdx.json
sbom.cdx.xml
- name: Generate SLSA provenance attestations
if: steps.buildable.outputs.buildable == 'true' && github.event.repository.private == false
uses: actions/attest-build-provenance@v4
with:
subject-path: dist/*
- name: Upload dist artifact
if: steps.buildable.outputs.buildable == 'true'
uses: actions/upload-artifact@v7.0.0
with:
name: dist
path: dist
# Don't try to upload artifacts to GitHub Release at all
draft-release:
name: Draft GitHub Release
runs-on: ubuntu-latest
needs: [tag, build]
steps:
- name: Download SBOM artifact
# Downloads sbom.cdx.json and sbom.cdx.xml into sbom/ directory
uses: actions/download-artifact@v8.0.0
with:
name: sbom
path: sbom
continue-on-error: true
- name: Create GitHub Release with artifacts
uses: softprops/action-gh-release@v2.5.0
with:
tag_name: ${{ needs.tag.outputs.tag }}
name: ${{ needs.tag.outputs.tag }}
generate_release_notes: true
draft: true
files: |
sbom/*
# Decide at step-level whether to publish
pypi:
name: Publish to PyPI
runs-on: ubuntu-latest
environment: release
needs: [tag, build, draft-release]
outputs:
should_publish: ${{ steps.check_dist.outputs.should_publish }}
steps:
- name: Checkout Code
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Download dist artifact
uses: actions/download-artifact@v8.0.0
with:
name: dist
path: dist
# Continue if dist folder is not found. Don't fail the job.
continue-on-error: true
- name: Check if dist contains artifacts and not marked as private
id: check_dist
run: |
if [[ ! -d dist ]]; then
echo "::warning::No folder dist/. Skipping PyPI publish."
echo "should_publish=false" >> "$GITHUB_OUTPUT"
else
if grep -R "Private :: Do Not Upload" pyproject.toml; then
echo "should_publish=false" >> "$GITHUB_OUTPUT"
else
echo "should_publish=true" >> "$GITHUB_OUTPUT"
fi
fi
cat "$GITHUB_OUTPUT"
# this should not take place, as "Private :: Do Not Upload" set in pyproject.toml
# repository-url and password only used for custom feeds, not for PyPI with OIDC
- name: Publish to PyPI
if: ${{ steps.check_dist.outputs.should_publish == 'true' }}
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist/
skip-existing: true
repository-url: ${{ vars.PYPI_REPOSITORY_URL }}
password: ${{ secrets.PYPI_TOKEN }}
finalise-release:
name: Finalise Release
runs-on: ubuntu-latest
needs: [tag, pypi]
if: needs.pypi.result == 'success'
steps:
- name: Checkout Code
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@v7.3.1
with:
version: "0.10.8"
- name: "Sync the virtual environment for ${{ github.repository }}"
shell: bash
run: |
export UV_EXTRA_INDEX_URL="${{ secrets.uv_extra_index_url }}"
# will just use .python-version?
uv sync --all-extras --all-groups --frozen
- name: Set up Python
uses: actions/setup-python@v6.2.0
- name: Generate PyPI Link
id: pypi_link
if: needs.pypi.outputs.should_publish == 'true' && needs.pypi.result == 'success'
run: |
PACKAGE_NAME=$(uv run python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['name'])")
VERSION="${{ needs.tag.outputs.tag }}"
VERSION=${VERSION#v}
REPO_URL="${{ vars.PYPI_REPOSITORY_URL || '' }}"
if [ -z "$REPO_URL" ]; then
LINK="https://pypi.org/project/$PACKAGE_NAME/$VERSION/"
NAME="PyPI Package"
else
LINK="$REPO_URL"
NAME="Custom Feed Package"
fi
{
echo "message<<EOF"
echo "### $NAME"
echo ""
echo "[$PACKAGE_NAME]($LINK)"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Publish Release
uses: softprops/action-gh-release@v2.5.0
with:
tag_name: ${{ needs.tag.outputs.tag }}
draft: false
append_body: true
body: |
${{ steps.pypi_link.outputs.message }}