Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
363 changes: 363 additions & 0 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,363 @@
name: Python CI/CD Pipeline

on:
push:
branches:
- main
paths:
- 'python/**'
- '.github/workflows/python.yml'
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'python/**'
- '.github/workflows/python.yml'
workflow_dispatch:
inputs:
bump_type:
description: 'Version bump type'
required: true
type: choice
options:
- patch
- minor
- major
description:
description: 'Release description (optional)'
required: false
type: string

concurrency:
group: python-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

defaults:
run:
working-directory: python

jobs:
# === DETECT CHANGES - determines which jobs should run ===
detect-changes:
name: Detect Changes
runs-on: ubuntu-latest
if: github.event_name != 'workflow_dispatch'
outputs:
py-changed: ${{ steps.changes.outputs.py-changed }}
tests-changed: ${{ steps.changes.outputs.tests-changed }}
package-changed: ${{ steps.changes.outputs.package-changed }}
docs-changed: ${{ steps.changes.outputs.docs-changed }}
workflow-changed: ${{ steps.changes.outputs.workflow-changed }}
any-code-changed: ${{ steps.changes.outputs.any-code-changed }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.13'

- name: Detect changes
id: changes
env:
GITHUB_EVENT_NAME: ${{ github.event_name }}
GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }}
GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: python scripts/detect_code_changes.py

# === CHANGELOG CHECK - only runs on PRs with code changes ===
# Docs-only PRs (./docs folder, markdown files) don't require changelog fragments
changelog:
name: Changelog Fragment Check
runs-on: ubuntu-latest
needs: [detect-changes]
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any-code-changed == 'true'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"

- name: Install scriv
run: pip install "scriv[toml]"

- name: Check for changelog fragments
run: |
# Get list of fragment files (excluding README and template)
FRAGMENTS=$(find changelog.d -name "*.md" ! -name "README.md" ! -name "*.j2" 2>/dev/null | wc -l)

# Get changed files in PR
CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)

# Check if any source files changed (excluding docs and config)
SOURCE_CHANGED=$(echo "$CHANGED_FILES" | grep -E "^python/(src/|tests/|scripts/)" | wc -l)

if [ "$SOURCE_CHANGED" -gt 0 ] && [ "$FRAGMENTS" -eq 0 ]; then
echo "::warning::No changelog fragment found. Please run 'scriv create' and document your changes."
echo ""
echo "To create a changelog fragment:"
echo " pip install 'scriv[toml]'"
echo " scriv create"
echo ""
echo "This is similar to adding a changeset in JavaScript projects."
echo "See changelog.d/README.md for more information."
# Note: This is a warning, not a failure, to allow flexibility
# Change 'exit 0' to 'exit 1' to make it required
exit 0
fi

echo "✓ Changelog check passed"

# === LINT AND FORMAT CHECK ===
lint:
name: Lint and Format Check
runs-on: ubuntu-latest
needs: [detect-changes]
if: |
github.event_name == 'push' ||
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.py-changed == 'true' ||
needs.detect-changes.outputs.tests-changed == 'true' ||
needs.detect-changes.outputs.docs-changed == 'true' ||
needs.detect-changes.outputs.package-changed == 'true' ||
needs.detect-changes.outputs.workflow-changed == 'true'
steps:
- uses: actions/checkout@v4

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.13'

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"

- name: Run Ruff linting
run: ruff check .

- name: Check Ruff formatting
run: ruff format --check .

- name: Run mypy
run: mypy src

- name: Check file size limit
run: python scripts/check_file_size.py

# === TEST ===
test:
name: Test (Python ${{ matrix.python-version }} on ${{ matrix.os }})
runs-on: ${{ matrix.os }}
needs: [detect-changes, changelog]
# Run if: push event, OR changelog succeeded, OR changelog was skipped (docs-only PR)
if: always() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || needs.changelog.result == 'success' || needs.changelog.result == 'skipped')
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ['3.13']
steps:
- uses: actions/checkout@v4

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"

- name: Run tests
run: pytest tests/ -v --cov=src --cov-report=xml --cov-report=term

- name: Upload coverage to Codecov
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13'
uses: codecov/codecov-action@v4
with:
file: ./python/coverage.xml
fail_ci_if_error: false
flags: python

# === BUILD PACKAGE ===
build:
name: Build Package
runs-on: ubuntu-latest
needs: [detect-changes, lint, test]
# Run if: push/dispatch event, OR lint/test succeeded, OR lint/test were skipped (docs-only PR)
if: |
always() && (
github.event_name == 'push' ||
github.event_name == 'workflow_dispatch' ||
(
(needs.lint.result == 'success' || needs.lint.result == 'skipped') &&
(needs.test.result == 'success' || needs.test.result == 'skipped')
)
)
steps:
- uses: actions/checkout@v4

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.13'

- name: Install build dependencies
run: |
python -m pip install --upgrade pip
pip install build twine

- name: Build package
run: python -m build

- name: Check package
run: twine check dist/*

- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: python-dist
path: python/dist/

# === AUTO RELEASE - triggers on push to main if version changed ===
auto-release:
name: Auto Release
needs: [lint, test, build]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.13'

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install build twine

- name: Check if version changed
id: version_check
run: |
# Get current version from pyproject.toml
CURRENT_VERSION=$(grep -Po '(?<=^version = ")[^"]*' pyproject.toml)
echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT

# Check if tag exists (with python prefix to differentiate from JS/Rust)
if git rev-parse "python-v$CURRENT_VERSION" >/dev/null 2>&1; then
echo "Tag python-v$CURRENT_VERSION already exists, skipping release"
echo "should_release=false" >> $GITHUB_OUTPUT
else
echo "New version detected: $CURRENT_VERSION"
echo "should_release=true" >> $GITHUB_OUTPUT
fi

- name: Download artifacts
if: steps.version_check.outputs.should_release == 'true'
uses: actions/download-artifact@v4
with:
name: python-dist
path: python/dist/

- name: Publish to PyPI
if: steps.version_check.outputs.should_release == 'true'
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: python/dist/

- name: Create GitHub Release
if: steps.version_check.outputs.should_release == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python scripts/create_github_release.py \
--version "${{ steps.version_check.outputs.current_version }}" \
--repository "${{ github.repository }}" \
--tag-prefix "python-v"

# === MANUAL RELEASE - triggered via workflow_dispatch ===
manual-release:
name: Manual Release
needs: [lint, test, build]
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.13'

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install build twine "scriv[toml]"

- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

- name: Collect changelog fragments
run: |
# Check if there are any fragments to collect
FRAGMENTS=$(find changelog.d -name "*.md" ! -name "README.md" ! -name "*.j2" 2>/dev/null | wc -l)
if [ "$FRAGMENTS" -gt 0 ]; then
echo "Found $FRAGMENTS changelog fragment(s), collecting..."
scriv collect --version "${{ github.event.inputs.bump_type }}"
else
echo "No changelog fragments found, skipping collection"
fi

- name: Version and commit
id: version
run: |
python scripts/version_and_commit.py \
--bump-type "${{ github.event.inputs.bump_type }}" \
--description "${{ github.event.inputs.description }}"

- name: Build package
if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true'
run: python -m build

- name: Check package
if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true'
run: twine check dist/*

- name: Publish to PyPI
if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true'
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: python/dist/

- name: Create GitHub Release
if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python scripts/create_github_release.py \
--version "${{ steps.version.outputs.new_version }}" \
--repository "${{ github.repository }}" \
--tag-prefix "python-v"
Loading