diff --git a/.github/workflows/publish-py-sdk.yaml b/.github/workflows/publish-py-sdk.yaml index 06fd4ee5..fc0939a7 100644 --- a/.github/workflows/publish-py-sdk.yaml +++ b/.github/workflows/publish-py-sdk.yaml @@ -1,35 +1,54 @@ -# -# This workflow is used to publish the Python SDK to PyPI. -# It is triggered by a tag push, and will only publish if the tag is valid. -# The tag must match the format py-sdk-v*.*.* -# - name: Publish Python SDK on: - push: - tags: - - "py-sdk-v*.*.*" # Trigger on version tags like py-sdk-v0.1.0, py-sdk-v1.2.3, etc. + workflow_dispatch: + inputs: + ref: + description: "Git ref to publish (branch, tag, or commit SHA)" + required: true + type: string + default: "main" + release_type: + description: "Release type to publish to PyPI" + required: true + type: choice + options: + - stable + - prerelease + default: stable + dry_run: + description: "Validate and build without publishing to PyPI or creating a GitHub Release" + required: true + type: boolean + default: false jobs: validate: runs-on: ubuntu-latest timeout-minutes: 10 outputs: - release_tag: ${{ steps.set_release_tag.outputs.release_tag }} + commit_sha: ${{ steps.validate.outputs.commit_sha }} + dry_run: ${{ steps.validate.outputs.dry_run }} + release_tag: ${{ steps.validate.outputs.release_tag }} + release_type: ${{ steps.validate.outputs.release_type }} + version: ${{ steps.validate.outputs.version }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: - fetch-depth: 0 # Fetch all history for checking branch - - name: Set release tag - id: set_release_tag - # ensure the tag is valid (matches code, is on main, etc) + ref: ${{ github.event.inputs.ref }} + fetch-depth: 0 + - name: Set up mise + uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3.6.3 + with: + cache: true + experimental: true + - name: Validate release inputs + id: validate run: | - RELEASE_TAG=${GITHUB_REF#refs/tags/} - echo "Using tag: $RELEASE_TAG" - ./py/scripts/validate-release-tag.sh "$RELEASE_TAG" - echo "RELEASE_TAG=$RELEASE_TAG" >> $GITHUB_ENV - echo "release_tag=$RELEASE_TAG" >> $GITHUB_OUTPUT + mise exec -- python py/scripts/validate-release.py \ + "${{ github.event.inputs.release_type }}" \ + --github-output "$GITHUB_OUTPUT" + echo "dry_run=${{ github.event.inputs.dry_run }}" >> "$GITHUB_OUTPUT" build-and-publish: needs: validate @@ -40,12 +59,17 @@ jobs: id-token: write # Required for PyPI trusted publishing env: + COMMIT_SHA: ${{ needs.validate.outputs.commit_sha }} + DRY_RUN: ${{ needs.validate.outputs.dry_run }} RELEASE_TAG: ${{ needs.validate.outputs.release_tag }} + RELEASE_TYPE: ${{ needs.validate.outputs.release_type }} + VERSION: ${{ needs.validate.outputs.version }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: - fetch-depth: 0 # Fetch all history for changelog generation + ref: ${{ env.COMMIT_SHA }} + fetch-depth: 0 - name: Set up mise uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3.6.3 with: @@ -61,15 +85,19 @@ jobs: path: py/dist/ retention-days: 5 - name: Publish to PyPI + if: env.DRY_RUN != 'true' uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1 with: packages-dir: py/dist/ + - name: Create local release tag + if: env.DRY_RUN != 'true' + run: git tag "$RELEASE_TAG" "$COMMIT_SHA" + # Create GitHub Release - name: Generate release notes id: release_notes run: | - VERSION="${RELEASE_TAG#py-sdk-v}" RELEASE_NOTES=$(.github/scripts/generate-release-notes.sh "${{ env.RELEASE_TAG }}" "py/") echo "notes<> $GITHUB_OUTPUT echo "$RELEASE_NOTES" >> $GITHUB_OUTPUT @@ -77,6 +105,7 @@ jobs: echo "release_name=Python SDK v${VERSION}" >> $GITHUB_OUTPUT - name: Create GitHub Release + if: env.DRY_RUN != 'true' uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 env: RELEASE_NOTES: ${{ steps.release_notes.outputs.notes }} @@ -87,24 +116,24 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, tag_name: process.env.RELEASE_TAG, + target_commitish: process.env.COMMIT_SHA, name: process.env.RELEASE_NAME, body: process.env.RELEASE_NOTES, draft: false, - prerelease: false + prerelease: process.env.RELEASE_TYPE === "prerelease" }); + - name: Summarize dry run + if: env.DRY_RUN == 'true' + run: | + echo "Dry run completed for $RELEASE_TAG from $COMMIT_SHA" + notify-success: needs: [validate, build-and-publish] if: always() && needs.build-and-publish.result == 'success' runs-on: ubuntu-latest timeout-minutes: 5 steps: - - name: Extract version from tag - id: version - run: | - TAG="${{ github.ref_name }}" - VERSION="${TAG#py-sdk-v}" - echo "version=$VERSION" >> $GITHUB_OUTPUT - name: Post to Slack on success uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1 with: @@ -112,16 +141,16 @@ jobs: token: ${{ secrets.SLACK_BOT_TOKEN }} payload: | channel: C0ABHT0SWA2 - text: "✅ Python SDK v${{ steps.version.outputs.version }} published" + text: "${{ needs.validate.outputs.dry_run == 'true' && '🧪 Python SDK dry run succeeded' || format('✅ Python SDK {0} v{1} published', needs.validate.outputs.release_type, needs.validate.outputs.version) }}" blocks: - type: "header" text: type: "plain_text" - text: "✅ Python SDK Published" + text: "${{ needs.validate.outputs.dry_run == 'true' && '🧪 Python SDK Dry Run Succeeded' || '✅ Python SDK Published' }}" - type: "section" text: type: "mrkdwn" - text: "*Version:* ${{ steps.version.outputs.version }}\n*Package:* \n\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run>" + text: "${{ needs.validate.outputs.dry_run == 'true' && format('*Mode:* dry run\n*Release type:* {0}\n*Version:* {1}\n*Ref:* {2}\n\n<{3}/{4}/actions/runs/{5}|View Run>', needs.validate.outputs.release_type, needs.validate.outputs.version, github.event.inputs.ref, github.server_url, github.repository, github.run_id) || format('*Release type:* {0}\n*Version:* {1}\n*Package:* \n\n<{2}/{3}/actions/runs/{4}|View Run>', needs.validate.outputs.release_type, needs.validate.outputs.version, github.server_url, github.repository, github.run_id) }}" notify-failure: needs: [validate, build-and-publish] @@ -145,4 +174,4 @@ jobs: - type: "section" text: type: "mrkdwn" - text: "*Tag:* ${{ github.ref_name }}\n*Commit:* ${{ github.sha }}\n\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run>" + text: "*Release type:* ${{ github.event.inputs.release_type }}\n*Ref:* ${{ github.event.inputs.ref }}\n*Commit:* ${{ needs.validate.outputs.commit_sha || github.sha }}\n\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run>" diff --git a/.github/workflows/test-publish-py-sdk.yaml b/.github/workflows/test-publish-py-sdk.yaml index 2e4abb57..8f0e5967 100644 --- a/.github/workflows/test-publish-py-sdk.yaml +++ b/.github/workflows/test-publish-py-sdk.yaml @@ -1,8 +1,7 @@ # -# This workflow is used to publish the Python SDK to TestPyPI. You do not need to upgrade the -# version number to use this. Only upgrade the version number when you are ready to publish to -# PyPI. The script will automatically add an "rc" suffix to the version number for test.pypi.org -# releases, so you can push a version number to test.pypi.org multiple times. +# This workflow is used to publish the Python SDK to TestPyPI. +# It mirrors the main release workflow where practical, but it does not create git tags +# or GitHub Releases. # name: Publish Python SDK to TestPyPI @@ -15,9 +14,37 @@ on: required: true type: string default: "main" + dry_run: + description: "Validate and build without publishing to TestPyPI" + required: true + type: boolean + default: false jobs: - build-and-publish-test: + validate: + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + commit_sha: ${{ steps.validate.outputs.commit_sha }} + dry_run: ${{ steps.validate.outputs.dry_run }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ github.event.inputs.ref }} + fetch-depth: 0 + - name: Set up mise + uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3.6.3 + with: + cache: true + experimental: true + - name: Resolve commit + id: validate + run: | + echo "commit_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "dry_run=${{ github.event.inputs.dry_run }}" >> "$GITHUB_OUTPUT" + + build-and-publish: + needs: validate runs-on: ubuntu-latest timeout-minutes: 20 permissions: @@ -27,23 +54,29 @@ jobs: version: ${{ steps.get_version.outputs.version }} env: + COMMIT_SHA: ${{ needs.validate.outputs.commit_sha }} + DRY_RUN: ${{ needs.validate.outputs.dry_run }} PYPI_REPO: testpypi steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: - ref: ${{ github.event.inputs.ref }} + ref: ${{ env.COMMIT_SHA }} + fetch-depth: 0 - name: Set up mise uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3.6.3 with: cache: true experimental: true - - name: Install build dependencies - run: | - mise exec -- make -C py install-dev - name: Build and verify run: | - mise exec -- make -C py verify-build + mise exec -- make -C py install-dev verify-build + - name: Upload build artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: python-sdk-testpypi-dist + path: py/dist/ + retention-days: 5 - name: Get version from built wheel id: get_version run: | @@ -51,14 +84,19 @@ jobs: VERSION=$(echo "$WHEEL" | sed -n 's/.*braintrust-\([^-]*\)-.*/\1/p') echo "version=$VERSION" >> $GITHUB_OUTPUT - name: Publish to TestPyPI + if: env.DRY_RUN != 'true' uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1 with: repository-url: https://test.pypi.org/legacy/ packages-dir: py/dist/ + - name: Summarize dry run + if: env.DRY_RUN == 'true' + run: | + echo "Dry run completed for TestPyPI build from $COMMIT_SHA" notify-success: - needs: build-and-publish-test - if: success() + needs: [validate, build-and-publish] + if: always() && needs.build-and-publish.result == 'success' runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -69,20 +107,20 @@ jobs: token: ${{ secrets.SLACK_BOT_TOKEN }} payload: | channel: C0ABHT0SWA2 - text: "🧪 Python SDK pre-release v${{ needs.build-and-publish-test.outputs.version }} published to TestPyPI" + text: "${{ needs.validate.outputs.dry_run == 'true' && '🧪 Python SDK TestPyPI dry run succeeded' || format('🧪 Python SDK prerelease v{0} published to TestPyPI', needs.build-and-publish.outputs.version) }}" blocks: - type: "header" text: type: "plain_text" - text: "🧪 Python SDK Pre-release Published" + text: "${{ needs.validate.outputs.dry_run == 'true' && '🧪 Python SDK TestPyPI Dry Run Succeeded' || '🧪 Python SDK Pre-release Published' }}" - type: "section" text: type: "mrkdwn" - text: "*Version:* ${{ needs.build-and-publish-test.outputs.version }}\n*Ref:* ${{ github.event.inputs.ref }}\n*Install:* `pip install -i https://test.pypi.org/simple/ braintrust==${{ needs.build-and-publish-test.outputs.version }}`\n*Package:* \n\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run>" + text: "${{ needs.validate.outputs.dry_run == 'true' && format('*Mode:* dry run\n*Ref:* {0}\n*Commit:* {1}\n\n<{2}/{3}/actions/runs/{4}|View Run>', github.event.inputs.ref, needs.validate.outputs.commit_sha, github.server_url, github.repository, github.run_id) || format('*Version:* {0}\n*Ref:* {1}\n*Install:* `pip install -i https://test.pypi.org/simple/ braintrust=={0}`\n*Package:* \n\n<{2}/{3}/actions/runs/{4}|View Run>', needs.build-and-publish.outputs.version, github.event.inputs.ref, github.server_url, github.repository, github.run_id) }}" notify-failure: - needs: build-and-publish-test - if: failure() + needs: [validate, build-and-publish] + if: always() && (needs.validate.result == 'failure' || needs.build-and-publish.result == 'failure') runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -102,4 +140,4 @@ jobs: - type: "section" text: type: "mrkdwn" - text: "*Ref:* ${{ github.event.inputs.ref }}\n*Commit:* ${{ github.sha }}\n\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run>" + text: "*Ref:* ${{ github.event.inputs.ref }}\n*Commit:* ${{ needs.validate.outputs.commit_sha || github.sha }}\n\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run>" diff --git a/docs/publishing.md b/docs/publishing.md new file mode 100644 index 00000000..13c4e809 --- /dev/null +++ b/docs/publishing.md @@ -0,0 +1,98 @@ +# Publishing the Python SDK + +The Python SDK is published from GitHub Actions. Do not use a local tag-push script. + +## PyPI release flow + +Use the `Publish Python SDK` workflow in GitHub Actions and provide: + +- `ref`: the branch, tag, or commit SHA to release. Defaults to `main`. +- `release_type`: `stable` or `prerelease`. Defaults to `stable`. +- `dry_run`: if you should validate and build without actually publishing. Defaults to `false`. + +The workflow will: + +1. Check out the requested ref. +2. Validate that the selected commit is on `main`. +3. Read the package version from `py/src/braintrust/version.py`. +4. Enforce that: + - `stable` uses a version like `X.Y.Z` + - `prerelease` uses a version like `X.Y.Zrc1`, `X.Y.Za1`, or `X.Y.Zb1` +5. Verify that the version is not already published on PyPI and that the release tag does not already exist. +6. Build and verify the package with `make -C py verify-build`. +7. If `dry_run=false`, publish to PyPI, create the `py-sdk-v` git tag and the corresponding GitHub Release. +9. Upload the built distribution artifacts for inspection either way. + +## Stable releases + +Before running the workflow: + +- bump `py/src/braintrust/version.py` to the final version, for example `0.8.0` +- merge the release commit to `main` + +Then run `Publish Python SDK` with: + +- `ref=main` or the exact release commit SHA +- `release_type=stable` +- `dry_run=false` + +## Prereleases + +Before running the workflow: + +- bump `py/src/braintrust/version.py` to a prerelease version, for example `0.8.0rc1` +- merge the release commit to `main` + +Then run `Publish Python SDK` with: + +- `ref=main` or the exact release commit SHA +- `release_type=prerelease` +- `dry_run=false` + +Prereleases publish to the normal PyPI package and are marked as prereleases on the GitHub Release. If you only want to publish a prerelease build for testing, you can also use `Publish Python SDK to TestPyPI` instead. That workflow does not create a GitHub Release. + +## TestPyPI releases + +Use the separate `Publish Python SDK to TestPyPI` workflow when you want to publish a build to TestPyPI without creating a real PyPI release, git tag, or GitHub Release. + +This is useful for: + +- packaging smoke tests +- validating a release candidate before the real PyPI publish +- sharing prerelease artifacts for testing without consuming the final PyPI version number + +The workflow reads the version from `py/src/braintrust/version.py`, then appends `rc` during the build when publishing to TestPyPI. For example, `0.8.0` becomes something like `0.8.0rc1234`. + +Run `Publish Python SDK to TestPyPI` with: + +- `ref=main` or the exact branch / commit you want to test +- `dry_run=true` if you only want to validate/build without publishing + +Install from TestPyPI with: + +```bash +pip install -i https://test.pypi.org/simple/ braintrust== +``` + +> The build will fail if you upload a package with a duplicate version number. If this happens, DO NOT update version.py. Instead, rebase your branch onto origin/main and try again. The workflow will add an incrementing suffix rc. So as long as you are up to date, this should just work. + +Just like the main PyPI workflow, the TestPyPI workflow also supports `dry_run=true`. In that mode it builds, verifies, and uploads artifacts, but it does not publish to TestPyPI. + +## Dry runs + +Use `dry_run=true` when you want to exercise the release workflow without publishing anything. + +A dry run still: + +- validates the selected ref and version +- checks that the release commit is on `main` +- checks that the tag and PyPI version do not already exist +- builds the package and runs `make -C py verify-build` +- uploads `py/dist/` as a workflow artifact +- generates release notes + +A dry run does not: + +- publish to PyPI +- create the `py-sdk-v` tag +- create a GitHub Release diff --git a/py/README.md b/py/README.md index 5d5e0c26..b0b3242d 100644 --- a/py/README.md +++ b/py/README.md @@ -66,6 +66,7 @@ Available extras: - Python SDK docs: https://www.braintrust.dev/docs/reference/sdks/python - Braintrust docs: https://www.braintrust.dev/docs +- Repo publishing guide: https://github.com/braintrustdata/braintrust-sdk-python/blob/main/docs/publishing.md - Source code: https://github.com/braintrustdata/braintrust-sdk-python/tree/main/py ## License diff --git a/py/scripts/get_version.sh b/py/scripts/get_version.sh deleted file mode 100755 index 51c0149e..00000000 --- a/py/scripts/get_version.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash -# Script to extract and print the version number from version.py - - -ROOT_DIR=$(git rev-parse --show-toplevel) - -VERSION_FILE="$ROOT_DIR/py/src/braintrust/version.py" - -# Extract the version using grep and cut -VERSION=$(grep -E '^VERSION\s*=' "$VERSION_FILE" | grep -o '".*"' | tr -d '"') - -# Print the version -echo "$VERSION" diff --git a/py/scripts/push-release-tag.sh b/py/scripts/push-release-tag.sh deleted file mode 100755 index 52090548..00000000 --- a/py/scripts/push-release-tag.sh +++ /dev/null @@ -1,102 +0,0 @@ -#!/bin/bash -set -euo pipefail - -ROOT_DIR=$(git rev-parse --show-toplevel) - -# Parse command line arguments and environment variables -# Support both --flag and ENVVAR=1 syntax -DRY_RUN=${DRY_RUN:-false} -FORCE=${FORCE:-false} - -# Normalize environment variables (1, true, TRUE -> true) -[[ "$DRY_RUN" == "1" || "$DRY_RUN" == "true" || "$DRY_RUN" == "TRUE" ]] && DRY_RUN=true -[[ "$FORCE" == "1" || "$FORCE" == "true" || "$FORCE" == "TRUE" ]] && FORCE=true - -while [[ $# -gt 0 ]]; do - case "$1" in - --dry-run) - DRY_RUN=true - shift - ;; - --force) - FORCE=true - shift - ;; - *) - echo "Unknown option: $1" - echo "Usage: $0 [--dry-run] [--force]" - exit 1 - ;; - esac -done - -# Fetch latest tags -git fetch --tags --prune - -REPO_URL="https://github.com/braintrustdata/braintrust-sdk-python" -TAG_PREFIX="py-sdk-v" -COMMIT=$(git rev-parse --short HEAD) -VERSION=$(bash "$ROOT_DIR/py/scripts/get_version.sh") -TAG="${TAG_PREFIX}${VERSION}" - -# Check if version already exists on PyPI when using --force -if [ "$FORCE" = true ]; then - echo "Checking if version ${VERSION} exists on PyPI..." - if curl -s "https://pypi.org/pypi/braintrust/${VERSION}/json" | grep -q "\"version\""; then - echo "" - echo "Error: Version ${VERSION} already exists on PyPI" - echo "Cannot force-replace a tag that has already been published to PyPI" - echo "Please bump the version number instead" - exit 1 - fi - echo "Version ${VERSION} not found on PyPI, safe to proceed" - echo "" -fi - -# Find the most recent version tag for comparison -# If forcing and the tag exists, skip to the previous tag for changeset comparison -if [ "$FORCE" = true ] && git rev-parse "$TAG" >/dev/null 2>&1; then - LAST_RELEASE=$(git tag -l "${TAG_PREFIX}*" --sort=-v:refname | head -n 2 | tail -n 1) -else - LAST_RELEASE=$(git tag -l "${TAG_PREFIX}*" --sort=-v:refname | head -n 1) -fi - -echo "================================================" -echo " Python SDK Release" -echo "================================================" -echo "version: ${TAG}" -echo "commit: ${COMMIT}" -echo "code: ${REPO_URL}/commit/${COMMIT}" -echo "changeset: ${REPO_URL}/compare/${LAST_RELEASE}...${COMMIT}" - -if [ "$DRY_RUN" = true ]; then - exit 0 -fi - -echo "" -echo "" -echo "Are you ready to release version ${VERSION}? Type 'YOLO' to continue:" -read -r CONFIRMATION - -if [ "$CONFIRMATION" != "YOLO" ]; then - echo "Release cancelled." - exit 1 -fi - -# Create and push the tag -echo "" -echo "Creating and pushing tag ${TAG}" -echo "" - -if [ "$FORCE" = true ]; then - git tag -f "$TAG" "$COMMIT" - git push --force origin "$TAG" -else - git tag "$TAG" "$COMMIT" - git push origin "$TAG" -fi - -echo "" -echo "Tag ${TAG} has been created and pushed to origin. Check GitHub Actions for build progress:" -echo "https://github.com/braintrustdata/braintrust-sdk-python/actions/workflows/publish-py-sdk.yaml" -echo "" diff --git a/py/scripts/validate-release-tag.sh b/py/scripts/validate-release-tag.sh deleted file mode 100755 index 246872f8..00000000 --- a/py/scripts/validate-release-tag.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/bin/bash -# Script to validate release requirements -# - Checks if the tag matches the version in the package -# - Ensures we're releasing from the main branch - -set -e - -# Get the tag from the first command line argument -if [ $# -eq 0 ]; then - echo "ERROR: Release tag argument not provided" - echo "Usage: $0 " - exit 1 -fi - -ROOT_DIR=$(git rev-parse --show-toplevel) - -# Fetch the latest tags to ensure we're up to date -git fetch --tags --prune --force - -TAG=$1 - -# Check if tag starts with py-sdk-v -if [[ ! "$TAG" =~ ^py-sdk-v ]]; then - echo "ERROR: Tag must start with 'py-sdk-v'" - exit 1 -fi - -# Extract version without the 'py-sdk-v' prefix -VERSION=${TAG#py-sdk-v} - -PACKAGE_VERSION=$(bash "$ROOT_DIR/py/scripts/get_version.sh") - -# Check if the tag version matches the package version -if [ "$VERSION" != "$PACKAGE_VERSION" ]; then - echo "ERROR: Tag version ($VERSION) does not match package version ($PACKAGE_VERSION)" - exit 1 -fi - -CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) -if [ "$CURRENT_BRANCH" != "main" ]; then - # If we're in detached HEAD state (which is likely in GitHub Actions with a tag), - # we need to check if the tag is on the main branch - if ! git rev-parse "$TAG" &>/dev/null; then - echo "ERROR: Tag $TAG does not exist in the repository" - exit 1 - fi - - TAG_COMMIT=$(git rev-parse "$TAG") - - # Ensure we have main branch history - git fetch origin main --depth=1000 - - # Check if tag is on main branch - if ! git merge-base --is-ancestor "$TAG_COMMIT" origin/main; then - echo "ERROR: Tag $TAG is not on the main branch" - exit 1 - fi -fi - -# All checks passed -exit 0 diff --git a/py/scripts/validate-release.py b/py/scripts/validate-release.py new file mode 100644 index 00000000..cb60ed7b --- /dev/null +++ b/py/scripts/validate-release.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Validate whether the checked-out commit can be published as a Python SDK release.""" + +from __future__ import annotations + +import argparse +import pathlib +import re +import subprocess +import sys +import urllib.error +import urllib.request + +STABLE_VERSION_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$") +PRERELEASE_VERSION_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(a|b|rc)[0-9]+$") + + +def run(*args: str, check: bool = True) -> str: + result = subprocess.run(args, check=check, capture_output=True, text=True) + return result.stdout.strip() + + +def get_repo_root() -> pathlib.Path: + return pathlib.Path(run("git", "rev-parse", "--show-toplevel")) + + +def get_version(repo_root: pathlib.Path) -> str: + version_file = repo_root / "py" / "src" / "braintrust" / "version.py" + version_line = next( + (line for line in version_file.read_text().splitlines() if line.startswith("VERSION = ")), + None, + ) + if version_line is None: + raise ValueError(f"Could not find VERSION in {version_file}") + return version_line.split('"')[1] + + +def validate_release_type(release_type: str, version: str) -> None: + if release_type == "stable" and not STABLE_VERSION_RE.fullmatch(version): + raise ValueError(f"Stable releases require a version like X.Y.Z; found '{version}'") + if release_type == "prerelease" and not PRERELEASE_VERSION_RE.fullmatch(version): + raise ValueError( + f"Prereleases require a version like X.Y.Zrc1, X.Y.Za1, or X.Y.Zb1; found '{version}'" + ) + + +def check_tag_does_not_exist(tag: str) -> None: + result = subprocess.run(("git", "rev-parse", tag), check=False, capture_output=True, text=True) + if result.returncode == 0: + raise ValueError(f"Tag '{tag}' already exists") + + +def check_commit_on_main() -> None: + run("git", "fetch", "--tags", "--prune", "--force") + run("git", "fetch", "origin", "main") + result = subprocess.run( + ("git", "merge-base", "--is-ancestor", "HEAD", "origin/main"), + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise ValueError("Releases must be cut from a commit on origin/main") + + +def check_version_not_on_pypi(version: str) -> None: + url = f"https://pypi.org/pypi/braintrust/{version}/json" + request = urllib.request.Request(url, headers={"User-Agent": "braintrust-release-validator"}) + try: + with urllib.request.urlopen(request) as response: + if response.status == 200: + raise ValueError(f"Version '{version}' already exists on PyPI") + raise ValueError( + f"Unexpected response from PyPI while checking version '{version}' (HTTP {response.status})" + ) + except urllib.error.HTTPError as exc: + if exc.code == 404: + return + raise ValueError( + f"Unexpected response from PyPI while checking version '{version}' (HTTP {exc.code})" + ) from exc + except urllib.error.URLError as exc: + raise ValueError(f"Failed to reach PyPI while checking version '{version}': {exc.reason}") from exc + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("release_type", choices=("stable", "prerelease")) + parser.add_argument("--github-output", type=pathlib.Path) + return parser.parse_args() + + +def write_github_output(output_path: pathlib.Path, values: dict[str, str]) -> None: + with output_path.open("a", encoding="utf-8") as f: + for key, value in values.items(): + f.write(f"{key}={value}\n") + + +def main() -> int: + args = parse_args() + repo_root = get_repo_root() + version = get_version(repo_root) + tag = f"py-sdk-v{version}" + + validate_release_type(args.release_type, version) + check_commit_on_main() + check_tag_does_not_exist(tag) + check_version_not_on_pypi(version) + + outputs = { + "commit_sha": run("git", "rev-parse", "HEAD"), + "release_tag": tag, + "release_type": args.release_type, + "version": version, + } + if args.github_output is not None: + write_github_output(args.github_output, outputs) + else: + for key, value in outputs.items(): + print(f"{key.upper()}={value}") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except ValueError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + raise SystemExit(1)