# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
# ══════════════════════════════════════════════════════════════════════
# ReleaseKit: Automated Release Pipeline — Python (uv)
# ══════════════════════════════════════════════════════════════════════
#
# This workflow implements a release-please-style pipeline for the
# genkit Python SDK. It uses releasekit to automate:
#
# 1. PREPARE — compute version bumps, generate changelogs, open
# or update a Release PR.
# 2. RELEASE — tag the merge commit, create a GitHub Release.
# 3. PUBLISH — build and publish packages to PyPI in topological
# order with retry and ephemeral version pinning.
#
# ── Automatic Flow ──────────────────────────────────────────────────
#
# push to main ──► releasekit prepare ──► Release PR
# (py/packages/** (autorelease: pending)
# py/plugins/**) │
# merge PR
# │
# ▼
# releasekit release ──► tags + GitHub Release
# │
# ▼
# releasekit publish ──► PyPI
# │
# ▼
# repository_dispatch ──► downstream repos
#
# ── Manual Dispatch Flow ────────────────────────────────────────────
#
# ┌─────────────────────────────────────────────────────────────┐
# │ workflow_dispatch UI │
# │ │
# │ action: [prepare ▼] ──► runs PREPARE job only │
# │ [release ▼] ──► runs RELEASE + PUBLISH + NOTIFY │
# │ │
# │ target: [pypi / testpypi] │
# │ dry_run: [✓] simulate, no side effects │
# │ force_prepare: [✓] skip preflight, force PR creation │
# │ group: [________] target a release group │
# │ bump_type: [auto / patch / minor / major] │
# │ prerelease: [________] e.g. rc.1, beta.1 │
# │ skip_publish: [✓] tag + release but don't publish │
# │ concurrency: [0] max parallel publish (0 = auto) │
# └─────────────────────────────────────────────────────────────┘
#
# ── Trigger Matrix ──────────────────────────────────────────────────
#
# Event │ Jobs that run
# ───────────────────┼──────────────────────────────────
# push to main │ prepare
# PR merged │ release → publish → notify
# dispatch: prepare │ prepare
# dispatch: release │ release → publish → notify
#
# ── Inputs Reference ────────────────────────────────────────────────
#
# Input │ Type │ Default │ Description
# ───────────────┼─────────┼─────────┼──────────────────────────────
# action │ choice │ release │ Pipeline stage: prepare or release
# target │ choice │ pypi │ Registry: pypi or testpypi
# dry_run │ boolean │ true │ Simulate without side effects
# force_prepare │ boolean │ false │ Force PR creation (--force)
# group │ string │ (all) │ Target a specific release group
# bump_type │ choice │ auto │ Override semver bump detection
# prerelease │ string │ (none) │ Prerelease suffix (e.g. rc.1)
# skip_publish │ boolean │ false │ Tag + release, skip registry
# concurrency │ string │ 0 │ Max parallel publish jobs
# max_retries │ string │ 2 │ Retry failed publishes (0 = off)
#
# The workflow is idempotent: re-running any step is safe because
# releasekit skips already-created tags and already-published versions.
# ══════════════════════════════════════════════════════════════════════
name: "ReleaseKit: Python (uv)"
on:
workflow_dispatch:
inputs:
action:
description: 'Which pipeline stage to run'
required: true
default: release
type: choice
options:
- prepare
- release
target:
description: 'Publish target registry (release only)'
required: true
default: pypi
type: choice
options:
- testpypi
- pypi
dry_run:
description: 'Dry run — log what would happen without creating tags or publishing'
required: true
default: true
type: boolean
force_prepare:
description: 'Force create/update the Release PR even if no new bumps are detected'
required: false
default: false
type: boolean
group:
description: 'Release group to target (leave empty for all)'
required: false
type: string
bump_type:
description: 'Override auto-detected bump type'
required: false
default: auto
type: choice
options:
- auto
- patch
- minor
- major
prerelease:
description: 'Publish as prerelease (e.g. rc.1, beta.1)'
required: false
type: string
skip_publish:
description: 'Tag and create GitHub Release but skip publishing to registry'
required: false
default: false
type: boolean
concurrency:
description: 'Max parallel publish jobs (0 = auto)'
required: false
default: '0'
type: string
max_retries:
description: 'Max retries for failed publish attempts (0 = no retries)'
required: false
default: '2'
type: string
push:
branches: [main]
paths:
- "py/packages/**"
- "py/plugins/**"
pull_request:
types: [closed]
branches: [main]
# Only one release pipeline runs at a time.
concurrency:
group: releasekit-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write # Create tags, releases, and push to release branch
pull-requests: write # Open/update Release PRs, manage labels
id-token: write # Sigstore keyless signing (SLSA provenance)
env:
RELEASEKIT_DIR: py/tools/releasekit
WORKSPACE_DIR: py
# Registry URLs resolved from the target input (defaults to prod PyPI).
PUBLISH_INDEX_URL: ${{ inputs.target == 'testpypi' && 'https://test.pypi.org/legacy/' || '' }}
PUBLISH_CHECK_URL: ${{ inputs.target == 'testpypi' && 'https://test.pypi.org/simple/' || 'https://pypi.org/simple/' }}
# Dry-run logic:
# - PR merge (pull_request closed): dry_run=false (this is the one-button release flow)
# - Manual dispatch: uses the checkbox value (default: true)
# - Push to main: dry_run is not relevant (only runs prepare, which is always live)
DRY_RUN: ${{ github.event_name == 'pull_request' && 'false' || (inputs.dry_run == 'false' && 'false' || 'true') }}
jobs:
# ═══════════════════════════════════════════════════════════════════════
# PREPARE: Compute bumps and open/update Release PR
#
# Runs on every push to main that touches package or plugin code.
# Creates a Release PR with version bumps, changelogs, and an
# embedded JSON manifest.
# ═══════════════════════════════════════════════════════════════════════
prepare:
name: Prepare Release PR
if: |
(github.event_name == 'push' &&
!startsWith(github.event.head_commit.message, 'chore(release):') &&
!contains(github.event.head_commit.message, 'releasekit--release')) ||
(github.event_name == 'workflow_dispatch' && inputs.action == 'prepare')
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
has_bumps: ${{ steps.prepare.outputs.has_bumps }}
pr_url: ${{ steps.prepare.outputs.pr_url }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # Full history needed for conventional commit parsing
fetch-tags: true # Tags needed for changelog since-tag ranges
token: ${{ secrets.GITHUB_TOKEN }}
- name: Install uv and setup Python
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
python-version: "3.12"
- name: Install releasekit
working-directory: ${{ env.RELEASEKIT_DIR }}
run: uv sync
- name: Configure git identity
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Run releasekit prepare
id: prepare
env:
# Pass user-controlled inputs via env vars to prevent script injection.
# ${{ inputs.* }} with string types is unsafe in inline shell scripts
# because the value is expanded before bash parses the command.
INPUT_GROUP: ${{ inputs.group }}
INPUT_BUMP_TYPE: ${{ inputs.bump_type }}
INPUT_PRERELEASE: ${{ inputs.prerelease }}
INPUT_FORCE_PREPARE: ${{ inputs.force_prepare }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
cmd=(uv run --directory ${{ env.RELEASEKIT_DIR }} releasekit --workspace py prepare)
if [ "$INPUT_FORCE_PREPARE" = "true" ]; then
cmd+=(--force)
fi
if [ -n "$INPUT_GROUP" ]; then
cmd+=(--group "$INPUT_GROUP")
fi
if [ "$INPUT_BUMP_TYPE" != "auto" ] && [ -n "$INPUT_BUMP_TYPE" ]; then
cmd+=(--bump "$INPUT_BUMP_TYPE")
fi
if [ -n "$INPUT_PRERELEASE" ]; then
cmd+=(--prerelease "$INPUT_PRERELEASE")
fi
# Run prepare — capture output even on failure so CI logs
# show diagnostics (set -e would exit before echo).
OUTPUT=$("${cmd[@]}" 2>&1) || EXIT_CODE=$?
echo "$OUTPUT"
if [ "${EXIT_CODE:-0}" -ne 0 ]; then
echo "::error::releasekit prepare failed with exit code $EXIT_CODE"
exit $EXIT_CODE
fi
# Parse output for PR URL (sed is portable; grep -oP needs GNU grep).
PR_URL=$(echo "$OUTPUT" | sed -n 's/.*Release PR: //p' | tail -1)
if [ -n "$PR_URL" ]; then
echo "has_bumps=true" >> "$GITHUB_OUTPUT"
echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT"
else
echo "has_bumps=false" >> "$GITHUB_OUTPUT"
fi
# ═══════════════════════════════════════════════════════════════════════
# RELEASE: Tag merge commit and create GitHub Release
#
# Runs when a Release PR (labeled "autorelease: pending") is merged.
# Extracts the manifest from the PR body and creates tags + Release.
# ═══════════════════════════════════════════════════════════════════════
release:
name: Tag and Release
if: |
(github.event_name == 'pull_request' &&
github.event.pull_request.merged == true &&
contains(github.event.pull_request.labels.*.name, 'autorelease: pending')) ||
(github.event_name == 'workflow_dispatch' && inputs.action == 'release')
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
release_url: ${{ steps.release.outputs.release_url }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
token: ${{ secrets.GITHUB_TOKEN }}
- name: Install uv and setup Python
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
python-version: "3.12"
- name: Install releasekit
working-directory: ${{ env.RELEASEKIT_DIR }}
run: uv sync
- name: Configure git identity
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Preview execution plan
run: |
echo "::group::Execution Plan (ASCII)"
uv run --directory ${{ env.RELEASEKIT_DIR }} releasekit --workspace py plan --format full 2>&1 || true
echo "::endgroup::"
if [ "${{ env.DRY_RUN }}" = "true" ]; then
echo "::notice::DRY RUN — no tags or releases will be created"
else
echo "::notice::LIVE RUN — tags and GitHub Release will be created"
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Run releasekit release
id: release
run: |
set -euo pipefail
DRY_RUN_FLAG=""
if [ "${{ env.DRY_RUN }}" = "true" ]; then
DRY_RUN_FLAG="--dry-run"
fi
# Run release — capture output even on failure so CI logs
# show diagnostics (set -e would exit before echo).
OUTPUT=$(uv run --directory ${{ env.RELEASEKIT_DIR }} releasekit --workspace py release $DRY_RUN_FLAG 2>&1) || EXIT_CODE=$?
echo "$OUTPUT"
if [ "${EXIT_CODE:-0}" -ne 0 ]; then
echo "::error::releasekit release failed with exit code $EXIT_CODE"
exit $EXIT_CODE
fi
# Parse release URL
# Parse release URL (sed is portable; grep -oP needs GNU grep).
RELEASE_URL=$(echo "$OUTPUT" | sed -n 's/.*release_url=//p' | tail -1)
echo "release_url=$RELEASE_URL" >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# ═══════════════════════════════════════════════════════════════════════
# PUBLISH: Build and publish packages to PyPI
#
# Runs after the release job completes. Publishes packages in
# topological order with retry and ephemeral version pinning.
# ═══════════════════════════════════════════════════════════════════════
publish:
name: Publish to ${{ inputs.target || 'pypi' }}
needs: release
# On PR-merge events, inputs.skip_publish is undefined (empty string),
# which is != 'true', so publish runs. This is intentional.
if: inputs.skip_publish != 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
environment: ${{ inputs.target || 'pypi' }}
permissions:
id-token: write # Trusted publishing (OIDC) + Sigstore signing
attestations: write # PEP 740 attestations
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends build-essential libffi-dev
- name: Install uv and setup Python
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
python-version: "3.12"
- name: Install workspace
working-directory: ${{ env.WORKSPACE_DIR }}
run: uv sync --no-dev
- name: Install releasekit
working-directory: ${{ env.RELEASEKIT_DIR }}
run: uv sync
- name: Preview execution plan
run: |
echo "::group::Execution Plan (ASCII)"
uv run --directory ${{ env.RELEASEKIT_DIR }} releasekit --workspace py plan --format full 2>&1 || true
echo "::endgroup::"
if [ "${{ env.DRY_RUN }}" = "true" ]; then
echo "::notice::DRY RUN — no packages will be published"
else
echo "::notice::LIVE RUN — packages will be published to ${{ inputs.target || 'pypi' }}"
fi
- name: Run releasekit publish
env:
# Pass user-controlled inputs via env vars to prevent script injection.
INPUT_GROUP: ${{ inputs.group }}
INPUT_CONCURRENCY: ${{ inputs.concurrency }}
INPUT_MAX_RETRIES: ${{ inputs.max_retries }}
# For trusted publishing (OIDC), no token needed.
# For API token auth, set PYPI_TOKEN / TESTPYPI_TOKEN in repo secrets.
UV_PUBLISH_TOKEN: ${{ inputs.target == 'testpypi' && secrets.TESTPYPI_TOKEN || secrets.PYPI_TOKEN }}
run: |
set -euo pipefail
cmd=(uv run --directory ${{ env.RELEASEKIT_DIR }} releasekit --workspace py publish --force)
if [ "${{ env.DRY_RUN }}" = "true" ]; then
cmd+=(--dry-run)
fi
if [ -n "$PUBLISH_CHECK_URL" ]; then
cmd+=(--check-url "$PUBLISH_CHECK_URL")
fi
if [ -n "$PUBLISH_INDEX_URL" ]; then
cmd+=(--index-url "$PUBLISH_INDEX_URL")
fi
if [ -n "$INPUT_CONCURRENCY" ] && [ "$INPUT_CONCURRENCY" != "0" ]; then
cmd+=(--concurrency "$INPUT_CONCURRENCY")
fi
if [ -n "$INPUT_GROUP" ]; then
cmd+=(--group "$INPUT_GROUP")
fi
if [ -n "$INPUT_MAX_RETRIES" ] && [ "$INPUT_MAX_RETRIES" != "0" ]; then
cmd+=(--max-retries "$INPUT_MAX_RETRIES")
fi
# Capture output even on failure so CI logs show diagnostics.
echo "::group::Running: ${cmd[*]}"
OUTPUT=$("${cmd[@]}" 2>&1) || EXIT_CODE=$?
echo "$OUTPUT"
echo "::endgroup::"
if [ "${EXIT_CODE:-0}" -ne 0 ]; then
echo "::error::releasekit publish failed with exit code $EXIT_CODE"
exit $EXIT_CODE
fi
- name: Upload manifest artifact
if: success()
uses: actions/upload-artifact@v4
with:
name: release-manifest
path: ${{ env.WORKSPACE_DIR }}/release-manifest.json
retention-days: 90
- name: Upload SLSA provenance
if: success()
uses: actions/upload-artifact@v4
with:
name: slsa-provenance
path: ${{ env.WORKSPACE_DIR }}/provenance*.intoto.jsonl
retention-days: 90
if-no-files-found: warn
- name: Upload SBOMs
if: success()
uses: actions/upload-artifact@v4
with:
name: sbom
path: |
${{ env.WORKSPACE_DIR }}/sbom.cdx.json
${{ env.WORKSPACE_DIR }}/sbom.spdx.json
retention-days: 90
if-no-files-found: warn
# ═══════════════════════════════════════════════════════════════════════
# VERIFY: Check published packages are installable
#
# Waits for PyPI propagation, then verifies that the core package and
# all plugins install correctly.
# ═══════════════════════════════════════════════════════════════════════
verify:
name: Verify Published Packages
needs: publish
if: >-
success() &&
(github.event_name == 'pull_request' ||
inputs.dry_run == false)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Wait for PyPI propagation
run: |
echo "Waiting 60 seconds for PyPI CDN propagation..."
sleep 60
- name: Get core version and verify installation
run: |
VERSION=$(grep '^version' py/packages/genkit/pyproject.toml | head -1 | sed 's/.*= *"//' | sed 's/".*//')
echo "Core version: $VERSION"
pip install --upgrade pip
# Verify core package.
pip install "genkit==$VERSION"
python -c "from genkit.ai import Genkit; print('✅ genkit imports successfully')"
# Verify all plugins are installable.
for pyproject in py/plugins/*/pyproject.toml; do
plugin_dir=$(dirname "$pyproject")
plugin_name=$(basename "$plugin_dir")
pkg_name="genkit-plugin-${plugin_name}"
plugin_version=$(grep '^version' "$pyproject" | head -1 | sed 's/.*= *"//' | sed 's/".*//')
echo "Verifying $pkg_name==$plugin_version..."
if pip install "$pkg_name==$plugin_version" 2>/dev/null; then
echo "✅ $pkg_name installed"
else
echo "⚠️ $pkg_name==$plugin_version not found on PyPI (may not have been published)"
fi
done
# ═══════════════════════════════════════════════════════════════════════
# NOTIFY: Post-release notifications
#
# Fires a repository_dispatch event so downstream repos (e.g.
# genkit-community-plugins) can update their dependencies.
# ═══════════════════════════════════════════════════════════════════════
notify:
name: Notify Downstream
needs: [release, publish, verify]
if: success()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Dispatch release event
uses: peter-evans/repository-dispatch@v3
with:
# NOTE: GITHUB_TOKEN is scoped to this repo only. For cross-repo
# dispatch (e.g. genkit-community-plugins), replace with a PAT
# or GitHub App token stored in secrets.
token: ${{ secrets.GITHUB_TOKEN }}
event-type: genkit-python-release
client-payload: '{"release_url": "${{ needs.release.outputs.release_url }}"}'