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
248 changes: 248 additions & 0 deletions .github/workflows/releasekit-uv.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
# 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
#
# This workflow implements a release-please-style pipeline for the
# genkit Python SDK. It uses releasekit to automate:
#
# 1. PREPARE: On push to main, compute version bumps, generate
# changelogs, and open/update a Release PR.
# 2. RELEASE: When a Release PR is merged, tag the merge commit,
# create a GitHub Release, and trigger publishing.
# 3. PUBLISH: Build and publish packages to PyPI in topological order.
#
# Flow:
#
# push to main ──► releasekit prepare ──► Release PR
# │
# merge PR
# │
# ▼
# releasekit release ──► tags + GitHub Release
# │
# ▼
# releasekit publish ──► PyPI
#
# 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:
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

env:
RELEASEKIT_DIR: py/tools/releasekit
WORKSPACE_DIR: py

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'
runs-on: ubuntu-latest
outputs:
has_bumps: ${{ steps.prepare.outputs.has_bumps }}
pr_url: ${{ steps.prepare.outputs.pr_url }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0 # Full history needed for conventional commit parsing
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
run: |
cd ${{ env.RELEASEKIT_DIR }}
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
run: |
cd ${{ env.WORKSPACE_DIR }}
OUTPUT=$(uv run --directory ../tools/releasekit releasekit prepare 2>&1) || {
echo "::warning::releasekit prepare exited with non-zero status"
echo "has_bumps=false" >> "$GITHUB_OUTPUT"
echo "$OUTPUT"
exit 0
}
echo "$OUTPUT"

# Parse output for PR URL
PR_URL=$(echo "$OUTPUT" | grep -oP 'Release PR: \K.*' || echo "")
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
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

# ═══════════════════════════════════════════════════════════════════════
# 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')
runs-on: ubuntu-latest
outputs:
release_url: ${{ steps.release.outputs.release_url }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
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
run: |
cd ${{ env.RELEASEKIT_DIR }}
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 release
id: release
run: |
cd ${{ env.WORKSPACE_DIR }}
OUTPUT=$(uv run --directory ../tools/releasekit releasekit release 2>&1)
echo "$OUTPUT"

# Parse release URL
RELEASE_URL=$(echo "$OUTPUT" | grep -oP 'release_url=\K.*' || echo "")
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 PyPI
needs: release
runs-on: ubuntu-latest
environment: pypi # Requires manual approval if configured
permissions:
id-token: write # Trusted publishing (OIDC)
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0

- 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 + releasekit
run: |
cd ${{ env.WORKSPACE_DIR }}
uv sync
cd tools/releasekit
uv sync

- name: Run releasekit publish
run: |
cd ${{ env.WORKSPACE_DIR }}
uv run --directory tools/releasekit releasekit publish \
--force \
--check-url
env:
# For trusted publishing (OIDC), no token needed.
# For API token auth, set PYPI_TOKEN in repo secrets.
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}

- name: Upload manifest artifact
uses: actions/upload-artifact@v4
with:
name: release-manifest
path: ${{ env.WORKSPACE_DIR }}/release-manifest.json
retention-days: 90

# ═══════════════════════════════════════════════════════════════════════
# 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: publish
runs-on: ubuntu-latest
steps:
- name: Dispatch release event
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}
event-type: genkit-python-release
client-payload: '{"release_url": "${{ needs.release.outputs.release_url }}"}'
10 changes: 10 additions & 0 deletions bin/lint
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ uv run --directory "${PY_DIR}" liccheck -s pyproject.toml
echo "--- 🔍 Running Consistency Checks ---"
"${PY_DIR}/bin/check_consistency"

# Releasekit workspace health checks (PyPI metadata, publish classifiers, changelog URLs, etc.)
# TODO: Re-enable once releasekit stabilizes.
# echo "--- 📦 Running Releasekit Checks ---"
# if uv run --directory "${TOP_DIR}/py/tools/releasekit" releasekit check 2>&1; then
# echo "✅ All releasekit checks passed"
# else
# echo "⚠️ releasekit check found issues (see above)"
# exit 1
# fi

# Shell script linting
echo "--- 🐚 Running Shell Script Lint (shellcheck) ---"
if command -v shellcheck &> /dev/null; then
Expand Down
38 changes: 38 additions & 0 deletions bin/releasekit
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# 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

# Run releasekit from the monorepo root.
#
# Usage:
# bin/releasekit discover
# bin/releasekit discover --ecosystem python
# bin/releasekit discover --ecosystem js --format json
# bin/releasekit init --dry-run
# bin/releasekit plan
# bin/releasekit --help

set -euo pipefail

REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
RELEASEKIT_DIR="${REPO_ROOT}/py/tools/releasekit"

if [[ ! -d "${RELEASEKIT_DIR}" ]]; then
echo "Error: releasekit not found at ${RELEASEKIT_DIR}" >&2
exit 1
fi

exec uv run --project "${RELEASEKIT_DIR}" releasekit "$@"
6 changes: 6 additions & 0 deletions py/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,9 @@ var/
venv/
wheels/
.gemini/artifacts/

# releasekit backup files and runtime artifacts
*.bak
.releasekit/
.releasekit-state.json
release-manifest.json
68 changes: 68 additions & 0 deletions py/GEMINI.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,23 @@
* **Always add a comment**: Explain why the suppression is needed.
* **Be specific**: Use the exact error code (e.g., `# noqa: S105 - enum value, not a password`
not just `# noqa`).
* **Place `# noqa` on the exact line Ruff flags**: Ruff reports errors on the
specific line containing the violation, not the statement's opening line. For
multi-line calls, a `# noqa` comment on the wrong line is silently ignored.

```python
# WRONG — S607 fires on line 2 (the list literal), noqa on line 1 is ignored
proc = subprocess.run( # noqa: S603, S607
['uv', 'lock', '--check'], # ← Ruff flags THIS line for S607
...
)

# CORRECT — each noqa on the line Ruff actually flags
proc = subprocess.run( # noqa: S603 - intentional subprocess call
['uv', 'lock', '--check'], # noqa: S607 - uv is a known tool
...
)
```
* **Examples**:
```python
# Type checker suppression
Expand Down Expand Up @@ -4372,3 +4389,54 @@ When reviewing a sample or service for production readiness, verify each item:
| Telemetry configured | Platform telemetry or OTLP endpoint set |
| Graceful shutdown | `SHUTDOWN_GRACE` appropriate for the platform |
| Keep-alive tuned | Server keep-alive > load balancer idle timeout |

## GitHub Actions Security

### Avoid `eval` in Shell Steps

Never use `eval "$CMD"` to run dynamically-constructed commands in GitHub
Actions `run:` steps. Free-form inputs (like `extra-args`) can inject
arbitrary commands.

**Use bash arrays** to build and execute commands:

```yaml
# WRONG — eval enables injection from free-form inputs
CMD="uv run releasekit ${{ inputs.command }}"
if [[ -n "${{ inputs.extra-args }}" ]]; then
CMD="$CMD ${{ inputs.extra-args }}"
fi
eval "$CMD"

# CORRECT — array execution prevents injection
cmd_array=(uv run releasekit ${{ inputs.command }})
if [[ -n "${{ inputs.extra-args }}" ]]; then
read -ra extra <<< "${{ inputs.extra-args }}"
cmd_array+=("${extra[@]}")
fi
"${cmd_array[@]}"
```

Key rules:

* **Build commands as arrays**, not strings
* **Execute with `"${cmd_array[@]}"`**, not `eval`
* **Quote all `${{ inputs.* }}`** references in array additions
* **Use `read -ra`** to safely split free-form inputs into array elements
* **Capture output** with `$("${cmd_array[@]}")`, not `$(eval "$CMD")`

### Pin Dependencies with Version Constraints

Always pin dependencies with `>=` version constraints, especially for
packages with known CVEs. This ensures CI and production use the patched
version:

```toml
# WRONG — allows any version, including vulnerable ones
dependencies = ["pillow"]

# CORRECT — pins to patched version (GHSA-cfh3-3jmp-rvhc)
dependencies = ["pillow>=12.1.1"]
```

After pinning, always run `uv lock` to regenerate the lockfile.
Loading
Loading