diff --git a/.github/scripts/generate-release-notes.sh b/.github/scripts/generate-release-notes.sh deleted file mode 100755 index c568962b9..000000000 --- a/.github/scripts/generate-release-notes.sh +++ /dev/null @@ -1,120 +0,0 @@ -#!/bin/bash -# Script to generate release notes filtered by path -# Usage: generate-release-notes.sh -# Example: generate-release-notes.sh py-sdk-v0.7.0 py/ - -set -euo pipefail - -if [ $# -lt 2 ]; then - echo "ERROR: Required arguments not provided" - echo "Usage: $0 " - exit 1 -fi - -CURRENT_TAG=$1 -PATH_FILTER=$2 - -# Extract the SDK prefix (js-sdk or py-sdk) -SDK_PREFIX=$(echo "$CURRENT_TAG" | sed -E 's/^([^-]+-[^-]+)-.*/\1/') - -# Find the previous tag for this SDK -PREVIOUS_TAG=$(git tag --list "${SDK_PREFIX}-v*" --sort=-v:refname | grep -v "^${CURRENT_TAG}$" | head -1 || true) - -if [ -z "$PREVIOUS_TAG" ]; then - PREVIOUS_TAG=$(git rev-list --max-parents=0 HEAD) -fi - -# Detect the GitHub repository for PR links -REPO_URL=$(git remote get-url origin 2>/dev/null | sed -E 's|git@github.com:|https://github.com/|; s|\.git$||') - -# Generate the changelog -CHANGELOG=$(git log "${PREVIOUS_TAG}..${CURRENT_TAG}" --oneline --no-merges -- "${PATH_FILTER}") - -if [ -z "$CHANGELOG" ]; then - echo "## Changelog" - echo "" - echo "No changes found in ${PATH_FILTER} since ${PREVIOUS_TAG}" -else - # Format a commit message as a markdown list item with PR link - # Args: $1=type prefix, $2=commit message (without hash) - format_line() { - local type="$1" - local msg="$2" - - # Extract optional scope (e.g. "anthropic" from "fix(anthropic): foo") - local scope - scope=$(echo "$msg" | sed -En 's/^[a-zA-Z]+\(([^)]*)\):.*/\1/p') - - # Strip the conventional commit prefix (e.g. "feat: ", "fix(scope): ") - local display - display=$(echo "$msg" | sed -E 's/^[a-zA-Z]+(\([^)]*\))?:[[:space:]]*//') - - # Capitalize the first letter - display="$(echo "${display:0:1}" | tr '[:lower:]' '[:upper:]')${display:1}" - - # Label perf commits explicitly, include scope if present - if [ "$type" = "perf" ] && [ -n "$scope" ]; then - display="(perf/${scope}) ${display}" - elif [ "$type" = "perf" ]; then - display="(perf) ${display}" - elif [ -n "$scope" ]; then - display="(${scope}) ${display}" - fi - - # Format PR link if present - if [[ $display =~ \(#([0-9]+)\)[[:space:]]*$ ]]; then - local pr_num="${BASH_REMATCH[1]}" - local clean - clean=$(echo "$display" | sed -E 's/[[:space:]]*\(#[0-9]+\)[[:space:]]*$//') - echo "* ${clean} ([#${pr_num}](${REPO_URL}/pull/${pr_num}))" - else - echo "* ${display}" - fi - } - - # Print a changelog section if it has content - print_section() { - local title="$1" - local content="$2" - if [ -n "$content" ]; then - echo "### ${title}" - echo "" - printf "%s" "$content" - echo "" - fi - } - - # Bucket commits by conventional commit type - FEATURES="" - FIXES="" - CHORES="" - OTHER="" - - while IFS= read -r line; do - # Extract message (skip short hash) and type prefix - msg="${line#* }" - type=$(echo "$msg" | sed -E 's/^([a-zA-Z]+)(\([^)]*\))?:.*/\1/' | tr '[:upper:]' '[:lower:]') - - FORMATTED=$(format_line "$type" "$msg") - case "$type" in - feat|perf) FEATURES="${FEATURES}${FORMATTED}"$'\n' ;; - fix) FIXES="${FIXES}${FORMATTED}"$'\n' ;; - chore|ci|build|docs|style|refactor|test) CHORES="${CHORES}${FORMATTED}"$'\n' ;; - *) OTHER="${OTHER}${FORMATTED}"$'\n' ;; - esac - done <<< "$CHANGELOG" - - echo "## Changelog" - echo "" - - print_section "Features" "$FEATURES" - print_section "Bug Fixes" "$FIXES" - print_section "Maintenance" "$CHORES" - print_section "Other Changes" "$OTHER" - - # Extract version from tag (e.g. py-sdk-v0.7.0 -> 0.7.0) - VERSION=$(echo "$CURRENT_TAG" | sed -E 's/^[^-]+-[^-]+-v//') - echo "**Package**: https://pypi.org/project/braintrust/${VERSION}/" - echo "" - echo "**Full Changelog**: ${REPO_URL}/compare/${PREVIOUS_TAG}...${CURRENT_TAG}" -fi diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 9d4073ff7..1bfc219e0 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -1,5 +1,5 @@ # Starts the stable Python SDK release process by opening a PR that bumps the -# package version. Merge the PR to trigger the approval-gated publish workflow. +# package version. After merging, manually publish the merge commit's SHA. name: Prepare Stable Python SDK Release on: @@ -61,6 +61,6 @@ jobs: commit-message: "chore: release Python SDK v${{ inputs.version }}" title: "chore: release Python SDK v${{ inputs.version }}" body: | - Automated stable Python SDK release PR for `${{ inputs.version }}`. Merging will trigger approval-gated publishing and create the `py-sdk-v${{ inputs.version }}` release tag. + Automated stable Python SDK release PR for `${{ inputs.version }}`. After merging, run `Publish Python SDK` with the merge commit SHA to publish and create the `py-sdk-v${{ inputs.version }}` release tag. ${{ steps.changeset.outputs.body }} diff --git a/.github/workflows/publish-py-sdk.yaml b/.github/workflows/publish-py-sdk.yaml index c02402807..c7fdb54b5 100644 --- a/.github/workflows/publish-py-sdk.yaml +++ b/.github/workflows/publish-py-sdk.yaml @@ -1,279 +1,142 @@ +# GENERATED by sdk-actions `bin/workflow` — update with: bin/workflow update +# sdk-actions: {"template":"release/py/turnkey","ref":"71731d4a726df3e3bf42e50c2da4a75842b038dd","version":"1.0.0","params":{"dry_run_environment":"publish-dry-run","emoji":":python:","package_name":"braintrust","publish_environment":"publish","python_version":".tool-versions","slack_channel_var":"SLACK_SDK_RELEASE_CHANNEL","slack_token_secret":"SLACK_BOT_TOKEN","version_file":"py/src/braintrust/version.py","workflow_name":"Publish Python SDK","working_directory":"py"}} + name: Publish Python SDK +# Releases braintrust to PyPI via braintrustdata/sdk-actions. Dispatch after merging +# the commit that bumps VERSION in py/src/braintrust/version.py; the release runs against that commit's SHA. on: - pull_request: - types: [closed] - branches: [main] - push: - tags: - - "py-sdk-v*" workflow_dispatch: inputs: - ref: - description: "Git ref to publish (branch, tag, or commit SHA)" - required: true + _instructions: + description: "⚠️ Before starting: Merge a version bump PR to the target branch. The version is read from the SHA: it cannot be overridden." type: string - default: "main" + default: "I have merged a version bump PR" + required: false release_type: - description: "Release type to publish to PyPI" - required: true + description: "Release type (stable → GitHub release; prerelease → none)" type: choice - options: - - stable - - prerelease - - auto default: stable - version: - description: "Version to publish for prereleases (e.g. 0.22.0rc1). Stable releases read version.py." - required: false + options: [stable, prerelease] + sha: + description: "Commit SHA (of the version bump) to release" + required: true type: string + prev_release: + description: "Release-notes anchor: a tag or SHA (empty → the previous tag)" + type: string + required: false dry_run: - description: "Validate and build without publishing to PyPI or creating a GitHub Release" - required: true + description: "Dry run: build without tagging or publishing" type: boolean default: false jobs: - validate: - if: github.event_name != 'pull_request' || (github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/py-sdk-v')) - runs-on: ubuntu-latest - timeout-minutes: 10 + # FACT-FIND — derive version/tag/notes, check the registry, and emit the package manifest. + # contents: write is for the releases/generate-notes API. + configure: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: write outputs: - 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 }} + version: ${{ steps.configure.outputs.version }} + release_tag: ${{ steps.configure.outputs.release_tag }} + prev_release: ${{ steps.configure.outputs.prev_release }} + branch: ${{ steps.configure.outputs.branch }} + on_release_branch: ${{ steps.configure.outputs.on_release_branch }} + commit_message: ${{ steps.configure.outputs.commit_message }} + github_release: ${{ steps.configure.outputs.github_release }} + already_published: ${{ steps.configure.outputs.already_published }} + notes: ${{ steps.configure.outputs.notes }} + package: ${{ steps.configure.outputs.package }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.merge_commit_sha || (github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.ref) }} - fetch-depth: 0 - - name: Set up mise - uses: jdx/mise-action@7e36c90d9ab29c415a2384db3006f3ec8a8cc654 # v4.2.4 + - name: Configure release + id: configure + uses: braintrustdata/sdk-actions/actions/release/lang/py/configure@71731d4a726df3e3bf42e50c2da4a75842b038dd with: - cache: true - experimental: true - - name: Validate release inputs - id: validate - run: | - VALIDATE_ARGS=("${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_type || 'auto' }}" --github-output "$GITHUB_OUTPUT") - VERSION_INPUT="${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version || '' }}" - if [[ -n "$VERSION_INPUT" ]]; then - VALIDATE_ARGS+=(--version "$VERSION_INPUT") - fi - if [[ "${{ github.event_name }}" == "push" ]]; then - VALIDATE_ARGS+=(--allow-existing-tag) - fi - mise exec -- python py/scripts/validate-release.py "${VALIDATE_ARGS[@]}" - echo "dry_run=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run || 'false' }}" >> "$GITHUB_OUTPUT" + version_file: py/src/braintrust/version.py + sha: ${{ inputs.sha }} + working_directory: py + tag_format: py-sdk-v{version} + release_type: ${{ inputs.release_type }} + pypi_package_name: braintrust + package_label: braintrust + emoji: ':python:' + prev_release: ${{ inputs.prev_release }} - build-and-publish-stable: - needs: validate - if: needs.validate.outputs.release_type == 'stable' && needs.validate.outputs.dry_run != 'true' - runs-on: ubuntu-latest + validate: + needs: [configure] + runs-on: ubuntu-24.04 timeout-minutes: 20 permissions: - contents: write - id-token: write # Required for PyPI trusted publishing - environment: pypi-publish - + contents: read 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 }} - + BRAINTRUST_RELEASE_CHANNEL: ${{ inputs.release_type }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Validate release + uses: braintrustdata/sdk-actions/actions/release/lang/py/validate@71731d4a726df3e3bf42e50c2da4a75842b038dd with: - ref: ${{ env.COMMIT_SHA }} - fetch-depth: 0 - - name: Set up mise - uses: jdx/mise-action@7e36c90d9ab29c415a2384db3006f3ec8a8cc654 # v4.2.4 - with: - cache: true - experimental: true - - name: Build and verify - env: - BRAINTRUST_RELEASE_CHANNEL: ${{ env.RELEASE_TYPE }} - BRAINTRUST_VERSION_OVERRIDE: ${{ env.VERSION }} - run: | - mise exec -- make -C py install-dev verify-build - - name: Upload build artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: python-sdk-dist - path: py/dist/ - retention-days: 5 - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 - with: - packages-dir: py/dist/ - - - name: Create local release tag - run: | - if ! git rev-parse "$RELEASE_TAG" >/dev/null 2>&1; then - git tag "$RELEASE_TAG" "$COMMIT_SHA" - fi - - # Create GitHub Release - - name: Generate release notes - id: release_notes - run: | - RELEASE_NOTES=$(.github/scripts/generate-release-notes.sh "${{ env.RELEASE_TAG }}" "py/") - echo "notes<> $GITHUB_OUTPUT - echo "$RELEASE_NOTES" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - echo "release_name=Python SDK v${VERSION}" >> $GITHUB_OUTPUT - - - name: Create GitHub Release - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - RELEASE_NOTES: ${{ steps.release_notes.outputs.notes }} - RELEASE_NAME: ${{ steps.release_notes.outputs.release_name }} + sha: ${{ inputs.sha }} + dry_run: ${{ inputs.dry_run }} + working_directory: py + python_version: .tool-versions + release_tag: ${{ needs.configure.outputs.release_tag }} + on_release_branch: ${{ needs.configure.outputs.on_release_branch }} + enforce_release_branch: ${{ inputs.release_type == 'stable' }} + notes: ${{ needs.configure.outputs.notes }} + already_published: ${{ needs.configure.outputs.already_published }} + build_command: make install-dev verify-build + + request-approval: + needs: [configure, validate] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: {} + steps: + - name: Request release approval + uses: braintrustdata/sdk-actions/actions/release/request-approval@71731d4a726df3e3bf42e50c2da4a75842b038dd with: - script: | - await github.rest.repos.createRelease({ - 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 - }); - - build-and-publish: - needs: validate - if: needs.validate.result == 'success' && (needs.validate.outputs.release_type != 'stable' || needs.validate.outputs.dry_run == 'true') - runs-on: ubuntu-latest + packages: '{"packages":[${{ needs.configure.outputs.package }}]}' + sha: ${{ inputs.sha }} + branch: ${{ needs.configure.outputs.branch }} + on_release_branch: ${{ needs.configure.outputs.on_release_branch }} + commit_message: ${{ needs.configure.outputs.commit_message }} + dry_run: ${{ inputs.dry_run }} + slack_token: ${{ secrets.SLACK_BOT_TOKEN }} + slack_channel: ${{ vars.SLACK_SDK_RELEASE_CHANNEL }} + slack_mention: '@sdk-eng' + + build-and-ship: + needs: [configure, validate, request-approval] + runs-on: ubuntu-24.04 timeout-minutes: 20 + environment: ${{ inputs.dry_run && 'publish-dry-run' || 'publish' }} permissions: contents: write - id-token: write # Required for PyPI trusted publishing - + id-token: write # OIDC trusted publishing + PEP 740 attestations + attestations: write # signed SBOM attestation 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ env.COMMIT_SHA }} - fetch-depth: 0 - - name: Set up mise - uses: jdx/mise-action@7e36c90d9ab29c415a2384db3006f3ec8a8cc654 # v4.2.4 - with: - cache: true - experimental: true - - name: Build and verify - env: - BRAINTRUST_RELEASE_CHANNEL: ${{ env.RELEASE_TYPE }} - BRAINTRUST_VERSION_OVERRIDE: ${{ env.VERSION }} - run: | - mise exec -- make -C py install-dev verify-build - - name: Upload build artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: python-sdk-dist - path: py/dist/ - retention-days: 5 - - name: Publish to PyPI - if: env.DRY_RUN != 'true' - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 - with: - packages-dir: py/dist/ - - - name: Create local release tag - run: | - if ! git rev-parse "$RELEASE_TAG" >/dev/null 2>&1; then - git tag "$RELEASE_TAG" "$COMMIT_SHA" - fi - - # Create GitHub Release - - name: Generate release notes - id: release_notes - run: | - RELEASE_NOTES=$(.github/scripts/generate-release-notes.sh "${{ env.RELEASE_TAG }}" "py/") - echo "notes<> $GITHUB_OUTPUT - echo "$RELEASE_NOTES" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - echo "release_name=Python SDK v${VERSION}" >> $GITHUB_OUTPUT - - - name: Create GitHub Release - if: env.DRY_RUN != 'true' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - RELEASE_NOTES: ${{ steps.release_notes.outputs.notes }} - RELEASE_NAME: ${{ steps.release_notes.outputs.release_name }} - with: - script: | - await github.rest.repos.createRelease({ - 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: 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-stable, build-and-publish] - if: always() && (needs.build-and-publish-stable.result == 'success' || needs.build-and-publish.result == 'success') - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Post to Slack on success - uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0 - with: - method: chat.postMessage - token: ${{ secrets.SLACK_BOT_TOKEN }} - payload: | - channel: C0ABHT0SWA2 - 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: "${{ needs.validate.outputs.dry_run == 'true' && '🧪 Python SDK Dry Run Succeeded' || '✅ Python SDK Published' }}" - - type: "section" - text: - type: "mrkdwn" - 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_name == 'workflow_dispatch' && github.event.inputs.ref || github.ref_name, 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-stable, build-and-publish] - if: always() && (needs.validate.result == 'failure' || needs.build-and-publish-stable.result == 'failure' || needs.build-and-publish.result == 'failure') - runs-on: ubuntu-latest - timeout-minutes: 5 + BRAINTRUST_RELEASE_CHANNEL: ${{ inputs.release_type }} steps: - - name: Post to Slack on failure - uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0 + - name: Build and ship + uses: braintrustdata/sdk-actions/actions/release/lang/py/build-and-ship@71731d4a726df3e3bf42e50c2da4a75842b038dd with: - method: chat.postMessage - token: ${{ secrets.SLACK_BOT_TOKEN }} - payload: | - channel: C0ABHT0SWA2 - text: "🚨 Python SDK release failed" - blocks: - - type: "header" - text: - type: "plain_text" - text: "🚨 Python SDK Release Failed" - - type: "section" - text: - type: "mrkdwn" - text: "*Release type:* ${{ needs.validate.outputs.release_type || (github.event_name == 'workflow_dispatch' && github.event.inputs.release_type || 'auto') }}\n*Ref:* ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.ref_name }}\n*Commit:* ${{ needs.validate.outputs.commit_sha || github.sha }}\n\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run>" + sha: ${{ inputs.sha }} + working_directory: py + python_version: .tool-versions + build_command: make install-dev verify-build + dry_run: ${{ inputs.dry_run }} + release_tag: ${{ needs.configure.outputs.release_tag }} + already_published: ${{ needs.configure.outputs.already_published }} + github_release: ${{ needs.configure.outputs.github_release }} + version: ${{ needs.configure.outputs.version }} + package_name: braintrust + label: braintrust + notes: ${{ needs.configure.outputs.notes }} + prev_release: ${{ needs.configure.outputs.prev_release }} + branch: ${{ needs.configure.outputs.branch }} + on_release_branch: ${{ needs.configure.outputs.on_release_branch }} + slack_token: ${{ secrets.SLACK_BOT_TOKEN }} + slack_channel: ${{ vars.SLACK_SDK_RELEASE_CHANNEL }} + emoji: ':python:' diff --git a/AGENTS.md b/AGENTS.md index e89f08358..954d58849 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -295,10 +295,10 @@ Stable Python SDK releases: 1. Run the `Prepare Stable Python SDK Release` workflow with a stable `X.Y.Z` version. 2. Review and merge the generated `release/py-sdk-v` PR. -3. The merge triggers `Publish Python SDK`; the actual PyPI publish job is gated by the `pypi-publish` GitHub environment. -4. After approval, the workflow publishes to PyPI and creates the `py-sdk-v` GitHub Release tag and release. +3. Run `Publish Python SDK` manually with the full SHA of the version-bump merge commit and `release_type=stable`. +4. Approve the `publish` environment; the workflow then publishes to PyPI and creates the `py-sdk-v` GitHub Release tag and release. -Prereleases stay on the manual `Publish Python SDK` path, but do not require a committed version bump: run the workflow against `main` or a commit on `main` with `release_type=prerelease` and the `version` input set to `X.Y.Zrc1`, `X.Y.Za1`, or `X.Y.Zb1`. Prereleases are not gated by the `pypi-publish` environment. +Prereleases also use the manual `Publish Python SDK` workflow and require the prerelease version to be committed at the supplied SHA. Use `release_type=prerelease` with a version such as `X.Y.Zrc1`, `X.Y.Za1`, or `X.Y.Zb1`; prerelease branches are allowed with a warning. Prereleases are gated by the `publish` environment, and dry runs are gated by `publish-dry-run`. Do not create or push release tags locally. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2c4c10890..c743c3015 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -251,7 +251,7 @@ Main workflows: - `langchain-py-test.yaml`: LangChain integration tests - `adk-py-test.yaml`: ADK integration tests - `prepare-release.yml`: stable Python SDK version-bump PR creation -- `publish-py-sdk.yaml`: PyPI release, including stable release PR merges +- `publish-py-sdk.yaml`: manually dispatched PyPI releases from an explicit commit SHA - `test-publish-py-sdk.yaml`: TestPyPI release validation CI uses committed HTTP VCR cassettes and Claude Agent SDK subprocess cassettes, so forks do not need provider API secrets for normal replayed test runs. @@ -260,9 +260,9 @@ CI uses committed HTTP VCR cassettes and Claude Agent SDK subprocess cassettes, See `docs/publishing.md` for the full Python SDK publishing playbook. -Stable releases are started from GitHub Actions by running `Prepare Stable Python SDK Release` with a stable version such as `0.22.0`. That workflow opens a `release/py-sdk-v` PR that updates `py/src/braintrust/version.py`. Merging the PR triggers `Publish Python SDK`. The stable PyPI publish job requires approval through the `pypi-publish` GitHub environment, then publishes to PyPI and creates the `py-sdk-v` GitHub Release tag and release. +Stable releases start by running `Prepare Stable Python SDK Release` with a version such as `0.22.0`. That workflow opens a `release/py-sdk-v` PR that updates `py/src/braintrust/version.py`. After merging the PR, run `Publish Python SDK` manually with the full merge commit SHA and `release_type=stable`. The publish job requires approval through the `publish` GitHub environment, then publishes to PyPI and creates the `py-sdk-v` GitHub Release tag and release. -Prereleases use the manual `Publish Python SDK` workflow without a committed version bump: run the workflow against `main` or a commit on `main` with `release_type=prerelease` and the `version` input set to a prerelease version such as `0.22.0rc1`. Prereleases are not gated by the `pypi-publish` environment. +Prereleases also use `Publish Python SDK` and require a committed prerelease version, such as `0.22.0rc1`, at the supplied SHA. Prerelease branches are allowed with a warning. Prereleases use the `publish` environment; dry runs use `publish-dry-run`. Do not create or push release tags locally. diff --git a/docs/publishing.md b/docs/publishing.md index ee0b08522..2dd548983 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -4,64 +4,54 @@ The Python SDK is published from GitHub Actions. Do not use a local tag-push scr ## Stable PyPI release flow -Stable releases use a two-step GitHub Actions flow: a version-bump PR, then an approval-gated publish after the PR is merged. +Stable releases use a version-bump PR followed by a manually dispatched, approval-gated publish. 1. Run the `Prepare Stable Python SDK Release` workflow in GitHub Actions with the stable version to release, for example `0.22.0`. 2. The workflow validates the version, updates `py/src/braintrust/version.py`, and opens a PR from `release/py-sdk-v`. 3. Review and merge the PR into `main`. -4. Merging the release PR triggers `Publish Python SDK`. -5. The stable publish job waits for approval in the `pypi-publish` GitHub environment. -6. After approval, the workflow builds/verifies the package, publishes to PyPI, and creates the `py-sdk-v` GitHub Release tag and release. +4. Copy the full SHA of the version-bump merge commit on `main`. +5. Run `Publish Python SDK` with `release_type=stable`, that commit SHA, and `dry_run=false`. +6. Approve the `publish` environment. +7. The workflow builds and verifies the package, generates and attests a CycloneDX SBOM, publishes to PyPI with trusted publishing, and creates the `py-sdk-v` GitHub Release. -The stable version must match `X.Y.Z`. Stable releases are published from the merge commit of the release PR. +The stable version must match `X.Y.Z`. Stable releases are published from the merge commit of the version-bump PR. ## Prereleases -Prereleases stay manual through the `Publish Python SDK` workflow. +Prereleases use the same manually dispatched workflow, but the prerelease version must already be committed at the release SHA. -Run `Publish Python SDK` with: +1. Create a prerelease branch and commit `py/src/braintrust/version.py` with a version such as `0.22.0rc1`, `0.22.0a1`, or `0.22.0b1`. +2. Run `Publish Python SDK` with: + - `release_type=prerelease` + - `sha` set to the full commit SHA containing the version bump + - `prev_release` set optionally to the previous tag or prerelease anchor + - `dry_run=false` +3. Approve the `publish` environment. -- `ref=main` or the exact commit SHA to build from -- `release_type=prerelease` -- `version` set to the prerelease version to publish, for example `0.22.0rc1` -- `dry_run=false` - -Do not bump `py/src/braintrust/version.py` for prereleases. The workflow validates the requested prerelease version and passes it to the build as a version override. - -Prerelease versions must match `X.Y.Zrc1`, `X.Y.Za1`, or `X.Y.Zb1`. Prereleases publish to the normal PyPI package and are marked as prereleases on the GitHub Release. They do not use the stable release PR/tag workflow, do not require a committed version bump, and do not require `pypi-publish` environment approval. +Prereleases publish to the normal PyPI package, but do not create a git tag or GitHub Release. A prerelease SHA outside `main` produces a warning rather than failing validation. 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. ## Publish Python SDK workflow details -`Publish Python SDK` can be triggered by: - -- a merged `release/py-sdk-v*` PR -- a pushed `py-sdk-v*` tag, for rerunning or repairing a release -- manual `workflow_dispatch` +`Publish Python SDK` is triggered manually through `workflow_dispatch`. Its inputs are: -Manual inputs are: +- `release_type`: `stable` or `prerelease`. Defaults to `stable`. +- `sha`: the full commit SHA containing the version to release. The version cannot be overridden. +- `prev_release`: an optional tag or SHA to use as the release-notes anchor. +- `dry_run`: build and validate without publishing or tagging. Defaults to `false`. -- `ref`: the branch, tag, or commit SHA to release. Defaults to `main`. -- `release_type`: `stable`, `prerelease`, or `auto`. Defaults to `stable`. -- `version`: the version to publish for manual prerelease runs, for example `0.22.0rc1`. Stable releases read `py/src/braintrust/version.py`. -- `dry_run`: validate and build without actually publishing. Defaults to `false`. +The workflow uses commit-pinned actions from [`braintrustdata/sdk-actions`](https://github.com/braintrustdata/sdk-actions) to: -The workflow will: +1. Check out the requested SHA and read the package version from `py/src/braintrust/version.py`. +2. Require stable release SHAs to be on `main`; prerelease SHAs outside `main` produce a warning instead. +3. Check PyPI availability and ensure the release tag does not already exist. +4. Generate release notes and post the release approval summary. +5. Build and verify the package with `make -C py install-dev verify-build`. +6. Generate a CycloneDX SBOM and, for real publishes, create a signed SBOM attestation. +7. If `dry_run=false`, publish to PyPI through OIDC trusted publishing and create the stable GitHub Release with the SBOM attached. -1. Check out the release PR merge commit, requested ref, or pushed tag. -2. Validate that the selected commit is on `main`. -3. Resolve the package version from the manual `version` input or 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` - - `auto` infers stable vs prerelease from the version -5. Verify that the version is not already published on PyPI and, for manual non-tag runs, that the release tag does not already exist. -6. Build and verify the package with `make -C py install-dev verify-build`. -7. Upload the built distribution artifacts for inspection. -8. If `dry_run=false`, publish to PyPI and create the corresponding GitHub Release. - -For stable, non-dry-run publishes, the job that publishes to PyPI runs in the `pypi-publish` GitHub environment. Configure required reviewers on that environment to approve stable releases before publishing. +The `build-and-ship` job always runs behind an environment approval gate. Real stable and prerelease publishes use the `publish` environment; dry runs use `publish-dry-run`. Configure required reviewers on both environments. The job needs `contents: write`, `id-token: write`, and `attestations: write` permissions. ## TestPyPI releases @@ -115,20 +105,63 @@ Just like the main PyPI workflow, the TestPyPI workflow also supports `dry_run=t ## Dry runs -Use `dry_run=true` when you want to exercise the release workflow without publishing anything. +Use `dry_run=true` when you want to exercise the release workflow without publishing anything. Dry runs require approval in the `publish-dry-run` GitHub environment. 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 +- validates the selected SHA and committed version +- reports whether the release commit is on `main` +- checks the tag and PyPI version, reporting existing releases as warnings - builds the package and runs `make -C py install-dev verify-build` -- uploads `py/dist/` as a workflow artifact -- generates release notes +- generates a CycloneDX SBOM +- generates release notes and release summaries A dry run does not: - publish to PyPI - create the `py-sdk-v` tag - create a GitHub Release -- require `pypi-publish` environment approval + +--- + +## Maintenance + +`.github/workflows/publish-py-sdk.yaml` is generated from the `release/py/turnkey` template in [`braintrustdata/sdk-actions`](https://github.com/braintrustdata/sdk-actions). The shared actions are pinned by commit SHA. Do not hand-edit their pins to pick up upstream changes; use the workflow generator so it can preserve this repository's customizations. + +### Updating sdk-actions + +From an `sdk-actions` checkout with its mise tools installed: + +```bash +WF=/path/to/braintrust-sdk-python/.github/workflows/publish-py-sdk.yaml +REF=$(git rev-parse origin/main) +mise exec -- bin/workflow compare --ref "$REF" "$WF" +mise exec -- bin/workflow update --ref "$REF" "$WF" +mise exec -- bin/workflow validate "$WF" +``` + +Pass the resolved commit SHA through `--ref`; `compare` otherwise uses the ref already recorded in the workflow header. `update` performs a three-way merge of upstream template changes, retains local edits, and updates the action pins and provenance header. + +After updating: + +1. Review the workflow diff and the upstream sdk-actions changes between the old and new refs. A major change to the header's `version` field indicates a breaking release-action change. +2. Run `bash scripts/ensure-pinned-actions.sh` and the workflow validator. +3. Open a PR and complete an approved `dry_run` before the next real release. + +The `# sdk-actions: {...}` header at the top of the workflow records the template, pinned ref, and generation parameters. Keep it intact so `compare` and `update` can reconstruct the upstream baseline. + +### Local workflow customizations + +`compare` reports the intentional differences from the turnkey template. Preserve these when updating: + +- the dispatch instruction reminding releasers to commit the version before publishing +- release-channel templating and wheel verification through `BRAINTRUST_RELEASE_CHANNEL` and `make install-dev verify-build`, with extended build timeouts +- the existing `py-sdk-v{version}` tag format +- hard enforcement of `main` for stable releases while prereleases remain warning-only +- the `@sdk-eng` mention in approval notifications + +### Required configuration + +- GitHub environments `publish` and `publish-dry-run`, with required reviewers configured. Real stable and prerelease publishes use `publish`; dry runs use `publish-dry-run`. +- A PyPI trusted publisher for `braintrust`: owner `braintrustdata`, repository `braintrust-sdk-python`, workflow `publish-py-sdk.yaml`, environment `publish`. +- Repository or organization secret `SLACK_BOT_TOKEN` and variable `SLACK_SDK_RELEASE_CHANNEL`, with the variable visible to this repository.