Skip to content

Release: RC (PyPI prod) #6

Release: RC (PyPI prod)

Release: RC (PyPI prod) #6

Workflow file for this run

name: 'Release: RC (PyPI prod)'
# Manually-triggered release-candidate publish to prod PyPI (e.g. 9.0.0rc1).
#
# Orphan-tag model
# ----------------
# - main is never bumped to an rcN version; pyproject.toml on main keeps the
# "next final" version (e.g. 9.0.0) for the entire RC cycle.
# - every successful RC run produces a git tag (vX.Y.ZrcN) that points at a
# release commit not present on any branch. The commit holds the bumped
# pyproject.toml + rust/Cargo.toml + rust/Cargo.lock, so
# `git checkout vX.Y.ZrcN` reproduces the exact tree we published.
#
# Iteration safety rails
# ----------------------
# 1. `skipTests` (default false) -> skip the on-push validation gate.
# 2. `dryRun` (default false) -> build + smoke-install every wheel,
# then stop before publish + push-tag.
# 3. The `publish` job runs in the `pypi-rc` GitHub Environment, which
# gates on a manual approval click before any artifact reaches PyPI.
#
# One-time setup (outside this file)
# ----------------------------------
# - PyPI Trusted Publisher pointed at this repo + workflow filename
# `release-rc.yaml` + environment `pypi-rc`.
# - GitHub Environment `pypi-rc` configured with required-reviewers gate.
on:
workflow_dispatch:
inputs:
ref:
description: 'Git ref to build (branch name or SHA)'
required: true
type: string
default: 'main'
releaseLevel:
description: 'Bump level applied to pyproject.toml version before suffix'
required: true
type: choice
default: 'none'
options:
- 'none' # use pyproject.toml version as-is (9.0.0 -> 9.0.0rc1)
- 'patch' # X.Y.Z -> X.Y.(Z+1)
- 'minor' # X.Y.Z -> X.(Y+1).0
- 'major' # X.Y.Z -> (X+1).0.0
prereleaseSuffix:
description: 'PEP 440 prerelease suffix (rc1, rc2, b1, ...)'
required: true
type: string
default: 'rc1'
skipTests:
description: 'Skip the on-push validation gate (lint/typecheck/unit/rust)'
type: boolean
default: false
dryRun:
description: 'Build + smoke-install wheels but skip publish + push-tag'
type: boolean
default: false
concurrency:
group: release-rc
cancel-in-progress: false
permissions:
contents: read
env:
PROTOC_VERSION: "28.3"
# arduino/setup-protoc@v3.0.0 has no Node.js 24 release yet
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ---------------------------------------------------------------------------
# Validation gate (reuses on-push.yml so we have one source of truth for
# "fast checks"). Skippable via `skipTests` while iterating.
# ---------------------------------------------------------------------------
validate:
name: Validate (on-push reuse)
if: ${{ !inputs.skipTests }}
uses: ./.github/workflows/on-push.yml
permissions:
contents: read
# ---------------------------------------------------------------------------
# Pre-flight: compute the target version, confirm it isn't already on PyPI,
# confirm the tag isn't already taken, and resolve the input ref to a SHA so
# every downstream job builds the exact same tree.
# ---------------------------------------------------------------------------
preflight:
name: Pre-flight (version + PyPI check)
needs: validate
if: |
always() &&
(needs.validate.result == 'success' || needs.validate.result == 'skipped')
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
pep440_version: ${{ steps.bump.outputs.pep440_version }}
cargo_version: ${{ steps.bump.outputs.cargo_version }}
tag_name: ${{ steps.bump.outputs.tag_name }}
resolved_sha: ${{ steps.resolve.outputs.sha }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.ref }}
fetch-depth: 0 # need history + tags for the tag-collision check
- name: Resolve ref -> SHA
id: resolve
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Validate prereleaseSuffix
run: |
if [[ -z "${{ inputs.prereleaseSuffix }}" ]]; then
echo "::error::prereleaseSuffix is required (this workflow is RC-only; final releases use release-prod.yaml)"
exit 1
fi
if [[ ! "${{ inputs.prereleaseSuffix }}" =~ ^(a|b|rc)[0-9]+$ ]]; then
echo "::error::prereleaseSuffix must match aN|bN|rcN (got '${{ inputs.prereleaseSuffix }}')"
exit 1
fi
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- name: Compute target version
id: bump
run: |
python scripts/bump_version.py \
--level "${{ inputs.releaseLevel }}" \
--suffix "${{ inputs.prereleaseSuffix }}"
- name: Confirm version is not already on PyPI
run: |
set -euo pipefail
version="${{ steps.bump.outputs.pep440_version }}"
url="https://pypi.org/pypi/pinecone/${version}/json"
status=$(curl -s -o /dev/null -w "%{http_code}" "${url}")
echo "PyPI lookup ${url} -> HTTP ${status}"
case "${status}" in
404) echo "::notice::pinecone==${version} is unpublished — clear to release" ;;
200) echo "::error::pinecone==${version} is already on PyPI; bump prereleaseSuffix and retry"; exit 1 ;;
*) echo "::error::unexpected HTTP ${status} from PyPI lookup"; exit 1 ;;
esac
- name: Confirm tag does not already exist
run: |
set -euo pipefail
tag="${{ steps.bump.outputs.tag_name }}"
if git rev-parse --verify "refs/tags/${tag}" >/dev/null 2>&1; then
echo "::error::tag ${tag} already exists locally"
exit 1
fi
if git ls-remote --exit-code --tags origin "${tag}" >/dev/null 2>&1; then
echo "::error::tag ${tag} already exists on remote"
exit 1
fi
echo "tag ${tag} is free"
- name: Render plan summary
run: |
{
echo "## Release plan"
echo ""
echo "| Field | Value |"
echo "|-------|-------|"
echo "| ref (input) | \`${{ inputs.ref }}\` |"
echo "| ref (resolved) | \`${{ steps.resolve.outputs.sha }}\` |"
echo "| releaseLevel | \`${{ inputs.releaseLevel }}\` |"
echo "| prereleaseSuffix | \`${{ inputs.prereleaseSuffix }}\` |"
echo "| target version | **\`${{ steps.bump.outputs.pep440_version }}\`** |"
echo "| cargo version | \`${{ steps.bump.outputs.cargo_version }}\` |"
echo "| git tag | \`${{ steps.bump.outputs.tag_name }}\` |"
echo "| dryRun | \`${{ inputs.dryRun }}\` |"
echo "| skipTests | \`${{ inputs.skipTests }}\` |"
} >> "$GITHUB_STEP_SUMMARY"
# ---------------------------------------------------------------------------
# Build wheels (matrix mirrors dev-publish.yml) + verify each wheel installs
# cleanly on its target platform (skipping the cross-compiled x86_64 mac).
# ---------------------------------------------------------------------------
build:
name: Build + verify (${{ matrix.target }})
needs: preflight
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
manylinux: auto
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
manylinux: auto
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
manylinux: musllinux_1_2
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
manylinux: musllinux_1_2
- os: macos-14
target: x86_64-apple-darwin
- os: macos-14
target: aarch64-apple-darwin
- os: windows-latest
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.preflight.outputs.resolved_sha }}
- name: Install protoc (non-Linux)
if: runner.os != 'Linux'
uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0
with:
version: ${{ env.PROTOC_VERSION }}
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- name: Inject release version
shell: bash
run: |
python scripts/bump_version.py \
--level "${{ inputs.releaseLevel }}" \
--suffix "${{ inputs.prereleaseSuffix }}" \
--write
grep '^version' pyproject.toml rust/Cargo.toml
- name: Verify version injection
shell: bash
env:
EXPECTED: ${{ needs.preflight.outputs.pep440_version }}
run: |
python -c "
import os, tomllib
with open('pyproject.toml', 'rb') as f:
v = tomllib.load(f)['project']['version']
expected = os.environ['EXPECTED']
assert v == expected, f'pyproject.toml version mismatch: expected {expected!r}, got {v!r}'
print(f'pyproject.toml version OK: {v}')
"
- name: Build wheel (maturin)
uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1.50.1
with:
target: ${{ matrix.target }}
args: --release --out dist
manylinux: ${{ matrix.manylinux || 'auto' }}
before-script-linux: |
TARGET="${{ matrix.target }}"
PROTOC_VERSION="${{ env.PROTOC_VERSION }}"
case "$TARGET" in
x86_64-*)
PROTOC_ARCH="linux-x86_64"
PROTOC_SHA256="0ad949f04a6a174da83cdcbdb36dee0a4925272a5b6d83f79a6bf9852076d53f"
;;
aarch64-*)
PROTOC_ARCH="linux-aarch_64"
PROTOC_SHA256="1de522032a8b194002fe35cab86d747848238b5e4de4f99648372079f5b46f9a"
;;
*)
echo "Unsupported target: $TARGET" >&2
exit 1
;;
esac
PROTOC_ZIP="protoc-${PROTOC_VERSION}-${PROTOC_ARCH}.zip"
curl -fsSLO "https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/${PROTOC_ZIP}"
echo "${PROTOC_SHA256} ${PROTOC_ZIP}" | sha256sum -c -
python3 -m zipfile -e "${PROTOC_ZIP}" /usr/local
chmod +x /usr/local/bin/protoc
rm "${PROTOC_ZIP}"
- name: Validate wheel platform tags (x86_64 macOS cross-compile)
if: matrix.target == 'x86_64-apple-darwin'
shell: bash
run: |
bad=0
for wheel in dist/*.whl; do
echo " $wheel"
[[ "$wheel" == *"x86_64"* ]] || { echo "ERROR: missing x86_64 tag in $wheel"; bad=1; }
done
[ "$bad" -eq 0 ] || exit 1
- name: Write install-verification script
if: matrix.target != 'x86_64-apple-darwin'
shell: bash
run: |
mkdir -p .ci
cat > .ci/verify_install.py <<'PY'
"""Smoke-check the just-built wheel: import + Rust-extension load."""
import sys
import pinecone # noqa: F401 (top-level import must succeed)
print("OK: import pinecone")
import pinecone._grpc # native dylib must load on this platform
print("OK: import pinecone._grpc")
ch = pinecone._grpc.GrpcChannel(
endpoint="https://example.invalid:443",
api_key="test-key",
api_version="2025-10",
version="0.0.0-rc-test",
)
print("OK: GrpcChannel constructed:", type(ch).__name__)
sys.exit(0)
PY
- name: Install wheel + verify (native host)
if: matrix.target != 'x86_64-apple-darwin' && !endsWith(matrix.target, '-musl')
shell: bash
run: |
set -euo pipefail
wheel=$(ls dist/*.whl | head -n1)
echo "Installing ${wheel} on $(uname -sm) with Python $(python -V)"
python -m pip install --upgrade pip
python -m pip install "${wheel}"
python .ci/verify_install.py
- name: Install wheel + verify (musl, alpine container)
if: endsWith(matrix.target, '-musl')
shell: bash
run: |
set -euo pipefail
if [[ "${{ matrix.target }}" == aarch64-* ]]; then
IMAGE="arm64v8/python:3.12-alpine"
else
IMAGE="python:3.12-alpine"
fi
docker run --rm \
-v "$PWD/dist:/dist:ro" \
-v "$PWD/.ci/verify_install.py:/verify_install.py:ro" \
"$IMAGE" sh -c '
set -e
ls /dist/*.whl
pip install --quiet /dist/*.whl
python /verify_install.py
'
- name: Note skipped install verification (x86_64-apple-darwin)
if: matrix.target == 'x86_64-apple-darwin'
run: |
echo "::notice::install verification skipped for x86_64-apple-darwin: cross-compiled on arm64 runner."
- name: Upload wheel
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: wheels-${{ matrix.target }}
path: dist/*.whl
# ---------------------------------------------------------------------------
# Build sdist (one job; serves as the canonical source distribution).
# ---------------------------------------------------------------------------
build-sdist:
name: Build sdist
needs: preflight
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.preflight.outputs.resolved_sha }}
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- name: Inject release version
run: |
python scripts/bump_version.py \
--level "${{ inputs.releaseLevel }}" \
--suffix "${{ inputs.prereleaseSuffix }}" \
--write
grep '^version' pyproject.toml rust/Cargo.toml
- name: Build sdist
uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1.50.1
with:
command: sdist
args: --out dist
- name: Upload sdist
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: sdist
path: dist/*.tar.gz
# ---------------------------------------------------------------------------
# Aggregate every wheel + sdist into a single dist/ artifact and validate it
# before any push/publish happens.
# ---------------------------------------------------------------------------
collect:
name: Collect + validate artifacts
needs: [preflight, build, build-sdist]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Download wheels + sdist
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: dist
pattern: '{wheels-*,sdist}'
merge-multiple: true
- name: Validate artifact counts + version stamp
env:
VERSION: ${{ needs.preflight.outputs.pep440_version }}
run: |
set -euo pipefail
ls -la dist/
whl_count=$(ls dist/*.whl 2>/dev/null | wc -l)
sdist_count=$(ls dist/*.tar.gz 2>/dev/null | wc -l)
echo "Wheels: ${whl_count}, sdist: ${sdist_count}"
# 7 platforms × 1 abi3 wheel = 7
if [ "$whl_count" -lt 7 ]; then
echo "::error::expected at least 7 wheels, got ${whl_count}"
exit 1
fi
if [ "$sdist_count" -lt 1 ]; then
echo "::error::sdist missing"
exit 1
fi
bad=0
for f in dist/*; do
base=$(basename "$f")
if [[ "$base" != *"${VERSION}"* ]]; then
echo "::error::artifact ${base} does not carry expected version ${VERSION}"
bad=1
fi
done
[ "$bad" -eq 0 ] || exit 1
- name: Upload aggregated dist/
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: dist-all
path: dist
# ---------------------------------------------------------------------------
# Publish to PyPI (Trusted Publisher OIDC). Behind the `pypi-rc` GitHub
# Environment so a human must approve before anything reaches PyPI.
# ---------------------------------------------------------------------------
publish:
name: Publish to PyPI
needs: [preflight, collect]
if: ${{ !inputs.dryRun }}
runs-on: ubuntu-latest
timeout-minutes: 15
environment:
name: pypi-rc
url: https://pypi.org/project/pinecone/${{ needs.preflight.outputs.pep440_version }}/
permissions:
id-token: write # required for Trusted Publisher OIDC
steps:
- name: Download dist/
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: dist-all
path: dist
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- name: twine check
run: |
pip install twine==6.1.0
twine check dist/*
# NOTE: pin to a SHA before merging. release/v1 is the maintained branch
# of pypa/gh-action-pypi-publish; replace with `@<sha> # v1.X.Y`.
- name: Publish to PyPI (Trusted Publisher OIDC)
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist
# No `password:` — OIDC via Trusted Publisher.
# No `repository-url:` — defaults to prod PyPI.
# ---------------------------------------------------------------------------
# Push the orphan release tag (the tag, never a branch).
#
# The tag commit is built fresh here from `resolved_sha`, with the version
# bumped exactly the way every build job did it — so the tag's tree is
# byte-identical to what we just published. main is left untouched.
# ---------------------------------------------------------------------------
push-tag:
name: Push orphan release tag
needs: [preflight, publish]
if: ${{ !inputs.dryRun }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write # required to push the tag ref
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ needs.preflight.outputs.resolved_sha }}
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
toolchain: stable
- name: Install protoc
uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0
with:
version: ${{ env.PROTOC_VERSION }}
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- name: Apply version bump (write)
run: |
python scripts/bump_version.py \
--level "${{ inputs.releaseLevel }}" \
--suffix "${{ inputs.prereleaseSuffix }}" \
--write
grep '^version' pyproject.toml rust/Cargo.toml
- name: Configure git identity
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Create release commit + push tag (orphan; main untouched)
env:
TAG: ${{ needs.preflight.outputs.tag_name }}
VERSION: ${{ needs.preflight.outputs.pep440_version }}
run: |
set -euo pipefail
# Cargo.lock lives at the workspace root, not under rust/.
git add pyproject.toml rust/Cargo.toml Cargo.lock pinecone/__init__.py docs/conf.py
git status
git commit -m "release: ${VERSION}"
git tag -a "${TAG}" -m "Release ${VERSION}"
# Push ONLY the tag — main does not move.
git push origin "refs/tags/${TAG}"
- name: Render tag summary
run: |
{
echo "## Tag pushed"
echo ""
echo "- **${{ needs.preflight.outputs.tag_name }}** -> \`$(git rev-parse HEAD)\`"
echo "- Reachable via \`git fetch --tags && git checkout ${{ needs.preflight.outputs.tag_name }}\`"
echo "- main is unchanged (orphan-tag model)"
} >> "$GITHUB_STEP_SUMMARY"
# ---------------------------------------------------------------------------
# Run-summary (always runs so dryRun + skip outcomes are visible).
# ---------------------------------------------------------------------------
summary:
name: Run summary
needs: [validate, preflight, build, build-sdist, collect, publish, push-tag]
if: always()
runs-on: ubuntu-latest
steps:
- name: Render summary
run: |
{
echo "## RC release run"
echo ""
echo "| Stage | Result |"
echo "|-------------|--------|"
echo "| validate | ${{ needs.validate.result }} |"
echo "| preflight | ${{ needs.preflight.result }} |"
echo "| build | ${{ needs.build.result }} |"
echo "| build-sdist | ${{ needs.build-sdist.result }} |"
echo "| collect | ${{ needs.collect.result }} |"
echo "| publish | ${{ needs.publish.result }} |"
echo "| push-tag | ${{ needs.push-tag.result }} |"
echo ""
if [ "${{ inputs.dryRun }}" = "true" ]; then
echo "**dryRun=true** — publish + push-tag were skipped on purpose."
fi
if [ "${{ inputs.skipTests }}" = "true" ]; then
echo "**skipTests=true** — on-push validation gate was skipped on purpose."
fi
} >> "$GITHUB_STEP_SUMMARY"